[NestJS] Testing _ 공식문서

김형주·2021년 10월 9일
1

Backend Study

목록 보기
18/19
post-thumbnail

Testing

테스트가 자동화되어있으면 차후에 유지관리 측면에서 혹은 문제가 발생하거나 했을 때의 개발차원의 자원이 과도하게 들어가는 것을 막을 수 있기때문에 필수적으로 취급된다. 자동화를 통해 개발중에 개별 유닛으로 테스트하거나 테스트 스위트를 쉽게 빠르고 반복할 수 있다. 이는 릴리즈의 품질과 성능 목표 충족도를 확인하는데 크게 도움이 된다. 자동화는 적용범위를 늘리고 개발자에게 더 빠른 피드백 루프를 제공한다. 자동화는 개별 개발자의 생산성도 높여주고, 소스 코드 제어 체크인, 기능 통합 및 버전 릴리즈와 같은 중요한 개발 수명주기 단계에서 테스트가 실행되도록 한다.

이러한 테스트는 단위 테스트, 종단간(e2e) 테스트, 통합 테스트등 다양한 유형에 걸쳐있는 경우가 많다. 단위 테스트의 경우 개별 모듈의 성능을 검사하고 체크하며, e2e테스트는 성능 한계점까지 커버리지를 확인하는 테스트고, 실제 환경과 동일하게 차용해서 확실함을 높인 테스트다. 통합 테스트의 경우 이러한 테스트를 종합적으로 테스트로 구축해서 전반적인 서비스 로직 흐름이 원활하게 돌아가는지 확인하기 위한 테스트다. 테스트가 필수적인 것은 확실히 알 수 있지만, 이를 초기 세팅하는 과정이 귀찮고 지루할 수 있다. Nest는 효과적인 테스트를 위한 다양한 툴과 모범 사례를 공유하고 있다.

Nest

  • 구성 요소(Module)에 대한 기본 단위 테스트애플리케이션에 대한 e2e테스트자동으로 구축해준다.
  • 기본 도구 제공(예 : 격리 모듈/어플리케이션 로더를 빌드하는 테스터)
  • JestSupertest와 통합을 즉시 제공하는 동시에 테스트 도구에 대해 독립적이다.
  • 구성 요소(Module)을 쉽게 모의(Mock)하기 위해서 테스트 환경에서 Nest 종속성 주입 시스템을 사용할 수 있다.

언급했듯이 Nest는 특정 도구를 강제하지 않으므로 원하는 테스트 프레임워크를 사용할 수 있다. 필요한 요소(예: 테스트 실행기)를 교체하기만하면 Nest의 기성 테스트의 이점을 계속 누릴 수 있다.

Installation

$ npm i --save-dev @nestjs/testing

Unit Testing

예제를 통해서, (공식문서에는 Cats를 이용해서 만든 모듈로 존재하는데, 이를 User로 바꾼다.) 클래스 테스트를 확인해보자. 앞서 언급했듯이 기본 테스트 프레임워크로 Jest가 제공된다. 테스트 실행자 역할을 하며 Mock, Spy 등에 도움되는 assert 함수 및 테스트 이중 유틸이 제공된다. 기본 테스트에서 이런 클래스를 수동으로 인스턴스화해서, 컨트롤러와 서비스가 API대로 동작하는지 확인한다.

users.controller.spec.ts

import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let UsersController;
  let UsersService;

  beforeEach(() => {
    UsersService = new UsersService();
    UsersController = new UsersController(UsersService);
  });

  describe('findAll', () => {
    it('should return an array of users', async () => {
      const result = ['test'];
      jest.spyOn(UsersService, 'findAll').mockImplementation(() => result);

      expect(await catsController.findAll()).toBe(result);
    });
  });
});

테스트 파일은 users.controller.spec.ts이나, users.service.test.ts와 같은 네이밍으로 파일이름을 지어야한다.

위의 샘플코드는 사소한 것이기 때문에 실제 Nest 테스트와는 다르다. 위엔 실제로 의존성 주입도 되어있지 않다. (UsersService의 인스턴스를 UsersController에 전달한다는 점에 유의하자!) 테스트중인 클래스를 수동으로 인스턴스화하는 이러한 형식의 테스트는 프레임워크와 독립적이므로 종종 격리된 테스트라고 합니다. Nest기능을 보다 광범위하게 사용하는 애플리케이션을 테스트하는데 도움이 되는 기능들을 살펴보자.

Testing utilities

@nestjs/testing 패키지는 보다 강력한 테스트 프로세스를 가능하게 하는 유틸리티 세트를 제공한다. 내장된 Test클래스를 사용하여 이전 예제를 다시 작성해보자.

users.conroller.spec.ts

import { Test } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let usersController: UsersController;
  let userService: UsersService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
        controllers: [UsersController],
        providers: [UsersService],
      }).compile();

    usersService = module.get<UsersService>(UsersService);
    usersController = module.get<UsersController>(UsersController);
  });

  describe('findAll', () => {
    it('should return an array of users', async () => {
      const result = ['test'];
      jest.spyOn(usersService, 'findAll').mockImplementation(() => result);

      expect(await usersController.findAll()).toBe(result);
    });
  });
});

Test클래스는 기본적으로 전체 Nest runtime을 Mock하는 애플리케이션 실행 컨텍스트를 제공하는 데 유용하지만, Mocking 및 재정의를 포함하여 클래스 인스턴스를 쉽게 관리할 수 있는 Hook을 제공한다. Test 클래스에는 모듈 메타데이터 객체를 인수로 사용하는 createTestingModule() 메서드가 있다. (@Module() 데코레이터에 전달하는 동일한 객체). 이 메서드는 몇가지 메서드를 제공하는 TestingModule 인스턴스를 반환한다. 단위 테스트에서 중요한 것은 compile()메서드다. 이 메소드는 의존성이 있는 모듈을 bootstrap하고(애플리케이션이 NestFactory.create()를 사용하여 기존 main.ts 파일에서 bootstrap되는 방식과 유사함) 테스트할 준비가 된 모듈을 반환한다.

compile()메서드는 비동기이므로 컴파일까지 기다려야한다. 모듈이 컴파일 되면 get()메서드를 사용하여 선언하는 정적 인스턴스(controller 및 provider)를 검색할 수 있습니다.

TestingModule모듈 참조 클래스에서 상속되므로 범위가 지정된 프로바이더(임시 또는 요청 범위)를 동적으로 확인하는 기능이다. resolve()메서드를 사용하여 이 작업을 수행한다.(get()메서드는 정적 인스턴스만 검색할 수 있다.)

const moduleRef = await Test.createTestingModule({
  						controllers: [UsersController],
  						providers: [UsersService],
					}).compile();

usersService = await moduleRef.resolve(UsersService);

resolve()메서드는 자체 DI 컨테이너 하위 트리에서 프로바이더의 고유한 인스턴스를 반환한다. 각 하위트리에는 고유한 컨텍스트 식별자가 있다. 따라서 이 메서드를 두번 이상 호출하고 인스턴스 참조를 비교하면 같지 않음을 알 수 있다.

프로바이더의 프로덕션 버전을 사용하는 대신 테스트 목적으로 Custum Provider로 재정의할 수 있다. 예를 들어 DB에 연결하는 대신 데이터베이스 서비스를 Mock할 수 있다. 다음 섹션에서 재정의를 다루겠지만, 단위 테스트에서 사용할 수 있다.

End-to-end testing

개별 모듈 및 클래스에 초점을 맞춘 단위 테스트와 달리 엔드-투-엔드(e2e)테스트는 최종 사용자가 프로덕션 레벨에서 가질 수 있는 수준보다 훨씬 더 디테일하고 총체적인 수준에서의 클래스 및 모듈 테스트다. system, application의 scale이 커질수록 각 API 엔드 포인트의 end-2-end 동작을 수동으로 테스트하기 어렵다. 자동화된 e2e 테스트는 시스템 전반적인 동작이 정확하고 프로젝트 요구사항을 충족하는지 확인하는데 도움된다. e2e 테스트를 수행하기 위해 방금 단위 테스트에서 다룬 것과 유사하게 사용한다. Nest를 사용하면 supertest 라이브러리를 사용해서 HTTP 요청을 쉽게 시뮬레이션 할 수 있다.

users.e2e-spec.ts

import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { UsersModule } from '../../src/users/users.module';
import { UsersService } from '../../src/users/users.service';
import { INestApplication } from '@nestjs/common';

describe('Users', () => {
  let app: INestApplication;
  let usersService = { findAll: () => ['test'] };

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [UsersModule],
    })
      .overrideProvider(UsersService)
      .useValue(catsService)
      .compile();

    app = moduleRef.createNestApplication();
    await app.init();
  });

  it(`/GET users`, () => {
    return request(app.getHttpServer())
      .get('/users')
      .expect(200)
      .expect({
        data: catsService.findAll(),
      });
  });

  afterAll(async () => {
    await app.close();
  });
});

이 예에서는 앞에서 설명한 몇가지 개념을 기반으로 한다. 이전에 사용한 compile() 메서드 외에도 이제 createNestApplication() 메서드를 사용하여 전체 Nest 런타임 환경을 인스턴스화한다. 실행중인 앱에 대한 참조를 app변수에 저장하여 HTTP요청을 시뮬레이션하는데 사용할 수 있다.

Supertest의 request() 함수를 사용하여 HTTP 테스트를 시뮬레이션한다. 이러한 HTTP 요청이 실행중인 Nest 앱으로 라우팅되기를 원하므로 request() 함수에 Nest의 기반이 되는 HTTP 리스터에 대한 참조를 전달한다.(이는 Express 플랫폼에서 사용할 수 있다.) 따라서 구성 request(app.getHttpServer()).request() 호출은 이제 Nest 앱에 연결되어 래핑된 HTTP 서버를 전달한다. 이 서버는 실제 HTTP 요청을 시뮬레이션하는 메소드를 노출합니다. 예를 들어, request(...).get('/cats')를 사용하면 네트워크를 통해 들어오는 get '/cats'와 같은 실제 HTTP 요청과 동일한 Nest앱에 대한 요청이 시작된다.

이 예에서는 테스트할 수 있는 하드코딩된 값을 간단히 반환하는 UsersService의 대체(test-double) 구현도 제공한다. 이러한 대체 구현을 제공하려면 overrideProvider()를 사용해야 한다. 마찬가지로 Nest는 각각 overrideGuard(), overrideInterceptor(), overrideFilter()overridePipe() 메서드를 사용하여 가드, 인터셉터, 필터 및 파이프를 재정의하는 메서드를 제공한다.

각 재정의 메서드는 커스텀 프로바이더에 대해 설명된 메서드를 미러링하는 3가지 메서드가 있는 객체를 반환한다.

  • useClass: 객체(프로바이더, 가드 등)을 재정의할 인스턴스를 제공하기 위해 인스턴스화될 클래스를 제공한다.
  • useValue: 객체를 재정의할 인스턴스를 제공한다.
  • useFactory: 객체를 재정의할 인스턴스를 반환하는 함수를 제공한다.

각 재정의 메서드 유형은 차례로 TestingModule 인스턴스를 반환하므로 fluent style의 다른 메서드와 연결할 수 있다. 이러한 체인의 끝에 compile()을 사용하여 Nest가 모듈을 인스턴스화하고 초기화하도록 해야한다.

또한 때로는 사용자 정의 Logger를 제공하고 싶을 수도 있다. 테스트가 실행될 때(예: CI 서버에서). setLogger() 메소드를 사용하고 LoggerService 인터페이스를 충족하는 객체를 전달하여 TestModuleBuilder에 테스트중에 기록하는 방법을 지시한다. 기본적으로 오류로그만 콘솔에 기록된다.
컴파일된 모듈에는 다음 표에 설명된 몇가지 유용한 메서드가 있다.

createNestApplication()

주어진 모듈을 기반으로 Nest 애플리케이션(INestApplication인스턴스)을 만들고 반환한다. init() 메서드를 사용하여 애플리케이션을 수동으로 초기화해야 한다.

createNestMicroservice()

지정된 모듈을 기반으로 Nest 마이크로 서비스(INestMicroservice 인스턴스)를 만들고 반환한다.

get()

애플리케이션 환경에서 사용 가능한 컨트롤러 또는 프로바이더(가드, 필터등 포함)의 정적 인스턴스를 검색한다. 모듈 참조 클래스에서 상속된다.

resolve()

애플리케이션 환경에서 사용할 수 있는 컨트롤러 또는 프로바이더(가드,필터 등 포함)의 동적으로 생성된 범위 인스턴스(요청 또는 일시적)를 검색한다. 모듈 참조 클래스에서 상속된다.

select()

모듈의 종속성 그래프를 탐색한다. 선택한 모듈에서 특정 인스턴스를 검색하는데 사용할 수 있다.(get()메서드에서 엄격모드(strict: true)와 함께 사용됨).

e2e 테스트 파일을 test 디렉토리에 보관하자! 테스트 파일에는 .e2e-spec 접미사가 있어야 한다.

Overriding globally registered enhancers

전역적으로 등록된 가드(또는 파이프, 인터셉터 또는 필터)가 있는 경우 해당 인핸서를 재정의하기 위해 몇가지 단계를 더 수행해야 한다. 원래 등록을 요약하면 다음과 같다.

providers: [
	{
	provide: APP_GUARD,
    useClass: JwtAuthGuard,
	},
],

APP_*토큰을 통해 가드를 "다중"프로바이더로 등록하는 것이다. 여기서 JwtAuthGuard를 교체하려면 등록시 이 슬롯에 있는 기존 프로바이더를 사용해야 한다.

providers: [
  {
    provide: APP_GUARD,
    useExisting: JwtAuthGuard,
  },
  JwtAuthGuard,
],

useClassuseExisting으로 변경하여 Nest가 토큰 뒤에서 인스턴스화하는 대신 등록된 프로바이더를 참조하자.

이제 JwtAuthGuardTestingModule을 만들 때 재정의할 수 있는 일반 프로바이더로 Nest에 표시된다.

const module = await Test.createTestingModule({
  	imports: [AppModule],
})
	.overrideProvider(JwtAuthGuard)
	.useClass(MockAuthGuard)
	.compile();

이제 모든 테스트에서 모든 요청에 MockAuthGuard를 사용한다.

Testing request-scoped instances

요청 범위 프로바이더는 들어오는 각 요청에 대해 고유하게 생성된다. 요청이 처리를 완료한 후 인스턴스가 가비지 콜렝팅(Garbage Collecting)된다. 테스트된 요청을 위해 특별히 생성된 종속성 주입 하위 트리에 액세스할 수 없기 때문에 문제가 된다.

우리는(위 섹션을 기반으로)resolve()메서드를 사용해서 동적으로 인스턴스화된 클래스를 검색할 수 있다는 것을 알고 있다. DI 컨테이너 하위 트리의 수명주기를 제어하기 위해 고유한 컨텍스트 식별자를 전달할 수 있다.
이를 테스트 컨텍스트에서 어떻게 사용하는가?

미리 컨텍스트 식별자를 생성하고 Nest가 이 특정 ID를 사용하여 들어오는 모든 요청에 대한 하위트리를 만들도록 하는 것이다. 이러한 방식으로 테스트된 요청을 위해 생성된 인스턴스를 검색할 수 있다.이를 수행하려면 ContextIdFactory에서 jest.spyOn()을 사용한다.

const contextId = ContextIdFactory.create();
jest
	.spyOn(ContextIdFactory, `getByRequest`)
	.mockImplementation(()=>contextId);

이제 contextId를 사용하여 후속 요청에 대해 생성된 단일 DI 컨테이너 하위 트리에 엑세스할 수 있다.

usersService = await module.resolve(UsersService, contextId);
profile
만물에 관심이 많은 잡학지식사전이자, 새로운 도전을 꿈꾸는 주니어 개발자 / 잡학지식에서 벗어나서 전문성을 가진 엔지니어로 거듭나자!

1개의 댓글

comment-user-thumbnail
2024년 2월 28일

와... 진짜 감사합니다. Custom Provider를 E2E Test시에 어떻게 override할까 엄청 고민 중이었는데 이 글을 우연히 발견하게되어 지금 잘 해결했네요 ㅠㅠㅠㅠ 정말 감사합니다!!!

답글 달기