[NestJS | Docs] Testing 알아보기
2024.03.06- -
1. Testing
자동화된 테스트는 소프트웨어 개발에 들어가는 모든 공수(effort)의 핵심 파트로 여겨집니다. 테스트를 자동화하면 개발 도중에 개별 테스트 또는 테스트 묶음들을 빠르고 쉽게 반복하여 테스트 해볼 수 있게 됩니다. 또한 제품을 출시할 때 품질과 성능의 만족을 보장하기도 합니다.
이렇듯 자동화는 개발자들에게 커버리지(테스트 범위)를 증가하는데 도움을 주며 더 빠른 피드백 순환을 가능하게 합니다. 그리고 각 개발자들의 생산성을 증가시키고 개발 생명주기의 정점(소스 코드 제어 체크인, 기능 통합, 버전 릴리즈 등)에서 테스트가 실행됨을 보장합니다.
그러한 테스트는 unit 테스트, end-to-end(e2e) 테스트, integration 테스트 등을 포함하여 다양한 유형에 걸쳐 분포합니다. 테스트를 함으로써 얻을 수 있는 장점은 의심할 여지가 없지만, 이를 설정하는 데 필요한 과정은 매우 지루한 작업입니다. Nest는 효과적인 테스트를 포함한 개발 모범 사례를 제공하기 위해 노력하였고, 그 결과 개발자와 팀이 테스트를 구축하고 자동화하는 데 도움이 되는 다음과 같은 기능을 만들어냈습니다.
- 컴포넌트에 대한 unit 테스트와 애플리케이션에 대한 e2e 테스트를 자동으로 지원합니다.
- 기본적인 도구(ex. 고립된 모듈/애플리케이션 로더를 빌드하는 테스트 러너)를 제공합니다.
- 테스트 도구에 구애받지 않으면서, Jest 및 Supertest와의 통합을 바로 사용가능 합니다.
- 테스트 환경에서 Nest 의존성 주입을 사용하여 컴포넌트를 쉽게 모킹할 수 있습니다.
앞서 언급했듯이, Nest는 특정 테스팅 툴을 사용하도록 강제하지 않기 때문에 각자 선호하는 테스팅 프레임워크를 전부 사용할 수 있습니다. 테스트 러너와 같이 필요한 요소를 잘 교체하기만 하면 Nest의 기성 테스트 기능의 이점을 그대로 누릴 수 있습니다.
1-1. Installation
시작하기 전, 필요한 패키지를 설치해줍니다.
$ npm i --save-dev @nestjs/testing
1-2. Unit testing
다음 예제에서는 두 가지 클래스를 테스트해볼 것입니다: CatsController와 CatsService
Jest가 기본적인 테스팅 프레임워크로 제공됩니다. Jest는 테스트 러너 역할을 하며 모킹, 스파이 등에 도움이 되는 assert 함수와 test-double 유틸리티도 제공합니다. 다음 기본 테스트에서는 이러한 클래스들을 수동으로 인스턴스화하고 controller와 service가 우리가 만든 API 약속을 이행하는지 확인합니다.
// cats.controller.spec.ts
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
describe('CatsController', () => {
let catsController: CatsController;
let catsService: CatsService;
beforeEach(() => {
catsService = new CatsService();
catsController = new CatsController(catsService);
});
describe('findAll', () => {
it('should return an array of cats', async () => {
const result = ['test'];
jest.spyOn(catsService, 'findAll').mockImplementation(() => result);
expect(await catsController.findAll()).toBe(result);
});
});
});
위의 샘플은 기초 뼈대이기 때문에 실제로 Nest와 관련된 어떤 것도 테스트하고 있지는 않습니다. 코드를 보면 알 수 있듯 실제로 의존성 주입을 사용하고 있지도 않습니다. 테스트 대상 클래스를 수동으로 인스턴스화 하는 이러한 형태의 테스트는 프레임워크와 독립적이기 때문에 종종 isolated testing이라고 부릅니다.
다음은 Nest 기능을 보다 광범위하게 사용하는 애플리케이션을 테스트할 때 도움이 되는 몇 가지 고급 기능을 살펴보도록 하겠습니다.
1-3. Testing utilties
@nestjs/testing 패키지는 보다 강력한 테스트 프로세스를 가능하게 하는 몇가지 유틸리티를 제공합니다. 기본으로 제공되는 Test 클래스를 사용하여 이전 예제를 다시 작성해 보겠습니다:
import { Test } from '@nestjs/testing';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
describe('CatsController', () => {
let catsController: CatsController;
let catsService: CatsService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({ // Test 클래스 사용
controllers: [CatsController],
providers: [CatsService],
}).compile();
catsService = moduleRef.get<CatsService>(CatsService);
catsController = moduleRef.get<CatsController>(CatsController);
});
describe('findAll', () => {
it('should return an array of cats', async () => {
const result = ['test'];
jest.spyOn(catsService, 'findAll').mockImplementation(() => result);
expect(await catsController.findAll()).toBe(result);
});
});
});
Test 클래스는 기본적으로 전체 Nest 런타임 자체를 모킹하기 때문에 애플리케이션 실행 컨텍스트를 제공하는 데 유용하고, 모킹 및 오버라이딩 등 클래스 인스턴스를 쉽게 관리할 수 있는 hooks을 제공합니다.
Test 클래스에는 모듈 메타데이터 객체(@Module() 데코레이터에 전달한 것과 동일한 객체)를 인수로 받는 createTestingModule() 메서드가 있습니다. 이 메서드는 몇 가지 메서드를 제공하는 TestingModule 인스턴스를 반환합니다.
단위(Unit) 테스트의 경우에는 compile()이라는 메서드가 중요합니다. 이 메서드는 의존성을 가진 모듈을 부트스트랩하고(기존의 main.ts 파일에서 NestFactory.create()를 사용하여 애플리케이션을 부트스트랩하는 방식과 유사), 테스트할 준비가 된 모듈을 반환합니다.
compile() 메서드는 비동기적이기 때문에 그 이후에 작업들에서 이 인스턴스를 사용하기 위해 await 키워드를 통해 동기 처리를 합니다. 그로 인해 모듈이 컴파일되고 나서야 get() 메서드를 사용하여 모듈이 선언한 모든 정적 인스턴스(컨트롤러 및 프로바이더)를 가져올 수 있습니다.
TestingModule은 module reference 클래스를 상속하므로 scoped provider(일시적 또는 요청 범위)를 동적으로 확인하는 기능이 있습니다. 이 경우의 작업은 resolve() 메서드를 사용하여 수행해야 합니다(get() 메서드는 정적 인스턴스만 찾을 수 있음).
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
providers: [CatsService],
}).compile();
catsService = await moduleRef.resolve(CatsService);
WARNING
resolve() 메서드는 자체 DI 컨테이너 하위 트리에서 유니크한 provider 인스턴스를 반환합니다. 각 하위 트리에는 고유한 context 식별자가 있는데 실제로 이 메서드를 두 번 이상 호출하고 인스턴스 참조를 비교하면 동일하지 않음을 알 수 있습니다.
production 버전의 provider를 사용하는 대신 테스트 목적으로 custom provider로 오버라이딩할 수 있습니다. 예를 들어 실제 데이터베이스에 연결하는 대신 데이터베이스 서비스를 mocking 하는 경우가 있을 것입니다. 다음 섹션에서 오버라이드를 다루는데, 이는 단위 테스트에도 사용할 수 있습니다.
1-4. Auto mocking
Nest를 사용하면 누락된 모든 의존성에 적용할 모의 factory를 정의할 수도 있습니다. 이 기능은 클래스에 많은 의존성이 있고 모든 의존성을 모킹하는 데 시간이 오래 걸리고 설정이 많은 경우에 유용합니다.
이 기능을 사용하려면 createTestingModule()을 useMocker() 메서드와 체인으로 연결하여 의존성 모킹을 위한 팩토리를 전달해야 합니다. 이러한 팩토리는 인스턴스 토큰인 optional token, Nest provider에 유효한 모든 token을 받을 수 있으며 모의 구현(mock implementation)을 반환합니다.
아래는 jest-mock을 사용하여 일반 모커를 생성하는 예시와 jest.fn()을 사용하여 CatsService에 대한 특정 모커를 생성하는 예시를 보여줍니다.
// ...
import { ModuleMocker, MockFunctionMetadata } from 'jest-mock';
const moduleMocker = new ModuleMocker(global);
describe('CatsController', () => {
let controller: CatsController;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
})
.useMocker((token) => {
const results = ['test1', 'test2'];
if (token === CatsService) {
return { findAll: jest.fn().mockResolvedValue(results) };
}
if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock();
}
})
.compile();
controller = moduleRef.get(CatsController);
});
});
또한 일반적으로 custom provider를 사용할 때와 마찬가지로 테스트 컨테이너에서 이러한 모형(mock)을 moduleRef.get(CatsService)로 가져올 수도 있습니다.
- @golevelup/ts-jest의 createMock과 같은 일반 모의 팩토리를 직접 전달할 수도 있습니다.
- REQUEST 및 INQUIRER provider는 컨텍스트에서 이미 사전 정의되어 있으므로 자동 모킹할 수 없습니다. 그러나 custom provider 구문을 사용하거나 .overrideProvider 메서드를 활용하여 덮어쓸 수 있습니다.
1-5. End-to-end testing
개별 모듈과 클래스에 초점을 맞추는 단위 테스트와 달리 엔드투엔드(e2e) 테스트는 최종 사용자가 프로덕션 시스템과 상호 작용하는 방식에 더 가깝기 때문에 보다 전반적인 애플리케이션 수준에서 클래스와 모듈의 상호 작용을 다룹니다.
애플리케이션이 커짐에 따라 각 API 엔드포인트의 end-to-end 동작을 수동으로 테스트하기는 점점 어려워집니다. 자동화된 e2e 테스트는 시스템의 전반적인 동작이 올바르고 프로젝트 요구 사항을 충족하는지 확인하는 데 도움이 됩니다.
e2e 테스트를 수행하기 위해 방금 단위 테스트에서 다룬 것과 유사한 구성을 사용합니다. 또한 Nest를 사용하면 supertest 라이브러리를 사용하여 HTTP 요청을 쉽게 시뮬레이션할 수 있습니다.
// cats.e2e-spec.ts
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { CatsModule } from '../../src/cats/cats.module';
import { CatsService } from '../../src/cats/cats.service';
import { INestApplication } from '@nestjs/common';
describe('Cats', () => {
let app: INestApplication;
let catsService = { findAll: () => ['test'] };
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [CatsModule],
})
.overrideProvider(CatsService)
.useValue(catsService)
.compile();
app = moduleRef.createNestApplication();
await app.init();
});
it(`/GET cats`, () => {
return request(app.getHttpServer())
.get('/cats')
.expect(200)
.expect({
data: catsService.findAll(),
});
});
afterAll(async () => {
await app.close();
});
});
이 예제에서는 앞서 설명한 몇 가지 개념을 기반으로 구축합니다. 앞에서 사용한 compile() 메서드에 더해 이제 createNestApplication() 메서드를 사용하여 전체 Nest 런타임 환경을 인스턴스화합니다. 실행 중인 앱에 대한 참조를 app 변수에 저장하여 HTTP 요청을 시뮬레이션하는 데 사용할 수 있습니다.
Supertest의 request() 함수를 사용하여 HTTP 테스트를 시뮬레이션합니다. 이러한 HTTP 요청이 실행 중인 Nest 앱으로 라우팅되기를 원하기때문에 request() 함수에 Nest의 기반이 되는 HTTP listener(Express 플랫폼에서 제공될 수 있음)에 대한 참조를 전달합니다.
그렇기 때문에 request(app.getHttpServer()) 구조가 생성됩니다. 그리고 request()를 호출하면 이제 Nest 앱에 연결된 래핑되어있는 HTTP 서버가 전달되며, 이 서버는 실제 HTTP 요청을 시뮬레이션하는 메서드를 제공합니다. 예를 들어, request(...).get('/cats')을 사용하면 네트워크를 통해 들어오는 get '/cats'와 같은 실제 HTTP 요청과 동일한 요청이 Nest 앱에서 시작되는 것입니다.
이 예에서는 테스트할 수 있는 하드코딩된 값을 간단히 반환하는 CatsService의 대체(test-double) 구현도 제공합니다. 이러한 대체 구현을 제공하려면 overrideProvider()를 사용해야 합니다. 마찬가지로 Nest는 모듈, 가드, 인터셉터, 필터 및 파이프를 재정의하는 메서드를 각각 overrideModule(), overrideGuard(), overrideInterceptor(), overrideFilter() 및 overridePipe() 메서드를 통해 제공합니다.
각 오버라이드 메서드(overrideModule() 제외)는 custom provider에 기술된 메서드를 미러링하는 3개의 다른 메서드를 갖는 객체를 반환합니다:
- useClass: 객체를 오버라이딩할 인스턴스를 제공하기 위해 인스턴스화할 클래스(provider, guard 등)를 제공합니다.
- useValue: 객체를 오버라이딩할 인스턴스를 제공합니다.
- useFactory: 객체를 오버라이딩할 인스턴스를 반환하는 함수를 제공합니다.
반면에 overrideModule()은 아래 코드와 같이 원래 모듈을 오버라이딩할 모듈을 제공할 때 사용할 수 있는 useModule() 메서드를 갖는 객체를 반환합니다:
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideModule(CatsModule)
.useModule(AlternateCatsModule)
.compile();
각 오버라이딩 메서드 유형은 차례로 TestingModule 인스턴스를 반환하므로 fluent style(.으로 이어지는)로 다른 메서드와 체인으로 연결할 수 있습니다. 이러한 체인의 끝에서는 compile()을 사용하여 Nest가 모듈을 인스턴스화하고 초기화하도록 해야 합니다.
또한 테스트가 실행될 때(예: CI 서버에서) custom logger를 제공하고자 하는 경우도 있습니다. setLogger() 메서드를 사용하고 LoggerService 인터페이스를 충족하는 객체를 전달하여 테스트 중에 TestModuleBuilder에 로깅하는 방법을 사용하도록 합니다.(디폴트로는 "error" 로그만 콘솔에 기록됩니다)
컴파일된 모듈에는 다음 설명과 같은 몇 가지 유용한 메서드들이 있습니다:
- createNestApplication()
- 주어진 모듈을 기반으로 Nest 애플리케이션(INestApplication 인스턴스)을 생성하고 반환합니다. init() 메서드를 사용하여 애플리케이션을 수동으로 초기화해야 한다는 점을 유의합니다.
- createNestMicroservice()
- 주어진 모듈을 기반으로 Nest 마이크로서비스(INestMicroservice 인스턴스)를 생성하고 반환합니다.
- get()
- 애플리케이션 context에서 사용할 수 있는 controller 또는 provider(guard, filter 등)의 정적 인스턴스를 가져옵니다. module reference 클래스에서 상속됩니다.
- resolve()
- 애플리케이션 context에서 사용 가능한 controller 또는 provider(guard, filter 등 포함)의 동적으로 생성된 scoped 인스턴스(request 또는 transient)를 가져옵니다. module reference 클래스에서 상속됩니다.
- select()
- 모듈의 의존성 그래프를 탐색하고, 선택한 모듈에서 특정 인스턴스를 가져오는 데 사용할 수 있습니다(get() 메서드에서 strict 모드(strict: true)와 함께 사용).
1-6. Overriding globally reigstered enhancers
전역으로 등록된 guard(또는 pipe, interceptor, filter)가 있는 경우 해당 enhancer들을 오버라이딩하려면 몇 가지 단계가 더 수행돼야 합니다.
이러한 enhander들의 등록은 다음과 같이 이루어졌었습니다.
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
이는 APP_* 토큰을 통해 guard를 "multi" provider로 등록하는 것입니다. 이 슬롯에 기존 provider를 사용해야만 JwtAuthGuard를 대체할 수 있습니다:
providers: [
{
provide: APP_GUARD,
useExisting: JwtAuthGuard,
// ^^^^^^^^ notice the use of 'useExisting' instead of 'useClass'
},
JwtAuthGuard,
],
Nest가 토큰 뒤에서 인스턴스화하는 대신 이미 등록된 provider를 참조하도록 useClass를 useExisting으로 변경할 수 있습니다.
이제 테스트 모듈을 생성할 때 오버라이딩할 수 있는 일반 provider로 JwtAuthGuard가 Nest에 표시됩니다:
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(JwtAuthGuard)
.useClass(MockAuthGuard)
.compile();
이제 모든 테스트에서 모든 요청에 MockAuthGuard를 사용하도록 되었습니다.
1-7. Testing request-scoped instances
requset-scoped provider는 들어오는 각 요청에 대해 고유하게 생성됩니다. 인스턴스는 요청 처리가 완료된 후 garbage-collected 됩니다. 이는 테스트된 요청을 위해 특별히 생성된 의존성 주입 하위 트리에 액세스할 수 없기 때문에 다소 문제가 될 수 있습니다.
위의 섹션을 통해 동적으로 인스턴스화된 클래스를 찾을 때는 resolve() 메서드를 사용할 수 있다는 것을 배웠습니다. 또한 여기에 설명된 대로 고유한 context 식별자를 전달하여 DI 컨테이너 하위 트리의 생명주기를 제어할 수 있다는 것도 배웠습니다.
그렇다면 테스트 context에서는 이를 어떻게 활용할 수 있을까요?
해당 전략은 context 식별자를 미리 생성하고 Nest가 이 특정 ID를 사용하여 들어오는 모든 요청에 대한 하위 트리를 생성하도록 하는 것입니다. 이렇게 하면 테스트된 요청에 대해 생성된 인스턴스를 가져올 수 있습니다.
이를 위해 ContextIdFactory에서 jest.spyOn()을 사용합니다:
const contextId = ContextIdFactory.create();
jest.spyOn(ContextIdFactory, 'getByRequest').mockImplementation(() => contextId);
이제 contextId를 사용하여 모든 후속 요청에 대해 생성된 단일 DI 컨테이너 하위 트리에 액세스할 수 있습니다.
catsService = await moduleRef.resolve(CatsService, contextId);
'Back-end > NestJS' 카테고리의 다른 글
[NestJS | Docs] Exception filters (예외 필터) (1) | 2024.03.04 |
---|---|
[NestJS | Docs] Middleware 알아보기 (0) | 2024.02.28 |
[NestJS | Docs] Execution context 알아보기 (feat. ArgumentHost) (0) | 2024.02.27 |
[NestJS | Docs] Configuration 사용법 (0) | 2024.02.23 |
[NestJS | Docs] Custom Decorators 알아보기 (커스텀 데코레이터) (0) | 2024.02.21 |
소중한 공감 감사합니다