kimyu0218
  • [테스트] jest와 supertest로 단위/유닛 테스트 하기
    2024년 02월 03일 00시 18분 21초에 업로드 된 글입니다.
    작성자: @kimyu0218
    한 줄만 고쳤는데요 😥

    개발에 제대로(?) 입문한지 얼마 되지 않았지만 테스트가 중요하다는 말을 정말 많이 들었다. 새로운 기능을 개발하느라 항상 테스트를 미루기 바빴는데 이번 포스팅에서 테스트를 진행해야 하는 이유와 테스트를 작성하는 방법을 알아볼 것이다.

    테스트를 작성해야 하는 이유

    버그 픽스, 리팩토링 등 좋은 의도에서 코드를 수정했을지 몰라도 개발자의 실수로 잘 돌아가던 코드가 에러를 발생시킬 가능성이 항상 도사리고 있다. 그렇다면 코드가 변경되었을 때 의도한 대로 동작하는지 확인할 수 있는 방법은 없을까?

    테스트를 이용하면 코드의 신뢰성을 확인할 수 있다. 코드를 수정한 후 기존에 작성해놓았던 테스트 코드가 실패한다면 이는 코드가 제대로 동작하지 않는다는 걸 의미한다. 즉, 테스트는 새로운 기능을 추가하거나 기존 기능을 변경할 때 예상치 못한 문제를 방지하는 중요한 수단이다.

    테스트의 필요성
    • 빠른 피드백 제공 : 코드를 작성하고 테스트를 진행함으로써 개발자는 자신의 작업이 올바르게 진행되고 있는지 확인할 수 있다. 초기에 문제를 발견할 수 있으므로 전체적인 개발 시간을 단축시킨다.
    • 일종의 명세서 : 테스트 코드를 통해 해당 모듈이나 함수가 어떻게 동작해야 하는지 명확하게 이해할 수 있다.

     

    좋은 테스트를 작성하는 법

    좋은 테스트 코드를 작성하기 위해서는 다음 사항들을 고려해야 한다.

    • 코드가 훼손되면 테스트는 실패*해야 한다.
    • 세부 구현 사항을 변경하더라도 테스트 코드는 변경되지 않아야 한다.
    • 테스트가 실패하면 원인과 문제점을 명확하게 설명해야 한다.
    • 테스트 코드가 무엇을 테스트하고, 어떻게 수행되는 것인지 이해할 수 있어야 한다.
    • 테스트는 쉽고 빠르게 실행할 수 있어야 한다.
    *테스트의 주된 목표는 코드가 의도된 대로 수행하는지 확인하는 것이다. 코드가 훼손되면 테스트는 실패하여 코드에 오류가 있음을 알려야 한다.

     

    jest와 supertest로 테스트하기

    jest와 supertest를 이용하여 현재 진행하고 있는 프로젝트를 테스트 해볼 것이다. 앞서 말했듯이 테스트는 쉽고 빠르게 실행되어야 한다. 이런 점에서 jest는 큰 이점을 갖고 있다고 판단하여 jest를 선택했다.

    jest의 장점
    • 테스트를 병렬로 실행하여 테스트 수행 속도를 높이고, 이는 개발자 생산성 향상으로 이어진다.
    • 실패한 테스트를 먼저 실행하여 빠르게 피드백을 제공한다.
    • 대부분의 프로젝트에서 설정 파일을 작성하지 않고 즉시 사용할 수 있다.
    • 다양한 기능을 내장하고 있기 때문에 별도의 라이브러리를 추가로 설치할 필요가 없다. (assertion, mocking, coverage가 jest에 기본적으로 포함되어 있다!)

     

    jest를 이용한 단위 테스트

    jest로 서비스 코드를 테스트 할 것이다. 하지만 서비스 코드 안에는 데이터베이스에 접근하는 로직이 들어있다.

    ...
    @Injectable()
    export class TarotService {
      constructor(
        @InjectRepository(TarotCard)
        private readonly tarotCardRepository: Repository<TarotCard>, // 데이터베이스에 접근하는 레포지토리!!
        @InjectRepository(TarotResult)
        private readonly tarotResultRepository: Repository<TarotResult>, // 데이터베이스에 접근하는 레포지토리!!
      ) {}
      ...
    }

    데이터베이스를 조작하면 실제 프로세스에 부정적인 결과를 초래할 수 있다. (테스트 데이터가 실제 서비스에 표시되는 등) 따라서 데이터베이스를 테스트 코드로부터 보호해야 한다.
     
    위 문제를 해결하기 위해 목(mock)을 활용할 수 있다. 목은 함수에 대한 호출만 기록하고 어떠한 일도 수행하지 않는 것을 의미한다. 즉, 레포지토리 함수가 호출되긴 하나 데이터베이스에 접근하지 않는 것이다. 이처럼 목을 활용하면 의존성을 시뮬레이션할 수 있다.

    it('해당 번호의 타로 카드가 존재하지 않아 NotFoundException을 반환한다.', async () => {
      [{ cardNo: -1 }, { cardNo: 79 }].forEach(async ({ cardNo }) => {
        const findOneByMock = jest
          .spyOn(tarotCardRepository, 'findOneBy')
          .mockResolvedValueOnce(null);
    
        await expect(
          tarotService.findTarotCardByCardNo(cardNo),
        ).rejects.toThrow(NotFoundException);
        expect(findOneByMock).toHaveBeenCalledWith({
          cardNo: cardNo,
          cardPack: undefined,
        });
      });
    });

    위의 테스트 코드는 타로 카드를 조회하는 메서드 `findTarotCardByCardNo`에 대한 테스트를 진행하고 있다. `spyOn`*을 이용하여 레포지토리의 `findOneBy` 메서드를 목으로 대체한다. 그리고 `mockResolvedValueOnce` 함수를 이용하여 `findOneBy`가 리턴할 값을 지정한다. 이로써 데이터베이스에 접근하지 않고 서비스 코드를 테스트 할 수 있다.

    *spyOn(object, methodName) : object[methodName]에 대한 목을 생성한다.

     

    supertest를 이용한 e2e 테스트

    이번엔 supertest로 e2e 테스트를 작성할 것이다. e2e 테스트는 소프트웨어 시스템 전체를 시뮬레이션하고 테스트하는 방법이다. 
     
    코드를 작성하기 전에 supertest에 대해 간단히 알아보자. supertest는 node 어플리케이션의 API를 테스트하기 위한 라이브러리로, HTTP 요청을 생성하고 응답을 검증할 수 있도록 도와준다.

     describe('잘못된 카드 번호를 받으면 에러를 던진다.', () => {
      [
        {
          scenario: '정수형이 아닌 카드 번호를 받으면 400번 에러를 반환한다.',
          route: '/tarot/card/invalidCardNo',
          status: 400,
        },
        {
          scenario: '존재하지 않는 카드 번호를 받으면 404번 에러를 반환한다.',
          route: '/tarot/card/-1',
          status: 404,
        },
      ].forEach(({ scenario, route, status }) => {
        it(scenario, () => {
          return request(app.getHttpServer()).get(route).expect(status);
        });
      });
    });

    위 코드는 타로 카드를 조회하는 API를 테스트한다. supertest의 `request` 함수를 이용하여 HTTP 요청을 만들고, `get(route)`를 통해 `route`라는 엔드포인트로 GET 요청을 전송한다. (`expect`로 HTTP 응답 코드를 검증하고 있다!)
     
    e2e 테스트의 경우 모킹하지 않고 테스트했다. 목을 작성해본 사람이라면 "이게 실제 서비스와 같다고 할 수 있나?" 하는 의심이 들 것이다.

    🙋‍♂️ : 목은 곧 가짜라는 건데 실제 로직과 다른 거 아닌가요?

    정답이다. 데이터베이스에 사용자를 저장하는 상황을 가정해보자. 데이터베이스에서는 이메일이 중복될 경우 에러가 발생하는데, 목에서는 중복 여부를 확인하지 않고 항상 성공하는 가상의 결과를 반환할 수 있다. 즉, 목의 반환값을 강제로 지정하기 때문에 테스트 코드는 항상 통과한다. 이로 인해 개발자는 코드가 정상적으로 동작한다고 생각하여 배포하고, 배포 시 예상치 못한 에러에 마주하게 된다.

    이러한 문제를 해결하기 위해 e2e 테스트에서는 모킹을 하지 않고 테스트했다. 단, 실제 데이터베이스를 조작하지 않아야 하므로 실제 DB 대신 메모리 DB를 연결해줬다.

    describe('Tarot', () => {
      let app: INestApplication;
      let entityManager: EntityManager;
    
      beforeAll(async () => {
        const moduleRef = await Test.createTestingModule({
          imports: [
            TypeOrmModule.forRoot({
              type: 'sqlite',
              database: ':memory:', // 실제 DB 대신 메모리 DB 사용하기!
              entities: [__dirname + '/../src/**/entities/*.entity.{js,ts}'],
              synchronize: true,
            }),
            TypeOrmModule.forFeature([TarotCard, TarotResult, TarotCardPack]),
          ],
          controllers: [TarotController],
          providers: [TarotService],
        }).compile();
    
        app = moduleRef.createNestApplication();
        entityManager = moduleRef.get(EntityManager);
    
        await app.init();
      });
      ...
    });

    위 코드는 타로 모듈의 e2e 테스트 코드다. 실제 DB 대신 메모리 DB를 주입하여 레포지토리가 메모리 DB에 접근하도록 설정했다. DB에서 특정 데이터를 조회하는 것을 테스트하는 경우에는 `beforeEach`나 `beforeAll`로 메모리 DB에 데이터를 쌓아줬다.
     

    🔗 얼렁뚱땅 테스트 코드 보러가기 : 단위 테스트 & e2e 테스트

    참고자료

     

    'backend > 테스트' 카테고리의 다른 글

    [jest] jest 시작하기 (matcher/setup/teardown/mock)  (0) 2024.02.03
    댓글