발견하기
언제나 신규 프로젝트를 들어가기 전에 데모 프로젝트로 스케치를 해보는 편인데, 테스트 코드를 작성하던 중 이상한 점을 발견했다.
// 흔한 회원가입 로직
@Post('/sign-up')
async signUp(@Body() body: SignUpRequest) {
try {
const newUser = await this.authService.signUp(body);
return new UserDto(newUser);
} catch (err) {
if (err instanceof ValidationException) {
throw new BadRequestException(err, { cause: err });
}
throw new InternalServerErrorException(err, { cause: err });
}
}
// 테스트 코드
it('ValidationException이 발생하면 BadRequestException을 던진다', () => {
// given
const body: SignUpRequest = {
email: 'test@test.com',
name: 'verycosy',
password: '1234',
retypedPassword: '1234',
};
const validationException = new ValidationException('유효성 검증 실패');
when(stubAuthService.signUp(body)).thenThrow(validationException);
// when
const actual = () => sut.signUp(body);
// then
expect(actual).rejects.toThrowError(
new BadRequestException(validationException, {
cause: validationException,
}),
); // PASS ^-^
});
테스트는 정상적으로 수행됐지만 혹시나하는 마음에 검증부에서 예상하던 에러를 BadRequestException
이 아닌 InternalServerErrorException
로 바꿔봤다.
AuthService
가 ValidationException
을 던지도록 stubbing해뒀기 때문에 당연히 테스트가 실패해야 했다.
expect(actual).rejects.toThrowError(
new InternalServerErrorException(validationException, {
cause: validationException,
}),
); // PASS ^-^;;;;???
근데 통과가 된다...??
확인하기
다른 프로젝트를 생성해서 왜 이러는지 간단히 확인해봤다.
class ErrorA extends Error { }
class ErrorB extends Error { }
const throwErrorA = () => {
throw new ErrorA();
};
expect(throwErrorA).toThrowError(ErrorA); // 통과 → 정상
expect(throwErrorA).toThrowError(new ErrorA()); // 통과 → 정상
expect(throwErrorA).not.toThrowError(ErrorB); // 통과 → 정상
expect(throwErrorA).not.toThrowError(new ErrorB()); // 실패 → 비정상
마지막 검증문은 통과되어야 정상인데 테스트 결과는 실패였다. jest에서 에러 인스턴스의 타입을 검증하고 있지 않은 듯 했다.
뜯어보기
내가 모르는 뭔가가 있는 건지 궁금해서 우선 공식 문서의 toThrow 메서드 설명부터 읽어봤다.
(toThrow
와 toThrowError
는 alias 관계다.)
You can provide an optional argument to test that a specific error is thrown:
...
(1) error object: error message is equal to the message property of the object
(2) error class: error object is instance of class
공식 문서의 설명에선 이렇게 설명하고 있다.
기대하는 값(expected)이
Error 객체
라면 해당 객체의message
와 던져진 에러의message
를 비교한다.Error 클래스
라면 던져진 에러가 Error 클래스의 인스턴스인지 확인한다.
아까 오류가 발생했던 검증문에서는 object literal도, Error 클래스도 아닌 Error 인스턴스를 전달하고 있었다.
expect(throwErrorA).not.toThrowError(new ErrorB()); // 실패 → 비정상
공식 문서에서도 에러 인스턴스를 사용하는 케이스에 대한 언급이 없었고, 실제 작동 결과도 문제가 있어보여 직접 toThrowError
구현부를 살펴보기로 했다.
// packages/expect/src/toThrowMatchers.ts
const toThrowExpectedObject = (
matcherName: string,
options: MatcherHintOptions,
thrown: Thrown | null,
expected: Error,
): SyncExpectationResult => {
const expectedMessageAndCause = createMessageAndCause(expected);
const thrownMessageAndCause =
thrown === null ? null : createMessageAndCause(thrown.value);
// 테스트 통과 여부를 판단하는 로직
const pass =
thrown !== null &&
thrown.message === expected.message &&
thrownMessageAndCause === expectedMessageAndCause;
// ...
};
구현 코드에서는 기대한 값(expected)과 던져진 값(thrown)의 message
와 cause
프로퍼티만 비교한다.
던져진 값이 에러 인스턴스여도 인스턴스의 타입을 검증하고 있지 않기 때문에 이런 문제가 발생했던 것이다.
해결하기
사실 처음엔 '음... 이렇게 돌아가게 구현돼있구나. 달리 방법이 없나? 공식문서 좀 더 봐보자.' 이런 마음으로 우회책을 찾긴 했다.
방안 1 - 타입과 값을 따로 검증
첫 번째 방안은 타입과 값을 각각 확인하는 방식이다. 두 번에 걸쳐 검증을 해야 하기 때문에 예외를 값으로 가져올 수 있어야 한다. 그를 위해 try~catch
가 필수적이다.
try {
// ...
} catch(err) {
expect(err).toBeInstanceOf(BadRequestException);
expect(err).toMatchObject({
message: '유효성 검증 실패',
cause: validationException
});
}
일단 타입과 값을 따로 검증한다는 게 영 번거롭게 느껴진다. 게다가 향로(jojoldu)님이 블로그에 잘 소개해주셨듯이 catch보다는 expect로 검증하는 편이 좋다. 만약 try
구문 안에서 에러가 발생하지 않으면 테스트가 통과돼버리기 때문이다.
방안 2 - toStrictEqual로 검증
값과 타입을 동시에 검증해야 하기 때문에 toStrictEqual
을 사용할 수도 있다. 하지만 이 방안엔 2가지 문제가 있다.
첫 번째 문제는 이 역시 try~catch
가 강제된다는 점이다. 비동기 함수에서 던져진 에러는 try~catch
를 쓰지 않고도 rejects
로 접근할 수 있다. 반면 동기 함수에서 던져진 에러는 try~catch
를 통해야만 에러에 접근할 수 있다. 때문에 비동기 함수에서만 부분적으로 적용할 수 있다.
const throwSync = () => {
throw new Error();
}
const throwAsync = async () => {
throw new Error();
}
const actualAsync = () => throwAsync();
await expect(actualAsync).rejects.toStrictEqual(new Error()); // 비동기 함수에선 try~catch 없이 사용 가능
const actualSync = () => throwSync()
// expect(actualSync).toStrictEqual(new Error()); ← 함수 자체가 호출되지 않음
// expect(actualSync()).toStrictEqual(new Error()); ← 에러가 즉각 발생하여 테스트 불가
try {
throwSync();
} catch(err) {
expect(err).toStrictEqual(new Error()); // 동기 함수에선 try~catch가 강제됨
}
두 번째 문제는 읽기 좋은 테스트 코드
가 아니란 것이다. 위 예시코드에서도 느껴지듯이 toStrictEqual
라는 함수 이름에서는 예외가 던져졌다
는 맥락이 전달되지 않는다.
방안 3 - toThrowError 구현부 수정
앞선 방안들이 좀처럼 마음에 들지 않았다.
기대 결과와 실제 결과가 달랐기에 내가 아닌 다른 사람들도 혼란스러워할 이슈라고 생각했다.
아무리 message
나 cause
값이 같더라도 서로 다른 클래스의 인스턴스라면 당연히 검증 결과가 달라야하니까 ㅠㅠ;;
내가 예상했던 작동 결과가 실제와 다르고, 다른 사람들도 같은 당혹감을 느낄 거란 생각이 들 때 개선해야겠단 의지가 생긴다.
자연스레 jest 레포지토리를 fork 떠서 코드를 개선했고 Pull Request을 날렸다.
예상보다 훨씬 빠르게 리뷰가 진행됐고 한 번에 approved 됐다! (사실... 되게 기뻤다 ㅎㅎ)
현재 릴리즈 버전이 v29.7.0이니까 v29.7.1에 내 코드가 반영될 것 같다.
→ 그간 변경점이 많았는지 내 커밋은 v30부터 적용된다.
야호!
내 첫 기여는 Nest.js
의 캐싱 모듈 오류 수정이었고, 이번이 두 번째 기여다.
모처럼의 오픈소스 활동에 오류를 발견했을 때부터 두근거렸고 코드가 병합됐을 땐 좀처럼 흥분이 가시질 않았다.
내가 작성한 코드가 전세계 어딘가에서 크고 작은 프로젝트에 위치하고 있다는 뿌듯함.
내가 도움받았기에 짧은 코드 한두 줄 보태어 나도 그들의 보탬이 되는 일.
개인의 자랑스러움과 집단에 대한 고마움이 공존할 수 있는 이 감정이, 오픈 소스가 주는 즐거움이 아닐까.