E2E 테스트 구현
실제 애플리케이션에 HTTP 요청을 보내는 E2E 환경을 구성하고 정상·실패 응답과 데이터 정리 경계를 검증합니다.
E2E 테스트를 구현할 때 상태 준비, 요청 시나리오, 실패 응답, 데이터 정리 기준을 함께 살펴봅니다.
이 절에서는 사용자 관점에서 애플리케이션의 전체 흐름을 검증하는 E2E(End-to-End) 테스트 구현 방법을 다룹니다.
단위 테스트가 선택한 개별 코드 조각의 동작을 확인한다면, E2E 테스트는 선택한 애플리케이션 경계를 실제 요청으로 통과하며 모듈 연결과 저장 결과를 함께 검증합니다.
이는 애플리케이션이 프로덕션 환경에서 사용자에게 어떤 경험을 제공할지 미리 확인하는 중요한 단계입니다.
E2E 테스트란?
E2E 테스트(End-to-End Test)는 애플리케이션의 시작부터 끝까지의 전체 워크플로우를 시뮬레이션하여 시스템이 의도한 대로 동작하는지 검증하는 테스트 유형입니다.
NestJS와 Supertest를 사용하는 이 절의 E2E 테스트는 브라우저를 구동하지 않고 Nest 애플리케이션의 HTTP 경계까지 검증합니다. 실제 브라우저 상호작용과 프론트엔드까지 포함하는 시스템 E2E는 Playwright나 Cypress 같은 별도 도구의 범위입니다.
E2E 테스트의 주요 특징- 사용자 관점: 실제 사용자가 겪을 수 있는 시나리오를 재현합니다.
- 선택한 시스템 경계 검증: 전체
AppModule을 사용할 수도 있고, 목적에 맞는 모듈만 로드하거나 외부 프로바이더를 대체할 수도 있습니다. - 통합된 동작 확인: 여러 컴포넌트가 올바르게 통합되어 작동하는지 확인합니다.
- 느린 실행 속도: 실제 시스템을 구동해야 하므로 단위 테스트나 통합 테스트보다 실행 시간이 오래 걸립니다.
- 실행 결과의 불안정성(Flakiness): 네트워크 지연, 외부 서비스의 상태, 비동기 작업 등으로 인해 불안정한 테스트(Flaky Test)가 발생할 수 있습니다.
NestJS 백엔드 애플리케이션에서 E2E 테스트는 주로 HTTP/REST API 엔드포인트를 호출해 서버 응답을 검증하는 방식으로 진행됩니다.
프론트엔드가 있는 경우 Cypress, Playwright 같은 도구를 함께 사용해 브라우저 상호작용까지 포함할 수 있습니다.
이 절은 Jest를 설정한 프로젝트에서 Supertest로 Nest HTTP 앱을 검사하는 예제입니다. 최신 Nest 공식 테스팅 문서의 새 프로젝트 기본 러너는 Vitest이므로, 아래 Jest 설정과 스크립트를 사용하려면 해당 의존성과 TypeScript 변환을 별도로 구성해야 합니다.
다음 기준선은 테스트 환경과 데이터 준비부터 HTTP 호출, 검증, 정리까지 Nest/Supertest E2E의 책임 경계를 보여줍니다.
Nest와 Supertest E2E의 HTTP 경계
환경/compile·configureApp/init·상태 준비·HTTP 검증·영속 상태 API 정리·app.close의 준비·검증·정리 순서다.
Nest · HTTP E2E lifecycle
HTTP E2E의 준비부터 종료까지
테스트 모듈의 범위와 외부 대체물을 고른 뒤 프로덕션과 HTTP 설정을 공유합니다. 요청·응답뿐 아니라 영속 상태의 정리까지 한 시나리오의 책임으로 봅니다.
한 시나리오의 준비부터 종료까지
-
테스트 환경과 모듈 범위를 고정한다
테스트 DB와 환경 변수를 선택하고
TestingModule을 컴파일합니다. 전체AppModule또는 목적에 맞는 모듈을 쓰고, 외부 프로바이더는 필요한 만큼 override합니다. -
Nest 애플리케이션 객체를 만든다
createNestApplication()은 테스트용 앱 객체를 만들지만main.ts의 명령형 bootstrap 코드를 자동 실행하지 않습니다. -
프로덕션과 같은 HTTP 설정을 적용한다
공유
configureApp으로 프로젝트가 실제 사용하는 global prefix, pipes, filters, interceptors를 같은 순서로 적용합니다. -
route와 lifecycle을 초기화한다
app.init()은 선택한 그래프를 준비합니다. 프로덕션 포트에 listen하지 않고 브라우저도 구동하지 않습니다. -
결정적인 시나리오 상태를 seed한다
고정 factory와 고유 key로 필요한 row만 만들고, 외부 sandbox나 test double의 시작 상태도 함께 고정합니다.
-
Supertest로 실제 HTTP 계약을 호출한다
getHttpServer()에 method, path, header, body를 보내 global prefix와 구성된 HTTP 경계를 통과합니다. DTO validation은 잘못된 body 사례로 따로 단언합니다. -
응답과 관찰 가능한 효과를 단언한다
status, headers, body뿐 아니라 DB 반영, 이벤트 발행, 외부 호출처럼 시나리오가 약속한 persistence와 side effect를 확인합니다.
-
영속 상태를 정리한 뒤 앱을 닫는다
row, queue, 파일, sandbox 기록, mock history를 자원별로 정리하고 마지막에
app.close()로 Nest lifecycle과 열린 연결을 종료합니다.
E2E라고 모든 의존성이 실제일 필요는 없다
핵심 모듈과 DB는 실제로 연결하되 결제·메일처럼 비결정적이거나 비용이 큰 경계는 sandbox 또는 provider override를 쓸 수 있습니다. 중요한 것은 선택을 시나리오 이름과 setup에 드러내는 일입니다.
configureApp이 계약 차이를 막는다
테스트에서 global prefix나 validation pipe가 빠지면 통과한 route가 프로덕션에서는 실패할 수 있습니다. 명령형 설정은 한 함수로 공유하거나 DI 기반 global provider로 등록합니다.
Supertest 경계와 브라우저 E2E를 구분한다
이 흐름은 Nest HTTP 서버까지 검증합니다. DOM, 라우팅, 사용자 클릭과 프론트엔드 네트워크를 검증하려면 별도의 Playwright·Cypress 시스템 E2E가 필요합니다.
app.close()는 데이터 삭제 명령이 아니다
Nest가 관리하는 lifecycle과 연결은 닫지만 이미 commit된 DB row, 발행된 message, 생성된 파일이나 외부 sandbox 데이터는 각 자원의 cleanup으로 되돌려야 합니다.
실패 지점을 좁히려면 setup, request, assertion, cleanup의 로그와 식별자를 분리합니다. 한 테스트의 성공은 응답 단언뿐 아니라 다음 테스트가 같은 시작 조건을 얻는 것까지 포함합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 테스트 환경을 고정한 뒤 선택한 모듈을 컴파일하고 앱 객체를 만든다. 공유 configureApp을 적용해야 prefix와 pipe가 프로덕션과 같고 app.init으로 route와 lifecycle을 준비한다. 필요한 시나리오 상태만 준비하고 Supertest HTTP 요청과 결과·부수 효과를 검증한다. 마지막에는 생성한 영속 상태를 각 자원의 API로 정리한 뒤 app.close로 관리 연결과 lifecycle을 종료한다. close만으로 commit된 row나 발행한 message가 삭제되는 관계는 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Nest와 Supertest E2E의 HTTP 경계 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 앱 준비 환경 고정·compile 뒤 앱을 만들고 configureApp·init을 적용합니다. | 준비: prefix·pipe·route · 환경: 테스트 | 실제 HTTP 계약이 준비됩니다. |
| 상태와 HTTP 검증 필요한 시나리오 상태를 만들고 HTTP·부수 효과를 확인합니다. | 실행: Supertest HTTP · 단언: 결과·부수 효과 | 앱 경계를 넘은 결과를 관찰합니다. |
| 상태 정리 뒤 close 자원 API로 영속 상태를 지운 뒤 app.close합니다. | 영속 상태: API 정리 · 앱: close · close: row 삭제 아님 | 시험 소유 자료와 관리 연결을 각각 정리합니다. |
Nest와 Supertest E2E의 HTTP 경계
환경/compile·configureApp/init·상태 준비·HTTP 검증·영속 상태 API 정리·app.close의 준비·검증·정리 순서다.
Nest · HTTP E2E lifecycle
HTTP E2E의 준비부터 종료까지
테스트 모듈의 범위와 외부 대체물을 고른 뒤 프로덕션과 HTTP 설정을 공유합니다. 요청·응답뿐 아니라 영속 상태의 정리까지 한 시나리오의 책임으로 봅니다.
한 시나리오의 준비부터 종료까지
-
테스트 환경과 모듈 범위를 고정한다
테스트 DB와 환경 변수를 선택하고
TestingModule을 컴파일합니다. 전체AppModule또는 목적에 맞는 모듈을 쓰고, 외부 프로바이더는 필요한 만큼 override합니다. -
Nest 애플리케이션 객체를 만든다
createNestApplication()은 테스트용 앱 객체를 만들지만main.ts의 명령형 bootstrap 코드를 자동 실행하지 않습니다. -
프로덕션과 같은 HTTP 설정을 적용한다
공유
configureApp으로 프로젝트가 실제 사용하는 global prefix, pipes, filters, interceptors를 같은 순서로 적용합니다. -
route와 lifecycle을 초기화한다
app.init()은 선택한 그래프를 준비합니다. 프로덕션 포트에 listen하지 않고 브라우저도 구동하지 않습니다. -
결정적인 시나리오 상태를 seed한다
고정 factory와 고유 key로 필요한 row만 만들고, 외부 sandbox나 test double의 시작 상태도 함께 고정합니다.
-
Supertest로 실제 HTTP 계약을 호출한다
getHttpServer()에 method, path, header, body를 보내 global prefix와 구성된 HTTP 경계를 통과합니다. DTO validation은 잘못된 body 사례로 따로 단언합니다. -
응답과 관찰 가능한 효과를 단언한다
status, headers, body뿐 아니라 DB 반영, 이벤트 발행, 외부 호출처럼 시나리오가 약속한 persistence와 side effect를 확인합니다.
-
영속 상태를 정리한 뒤 앱을 닫는다
row, queue, 파일, sandbox 기록, mock history를 자원별로 정리하고 마지막에
app.close()로 Nest lifecycle과 열린 연결을 종료합니다.
E2E라고 모든 의존성이 실제일 필요는 없다
핵심 모듈과 DB는 실제로 연결하되 결제·메일처럼 비결정적이거나 비용이 큰 경계는 sandbox 또는 provider override를 쓸 수 있습니다. 중요한 것은 선택을 시나리오 이름과 setup에 드러내는 일입니다.
configureApp이 계약 차이를 막는다
테스트에서 global prefix나 validation pipe가 빠지면 통과한 route가 프로덕션에서는 실패할 수 있습니다. 명령형 설정은 한 함수로 공유하거나 DI 기반 global provider로 등록합니다.
Supertest 경계와 브라우저 E2E를 구분한다
이 흐름은 Nest HTTP 서버까지 검증합니다. DOM, 라우팅, 사용자 클릭과 프론트엔드 네트워크를 검증하려면 별도의 Playwright·Cypress 시스템 E2E가 필요합니다.
app.close()는 데이터 삭제 명령이 아니다
Nest가 관리하는 lifecycle과 연결은 닫지만 이미 commit된 DB row, 발행된 message, 생성된 파일이나 외부 sandbox 데이터는 각 자원의 cleanup으로 되돌려야 합니다.
실패 지점을 좁히려면 setup, request, assertion, cleanup의 로그와 식별자를 분리합니다. 한 테스트의 성공은 응답 단언뿐 아니라 다음 테스트가 같은 시작 조건을 얻는 것까지 포함합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 테스트 환경을 고정한 뒤 선택한 모듈을 컴파일하고 앱 객체를 만든다. 공유 configureApp을 적용해야 prefix와 pipe가 프로덕션과 같고 app.init으로 route와 lifecycle을 준비한다. 필요한 시나리오 상태만 준비하고 Supertest HTTP 요청과 결과·부수 효과를 검증한다. 마지막에는 생성한 영속 상태를 각 자원의 API로 정리한 뒤 app.close로 관리 연결과 lifecycle을 종료한다. close만으로 commit된 row나 발행한 message가 삭제되는 관계는 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Nest와 Supertest E2E의 HTTP 경계 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 앱 준비 환경 고정·compile 뒤 앱을 만들고 configureApp·init을 적용합니다. | 준비: prefix·pipe·route · 환경: 테스트 | 실제 HTTP 계약이 준비됩니다. |
| 상태와 HTTP 검증 필요한 시나리오 상태를 만들고 HTTP·부수 효과를 확인합니다. | 실행: Supertest HTTP · 단언: 결과·부수 효과 | 앱 경계를 넘은 결과를 관찰합니다. |
| 상태 정리 뒤 close 자원 API로 영속 상태를 지운 뒤 app.close합니다. | 영속 상태: API 정리 · 앱: close · close: row 삭제 아님 | 시험 소유 자료와 관리 연결을 각각 정리합니다. |
NestJS E2E 테스트 환경 설정
아래 예제는 test/app.e2e-spec.ts와 test/jest-e2e.json을 사용하는 파일 구조입니다. Jest 기반 스타터에 이미 있으면 확인하고, 다른 러너를 쓰는 프로젝트에서는 예제용 파일과 스크립트를 직접 구성합니다.
앞 절처럼 CLI 11의 Jest 기반 프로젝트를 이어가면 기존 파일과 스크립트를 확인합니다. 최신 CLI의 기본 러너가 다르다면 이 장의 Jest 구성을 먼저 준비해야 합니다. 아래 JSON은 test/jest-e2e.json에 저장하며, 파일 제목만 보고 프로젝트 루트에 만들지 않습니다. 앞 절의 test:e2e 스크립트가 이 경로를 선택하기 때문입니다.
jest-e2e.json (E2E 테스트를 위한 Jest 설정)
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": "\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}testRegex:\.e2e-spec\.ts$패턴으로 끝나는 파일만 E2E 테스트로 인식하여 실행합니다. 이는 단위 테스트와 E2E 테스트를 분리하여 실행할 때 유용합니다.rootDir가test폴더라면<rootDir>/src는test/src를 뜻합니다. 별칭이 필요하면 실제 소스 위치인<rootDir>/../src/$1처럼 프로젝트 구조에 맞춰 명시해야 하며, 이 예제는 상대 import를 사용하므로 별도 매핑을 두지 않습니다.
E2E 테스트 작성: AppController 예시
AppController의 간단한 HTTP 엔드포인트를 테스트하는 예시를 통해 E2E 테스트의 기본 구조를 이해해 보겠습니다.
AppController 코드 (이전과 동일)
import { Controller, Get, Post, Body } from '@nestjs/common';
import { AppService } from './app.service';
@Controller('app')
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('hello')
getHello(): string {
return this.appService.getHello();
}
@Post('sum')
sumNumbers(@Body() data: { a: number; b: number }): number {
return this.appService.sum(data.a, data.b);
}
}AppService 코드 (이전과 동일)
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
sum(a: number, b: number): number {
return a + b;
}
}따라 하려면 AppModule의 controllers에 위 AppController, providers에 위 AppService가 등록되어 있어야 합니다. 테스트가 AppModule을 가져오는 것만으로 다른 파일의 클래스를 자동 등록하지는 않습니다. 기본 스타터의 이 두 등록을 유지하고 해당 파일의 예제를 교체합니다.
프로덕션과 E2E가 같은 HTTP 계약을 사용하도록 main.ts에만 있던 명령형 bootstrap 설정을 함수로 분리합니다. 전역 filter와 interceptor를 코드에서 직접 등록한다면 이 함수에 함께 두고, DI가 필요하면 APP_FILTER, APP_INTERCEPTOR 프로바이더로 등록합니다.
import {
INestApplication,
ValidationPipe,
} from '@nestjs/common';
export function configureApp(app: INestApplication): void {
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
}import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { configureApp } from './configure-app';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
configureApp(app);
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();app.e2e-spec.ts 파일
Jest 기반 프로젝트의 app.e2e-spec.ts 파일을 수정하거나, 아래 E2E 테스트 파일을 새로 작성할 수 있습니다.
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from './../src/app.module';
import { configureApp } from './../src/configure-app';
describe('AppController (e2e)', () => {
let app: INestApplication;
// 각 테스트 스위트가 시작되기 전에 단 한 번 실행됩니다.
// NestJS 애플리케이션을 초기화하고 HTTP 요청을 보낼 준비를 합니다.
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app);
await app.init();
});
// 각 테스트 스위트가 끝난 후 단 한 번 실행됩니다.
// NestJS 애플리케이션을 종료하여 리소스를 정리합니다.
afterAll(async () => {
await app.close();
});
// --- GET /api/app/hello 엔드포인트 테스트 ---
it('/api/app/hello (GET)', () => {
return request(app.getHttpServer()) // Supertest를 사용하여 HTTP 서버에 요청
.get('/api/app/hello') // global prefix를 포함한 실제 HTTP 계약
.expect(200) // HTTP 상태 코드 200 (OK) 예상
.expect('Hello World!'); // 응답 본문이 'Hello World!'인지 예상
});
// --- POST /api/app/sum 엔드포인트 테스트 ---
it('/api/app/sum (POST)', () => {
return request(app.getHttpServer())
.post('/api/app/sum')
.send({ a: 5, b: 7 }) // 요청 본문 (JSON) 전송
.expect(201) // HTTP 상태 코드 201 (Created) 예상 (NestJS는 기본적으로 POST에 201 반환)
.expect('12'); // 응답 본문이 '12' (문자열로 반환됨)인지 예상
});
// --- 존재하지 않는 경로 테스트 (404 Not Found) ---
it('/non-existent-path (GET) should return 404', () => {
return request(app.getHttpServer())
.get('/non-existent-path')
.expect(404); // HTTP 상태 코드 404 (Not Found) 예상
});
});import request from 'supertest';: HTTP 요청을 보내고 응답을 검증하는 데 사용되는 Supertest 라이브러리의 기본 export를 임포트합니다.Test.createTestingModule({ imports: [AppModule] }).compile();: 테스트가 선택한 모듈 그래프를 컴파일합니다. 전체AppModule을 사용할 수 있지만, 목적에 따라 일부 모듈만 선택하거나overrideProvider()로 제어하기 어려운 외부 의존성을 대체할 수 있습니다.configureApp(app):createNestApplication()은main.ts의 명령형 코드를 자동 실행하지 않습니다. 프로덕션과 테스트가 프로젝트에서 실제 사용하는 global prefix, pipe, filter, interceptor 설정을 공유해야 같은 HTTP 계약을 검증할 수 있습니다.await app.init();: 선택한 애플리케이션 그래프의 lifecycle과 route를 초기화합니다. 프로덕션 포트에listen()하지 않으며 브라우저를 실행하지도 않습니다.await app.close();: Nest가 관리하는 lifecycle과 연결을 종료합니다. 테스트가 생성한 DB row, queue message, 파일, 외부 sandbox 데이터까지 되돌리거나 삭제해 주는 명령은 아니므로 이 상태는 별도로 정리해야 합니다.request(app.getHttpServer()): Supertest의 핵심 부분입니다.app.getHttpServer()를 통해 NestJS가 사용하는 Node.js HTTP 서버 인스턴스를 가져와 Supertest에 전달하면, Supertest가 해당 서버에 직접 요청을 보냅니다..get('/api/app/hello'),.post('/api/app/sum'): global prefix를 포함한 HTTP 메서드와 경로를 지정합니다..send({ a: 5, b: 7 }): POST 요청의 경우, 요청 본문 데이터를 전송합니다..expect(200),.expect(201),.expect(404): 예상되는 HTTP 상태 코드를 검증합니다..expect('Hello World!'),.expect('12'): 예상되는 응답 본문을 검증합니다. Supertest는 JSON 응답 검증도 지원합니다(예:.expect({ id: 1, name: 'Test User' })).
이 짧은 sum 예제의 인라인 객체 타입에는 런타임 validation metadata가 없으므로, 정상 POST 테스트는 공유 bootstrap과 route 계약을 확인할 뿐 ValidationPipe의 변환·whitelist 규칙 자체를 입증하지 않습니다. 그 규칙까지 검증하려면 데코레이터가 있는 DTO를 사용하고 잘못된 body가 400이 되는 사례를 별도로 단언해야 합니다.
직접 판단하기
현재 예제에 DTO 클래스를 추가하지 않은 상태에서 configureApp()의 prefix만 v1으로 바꾸고 테스트 요청은 /api/app/sum으로 남겼습니다. 첫 실패는 덧셈 결과일까요, route일까요? 요청 경로를 /v1/app/sum으로 맞춘 뒤 { a: "5", b: 7 }을 보내면 자동으로 400이 될까요?
답과 이유 확인
첫 조건에서는 실제 route가 /v1/app/sum이므로 기존 /api/app/sum 요청은 404입니다. 서비스의 덧셈까지 도달하지 않습니다. 경로를 맞춘 두 번째 조건에서는 인라인 객체 타입이 유효성 검증용 DTO가 아니므로 문자열 "5"가 숫자로 검증·변환된다고 보장할 수 없습니다. 현재 sum()의 JavaScript 연산은 "5" + 7을 "57"로 계산하며 정상 POST의 기본 상태는 201입니다. 타입 표기와 ValidationPipe 등록만으로 잘못된 body를 400으로 거절했다고 판단하면 안 됩니다. DTO와 오류 응답 사례를 별도로 준비해야 그 계약을 검사할 수 있습니다.
E2E 테스트는 별도의 Jest 설정 파일을 사용하므로, package.json에 정의된 스크립트를 사용합니다.
npm run test:e2e콘솔에 테스트 통과 결과가 나타날 것입니다.
데이터베이스 연동 E2E 테스트 시 고려사항
실제 애플리케이션의 E2E 테스트는 데이터베이스를 포함하는 경우가 많습니다.
이때는 DB 연결만 분리하는 데서 끝내지 말고, seed와 외부 의존성, queue·파일, mock 기록, 열린 handle까지 자원별 준비·격리·정리·실패 신호를 정해야 합니다.
Nest · E2E resource matrix
E2E 자원별 격리와 정리
준비 방법, 병렬 실행 경계, 지울 대상, 누수 신호를 자원별로 정합니다. 앱 종료와 데이터 정리는 별도의 책임입니다.
| 자원 | Setup | Isolation | Cleanup | Failure signal |
|---|---|---|---|---|
| DB | 테스트 DSN, migration 적용 | worker별 schema·DB·container 또는 유효한 transaction 경계 | 생성 row 삭제·truncate·rollback 중 한 전략 | 중복 key, 순서 의존, 병렬 충돌 |
| 시드 | 결정적 factory와 명시적 ID | 시나리오별 namespace와 고유 key | 만든 ID 목록으로 정확히 삭제 | 시간·랜덤 값에 따라 assertion 변화 |
| 외부 서비스 | sandbox 계정 또는 제어 가능한 test double | 테스트별 tenant·idempotency key | 외부 record·webhook·호출 기록 제거 | quota, 네트워크, 공유 계정 오염 |
| 큐·파일 | 전용 queue와 임시 경로 준비 | run ID prefix와 worker별 디렉터리 | message drain, 파일·디렉터리 삭제 | 다음 테스트 소비, 남은 artifact |
| Mock | provider override와 기본 구현 고정 | 테스트별 instance 또는 명시적 lifecycle | clear·reset; spy·교체 속성은 restoreAllMocks |
이전 호출 횟수와 구현이 누적 |
| 앱 수명 | 공유 bootstrap 뒤 app.init() |
suite별 app과 추적 가능한 handle | 영속 상태 정리 후 app.close() |
Jest open handle, timeout, 종료 지연 |
worker 경계를 먼저 고른다
- Setup
- 테스트 DSN, migration 적용
- Isolation
- worker별 schema·DB·container 또는 유효한 transaction 경계
- Cleanup
- 생성 row 삭제·truncate·rollback 중 한 전략
- Failure signal
- 중복 key, 순서 의존, 병렬 충돌
필요한 상태만 결정적으로 만든다
- Setup
- 결정적 factory와 명시적 ID
- Isolation
- 시나리오별 namespace와 고유 key
- Cleanup
- 만든 ID 목록으로 정확히 삭제
- Failure signal
- 시간·랜덤 값에 따라 assertion 변화
sandbox와 test double을 목적에 맞게 선택한다
- Setup
- sandbox 계정 또는 제어 가능한 test double
- Isolation
- 테스트별 tenant·idempotency key
- Cleanup
- 외부 record·webhook·호출 기록 제거
- Failure signal
- quota, 네트워크, 공유 계정 오염
DB 밖의 영속 흔적도 추적한다
- Setup
- 전용 queue와 임시 경로 준비
- Isolation
- run ID prefix와 worker별 디렉터리
- Cleanup
- message drain, 파일·디렉터리 삭제
- Failure signal
- 다음 테스트 소비, 남은 artifact
호출 이력과 구현 수명을 구분한다
- Setup
- provider override와 기본 구현 고정
- Isolation
- 테스트별 instance 또는 명시적 lifecycle
- Cleanup
clear·reset; spy·교체 속성은restoreAllMocks- Failure signal
- 이전 호출 횟수와 구현이 누적
영속 상태 정리 뒤 handle을 닫는다
- Setup
- 공유 bootstrap 뒤
app.init() - Isolation
- suite별 app과 추적 가능한 handle
- Cleanup
- 영속 상태 정리 후
app.close() - Failure signal
- Jest open handle, timeout, 종료 지연
Transaction rollback은 애플리케이션 요청과 테스트가 같은 transaction 경계를 공유할 때만 모든 쓰기를 되돌립니다. Testcontainers도 강력한 선택지일 뿐 절대적인 정답은 아니며, 기동 비용과 CI 환경, 병렬화 요구를 함께 비교해야 합니다.
- 독립적인 테스트 데이터: 결정적인 factory와 고유 key로 필요한 데이터만 준비하고, 테스트 완료 후 row·queue message·임시 파일처럼 테스트가 만든 영속 상태를 명시적으로 정리합니다.
- DB 격리 전략: 트랜잭션 rollback은 빠르지만 애플리케이션과 테스트가 같은 연결·트랜잭션 경계를 공유할 때만 유효합니다. 병렬 worker에는 worker별 schema·database·container가 더 명확할 수 있으며, Testcontainers는 강력한 선택지이지만 Docker 기동 비용과 CI 제약을 함께 평가해야 합니다.
- 환경 변수 관리: 테스트 환경에서 데이터베이스 연결 정보, 외부 서비스 API 키 등 환경 변수를 다르게 설정해야 합니다.
config모듈을 활용하거나,dotenv를 사용하여test.env파일 등을 활용할 수 있습니다. - 외부 서비스 경계: 검증 목적에 따라 공급자의 sandbox를 사용하거나 제어 가능한 test double로 대체합니다. 결제·SMS처럼 비용, quota, 비결정성이 큰 의존성을 무조건 실제 호출하는 것은 좋은 E2E의 조건이 아닙니다.
- mock lifecycle: 호출 이력만 비우는
clear, 구현까지 초기화하는reset중 필요한 정책을 정하고beforeEach에서 일관되게 적용합니다.mockRestore()로 원래 구현을 복원할 수 있는 것은jest.spyOn()으로 만든 spy이며, 직접 대입한jest.fn()은 원래 값을 별도로 보관해 복원해야 합니다.
NestJS의 Test.createTestingModule에서 특정 프로바이더만 오버라이드(override)해 모킹된 버전을 주입할 수 있습니다.
아래는 등록 기능이 이미 있는 프로젝트의 부분 발췌입니다. EmailService가 선택한 모듈 그래프에 등록되고 /api/register route가 실제 구현되어 있어야 합니다. 앞의 덧셈 예제에 이 테스트 파일만 붙여서 회원가입 기능이 생기는 것은 아닙니다.
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from './../src/app.module';
import { configureApp } from './../src/configure-app';
import { EmailService } from './../src/email/email.service'; // 이메일 서비스 (외부 의존성)
// 이메일 서비스를 모킹하여 실제 이메일 전송을 방지
const mockEmailService = {
sendEmail: jest.fn(() => Promise.resolve('Email sent successfully')),
};
describe('User Registration (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(EmailService) // EmailService를 오버라이드
.useValue(mockEmailService) // 모킹된 EmailService 사용
.compile();
app = moduleFixture.createNestApplication();
configureApp(app);
await app.init();
});
beforeEach(() => {
mockEmailService.sendEmail.mockClear();
});
afterAll(async () => {
await app.close();
});
it('/api/register (POST) should register user and send email', () => {
return request(app.getHttpServer())
.post('/api/register')
.send({ username: 'testuser', email: '[email protected]', password: 'password123' })
.expect(201)
.expect(res => {
expect(res.body.message).toBe('User registered successfully');
})
.then(() => {
// 모킹된 이메일 서비스의 sendEmail 메서드가 호출되었는지 검증
expect(mockEmailService.sendEmail).toHaveBeenCalledTimes(1);
expect(mockEmailService.sendEmail).toHaveBeenCalledWith('[email protected]', 'Welcome testuser!');
});
});
});overrideProvider()를 사용하면 전체 AppModule을 로드하면서도 특정 프로바이더만 가짜 구현으로 대체할 수 있습니다.
이는 실제 데이터베이스 연결은 사용하되, 외부 API 호출과 같은 특정 로직만 모킹하고 싶을 때 유용합니다.
이 예제에서도 app.close()는 Nest lifecycle과 연결을 닫을 뿐입니다. 등록된 사용자 row나 외부 sandbox 기록을 정리하려면 해당 자원의 API 또는 저장소를 테스트 teardown에서 별도로 호출해야 합니다.
E2E 테스트는 실제 요청 흐름, 모듈 연결, 데이터 저장소 연동을 함께 검증하는 테스트입니다.
실행 속도와 유지보수 비용이 높을 수 있으므로 핵심 사용자 흐름과 장애 가능성이 큰 경로를 우선 대상으로 삼아야 합니다.
Jest와 Supertest는 NestJS 애플리케이션의 HTTP 경계를 검증하는 데 사용할 수 있습니다.
이것으로 8장 테스팅 전략의 두 번째 절을 마칩니다.
다음 절에서는 테스트 커버리지 도구를 사용하여 테스트 코드의 품질을 측정하고 개선하는 방법에 대해 알아보겠습니다.