🗓️ 2023. 09. 27
⏱️ 10

테스트 프레임워크 Jest 컨트리뷰션 경험기

한두 줄의 보탬

발견하기

언제나 신규 프로젝트를 들어가기 전에 데모 프로젝트로 스케치를 해보는 편인데, 테스트 코드를 작성하던 중 이상한 점을 발견했다.

// 흔한 회원가입 로직
@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로 바꿔봤다.
AuthServiceValidationException을 던지도록 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 메서드 설명부터 읽어봤다.
(toThrowtoThrowError는 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)이

  1. Error 객체라면 해당 객체의 message와 던져진 에러의 message를 비교한다.
  2. 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)의 messagecause 프로퍼티만 비교한다.
던져진 값이 에러 인스턴스여도 인스턴스의 타입을 검증하고 있지 않기 때문에 이런 문제가 발생했던 것이다.

해결하기

사실 처음엔 '음... 이렇게 돌아가게 구현돼있구나. 달리 방법이 없나? 공식문서 좀 더 봐보자.' 이런 마음으로 우회책을 찾긴 했다.

방안 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 구현부 수정

앞선 방안들이 좀처럼 마음에 들지 않았다.
기대 결과실제 결과가 달랐기에 내가 아닌 다른 사람들도 혼란스러워할 이슈라고 생각했다.
아무리 messagecause 값이 같더라도 서로 다른 클래스의 인스턴스라면 당연히 검증 결과가 달라야하니까 ㅠㅠ;;

내가 예상했던 작동 결과가 실제와 다르고, 다른 사람들도 같은 당혹감을 느낄 거란 생각이 들 때 개선해야겠단 의지가 생긴다.
자연스레 jest 레포지토리를 fork 떠서 코드를 개선했고 Pull Request을 날렸다.
예상보다 훨씬 빠르게 리뷰가 진행됐고 한 번에 approved 됐다! (사실... 되게 기뻤다 ㅎㅎ)

리뷰어의 이 짧은 한 줄만으로도 큰 보상을 받은 기분이었다.리뷰어의 이 짧은 한 줄만으로도 큰 보상을 받은 기분이었다.
그리고 jest repository에서 contributor 마크가 붙었다.그리고 jest repository에서 contributor 마크가 붙었다.

현재 릴리즈 버전이 v29.7.0이니까 v29.7.1에 내 코드가 반영될 것 같다.
→ 그간 변경점이 많았는지 내 커밋은 v30부터 적용된다.

야호!

내 첫 기여는 Nest.js의 캐싱 모듈 오류 수정이었고, 이번이 두 번째 기여다.
모처럼의 오픈소스 활동에 오류를 발견했을 때부터 두근거렸고 코드가 병합됐을 땐 좀처럼 흥분이 가시질 않았다.

내가 작성한 코드가 전세계 어딘가에서 크고 작은 프로젝트에 위치하고 있다는 뿌듯함.
내가 도움받았기에 짧은 코드 한두 줄 보태어 나도 그들의 보탬이 되는 일.

개인의 자랑스러움과 집단에 대한 고마움이 공존할 수 있는 이 감정이, 오픈 소스가 주는 즐거움이 아닐까.

돌아가기
© 2024 VERYCOSY.