Search

11. 통합 테스트

이번 글에서는 NestJS 애플리케이션에서 엔드투엔드(E2E) 테스트를 작성하는 방법을 알아본다.
앞서 단위 테스트에 대해 다뤄봤지만, 엔드투엔드 테스트는 애플리케이션의 서로 다른 부분들이 함께 잘 동작하는지 확인하는 데 중점을 둔다.

1. 엔드투엔드 테스트란?

엔드투엔드 테스트는 애플리케이션의 전반적인 동작을 확인하기 위해, 실제 서버 인스턴스를 생성하여 요청을 보내고 응답을 확인하는 테스트 방식이다. 단위 테스트가 특정 메서드의 동작을 검증하는 것과 달리, E2E 테스트는 애플리케이션의 다양한 컴포넌트가 유기적으로 잘 작동하는지를 확인한다.

E2E 테스트 구조

NestJS 프로젝트를 생성하면 루트 프로젝트 폴더에 자동으로 test 디렉터리가 생성된다.
이 폴더 안에는 기본적인 엔드투엔드 테스트 파일이 하나 들어있다. 파일 구조는 다음과 같다.
describe 블록: 테스트 모듈을 생성한다.
beforeEach 블록: 각 테스트 전에 실행될 초기화 작업을 수행한다.
테스트 코드: 실제로 서버를 띄우고 요청을 보내 응답을 검증한다.
import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from 'src/app.moudle'; describe('AppController (e2e)', () => { let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); }); it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') .expect(200) .expect('Hello World!'); }); });
TypeScript
복사
app.e2e-spec.ts
이 구조는 단위 테스트와 유사하지만, E2E 테스트는 애플리케이션 인스턴스를 전체로 띄워서 진행된다는 점이 다르다.

E2E 테스트의 특징

애플리케이션의 전체 인스턴스를 생성하여 테스트한다.
테스트가 실행될 때마다 새로운 서버 인스턴스가 만들어진다.
서버 인스턴스는 컴퓨터의 임의 포트에서 트래픽을 수신하도록 할당된다.
실제 요청을 보내고 응답을 검증하여 각 기능이 올바르게 작동하는지 확인한다.

테스트 실행 방법

엔드투엔드 테스트를 실행하기 위해 다음 명령어를 사용한다.
npm run test:e2e
TypeScript
복사
이 명령어는 test 디렉터리 안의 모든 E2E 테스트를 실행하며, src 폴더 안의 단위 테스트 파일과는 별도로 수행된다.

2. 인증 시스템 테스트

Step 1: 테스트 파일 생성 및 보일러플레이트 준비

먼저 test 디렉토리 안에 새로운 테스트 파일(auth.e2e-spec.ts)을 만든다.
보일러플레이트를 처음부터 작성할 필요는 없다. 기존 app.e2e-spec.ts 파일의 내용을 복사해서 auth.e2e-spec.ts에 붙여넣고 app.e2e-spec.ts는 닫아준다.
import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from 'src/app.module'; import * as request from 'supertest'; describe('AppController (e2e)', () => { let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); }); it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') .expect(200) .expect('Hello World!'); }); });
TypeScript
복사
app.e2e-spec.ts

Step 2: 테스트 설명 변경

맨 위 테스트 설명 부분이 아래와 같다면
describe('AppController (e2e)', () => {
TypeScript
복사
이건 우리가 지금 테스트하는 대상이 아니기 때문에 아래처럼 변경한다.
describe('AppController (e2e)', () => {
TypeScript
복사

Step 3: 회원가입 테스트 작성

회원가입을 테스트해보자. API 문서를 보면, POST /auth/signup 엔드포인트로 email, password를 보내야한다. 성공하면 idemail을 포함한 유저 객체가 반환된다.
it('handles a signup request', () => { const email = 'asdkdf@asdf.com'; return request(app.getHttpServer()) .post('/auth/signup') .send({ email, password: 'asdfasdf', }) .expect(201) .then((res) => { const { id, email } = res.body; expect(id).toBeDefined(); expect(email).toEqual(email); }); });
TypeScript
복사
여기서 몇 가지 테스트 포인트를 짚어보자.
expect(id).toBeDefined(): 무작위로 생성되는 ID는 값이 정확하지 않기 때문에 존재 여부만 확인한다.
expect(email).toEqual(email): 요청에 사용한 이메일과 응답의 이메일이 같아야한다.

Step 4: 테스트 실행하기

테스트를 실행해보자.
npm run test:e2e
TypeScript
복사
터미널에서 테스트를 실행했을 때 다음과 같은 에러가 나타났다.
Received status code: 500 Cannot set properties of undefined (setting 'userId')
Shell
복사
요청 처리 도중 userId 속성을 설정하려 했지만, 세션 객체 자체가 정의되지 않은 상태였던 것이다.
왜 이런 오류가 발생했을까?
개발 환경에서는 main.ts를 통해 애플리케이션을 실행하면서 다음과 같은 설정을 적용한다.
쿠키 기반 세션 미들웨어
전역 유효성 파이프 (ValidationPipe)
app.use( cookieSession({ keys: ['asdfasdf'], }), ); app.useGlobalPipes( new ValidationPipe({ whitelist: true, }), );
TypeScript
복사
하지만 테스트 환경에서는 중요한 차이가 하나 있다.
엔드투엔드 테스트에서는 main.ts가 실행되지 않는다.
대신 테스트 파일 내에서 AppModule을 직접 불러와 NestApplication을 생성한다.
따라서 위에서 설정한 세션 미들웨어와 유효성 파이프가 아예 적용되지 않는 상태로 테스트가 수행되는 것이다.
그래서 req.session.userId = … 코드를 실행할 때 req.session 자체가 undefined인 상황이 되어 위와 같은 오류가 발생한다.
이 문제의 해결 방법으로는 크게 2가지가 있다.
첫 번째 방법은 간단하지만 권장 방식은 아니고, 두 번째 방법은 복잡하지만 NestJS가 의도한 정석적인 방식이다.

해결 방법1: setupApp 헬퍼 함수로 설정 코드 재사용하기

이 문제를 가장 빠르게 해결하는 방법은 main.ts의 설정 코드를 별도의 함수로 분리하여 테스트 환경에서도 동일하게 적용하는 것이다.
1.
setup-app.ts 생성
// src/setup-app.ts import { ValidationPipe } from '@nestjs/common'; import * as cookieSession from 'cookie-session'; export const setupApp = (app: any) => { app.use( cookieSession({ keys: ['asdfasdf'], }), ); app.useGlobalPipes( new ValidationPipe({ whitelist: true, }), ); };
TypeScript
복사
2.
main.ts 수정
// main.ts import { setupApp } from './setup-app'; async function bootstrap() { const app = await NestFactory.create(AppModule); setupApp(app); await app.listen(3000); } bootstrap();
TypeScript
복사
3.
테스트 파일에서 설정 적용
// auth.e2e-spec.ts import { setupApp } from '../setup-app'; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); setupApp(app); // 설정 적용 await app.init(); });
TypeScript
복사
이렇게 하면 테스트 환경에서도 개발 환경과 동일한 설정이 적용되므로 req.session 관련 오류가 발생하지 않는다.
하지만 이건 완벽한 해결책이 아니다. 위 방법은 빠르고 간단하지만, NestJS가 의도한 방식은 아니다.
더 정성적인 방법은 Nest의 모듈 시스템과 DI 컨테이너를 활용하여 테스트 전용 설정 모듈을 구성하거나, AppModule 자체를 환경에 따라 유연하게 설정할 수 있도록 만드는 것이다.

해결 방법 2: AppModule에 설정하는 방법

우리가 흔히 글로벌 설정이라고 하면 main.ts에서 아래와 같이 코드를 작성하는 경우가 많다.
const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe(...));
TypeScript
복사
이 방식은 실제로 애플리케이션을 실행할 때는 잘 동작하지만,
테스트 환경에서는 main.ts가 실행되지 않기 때문에 글로벌 파이프나 미들웨어가 작동하지 않는 문제가 발생한다.
그래서 AppModule 자체에 설정을 옮겨야 테스트 환경에서도 안정적으로 작동하게 할 수 있다.
사실 main.ts에 설정하던 것과 거의 비슷한 방식이다. 다만 위치만 AppModule 내부로 옮길 뿐이다.
1.
먼저 app.module.ts 상단에서 필요한 모듈을 불러온다.
import { ValidationPipe } from '@nestjs/common'; import { APP_PIPE } from '@nestjs/core';
TypeScript
복사
2.
그런 다음, providers 배열에 다음과 같이 추가해준다.
providers: [ { provide: APP_PIPE, useValue: new ValidationPipe({ whitelist: true, }), }, ],
TypeScript
복사
이렇게 하면 AppModule이 생성될 때 자동으로 유효성 파이프가 설정되고, 애플리케이션에 들어오는 모든 요청에 대해 ValidationPipe가 실행된다.
main.ts와의 차이는 뭘까?
사실 코드 구조만 보면 두 방식 모두 크게 다르지 않다.
하지만 중요한 차이점은 AppModule에서 설정하면 테스트에서도 정상 작동한다는 것이고, main.ts에서 설정하면 테스트 시 무시된다는 것이다.
따라서, 테스트까지 고려한 안정적인 NestJS 앱을 만든다면 AppModule에서 글로벌 설정을 등록하는 방식이 더 추천되는 방법이다.
이번에는 쿠키 세션 미들웨어를 AppModule에 글로벌 미들웨어로 적용해보자.
3.
이전에 사용했던 main.tsapp.useGlobalPipes 문과 cookie-session 관련 코드를 깔끔하게 제거해준다.
// main.ts // ❌ 삭제할 코드 app.use(cookieSession({...})); app.useGlobalPipes(new ValidationPipe({...}));
TypeScript
복사
이제 이 역할은 AppModule에서 대신 수행하게 된다.
4.
app.module.ts 상단에서 필요한 모듈을 불러온다.
// app.module.ts import { MiddlewareConsumer } from '@nestjs/common'; const cookieSession = require('cookie-session');
TypeScript
복사
5.
configure 함수를 작성한다.
export class AppModule { configure(consumer: MiddlewareConsumer) { consumer .apply( cookieSession({ keys: ['your-secret-key'], }), ) .forRoutes('*'); // 모든 라우트에 적용 } }
TypeScript
복사
forRoutes(’*’)는 앱에 들어오는 모든 요청에 이 미들웨어를 적용하겠다는 의미이다.
즉, 전역(Global) 미들웨어가 된다.
이 방식은 공식 문서에서 제시한 방법이지만, 전체 설정을 한눈에 파악하기 어려운 단점이 있다.
main.ts에서 설정하면 bootstrap 함수 안에서 모든 설정이 모이기 때문에, 앱의 초기화 흐름을 빠르게 파악할 수 있다.
반면 AppModule에 분산되어 있으면, 각 설정이 숨겨져 있어 처음 보는 사람이 전체 구조를 파악하기 어렵다.
설정이 완료되었으니, 엔드투엔드 테스트로 돌아가 테스트가 잘 동작하는지 확인해보자.
npm run test:e2e
TypeScript
복사
테스트가 정상적으로 통과하는 것을 볼 수 있다.
하지만, 테스트를 똑같이 한번 더 실행하면 중복된 이메일로 실패하는 것을 볼 수 있다.

3. 인증 시스템 테스트 개선하기

엔드투엔드 테스트가 한 번은 성공하고, 다음에 실패한 이유는 무엇일까?
NestJS에서 E2E 테스트는 beforeEach를 사용해 각 테스트마다 새로운 앱 인스턴스를 생성하게 된다.
문제는 이전에 만든 db.sqlite 파일은 그대로 남아 있다는 것이다.
즉, 두 번째 테스트는 이전 테스트와 똑같은 이메일 주소로 가입을 시도하게 되고, 이는 Nest 애플리케이션에서 이미 존재하는 이메일이라고 판단해 400 오류를 발생시킨다.
expected 201 "Created", got 400 "Bad Request"
TypeScript
복사

해결 방법

테스트는 테스트만의 데이터베이스에서!
이 문제를 해결하는 가장 확실한 방법은 각 테스트 전에 데이터베이스를 완전히 지우고 새로 만드는 것이다.
각 텍스트가 완전히 격리되어 실행되도록 만들고, 이전 테스트의 데이터가 남지 않도록 정리한다면 문제를 해결할 수 있다.
test.sqlite와 같은 테스트 전용 DB 파일을 사용하면, 매번 테스트 전에 깔끔하게 초기화할 수 있다.
그리고 개발 중에는 로그인 테스트용 계정을 남겨두는 게 편한데, 테스트가 실행될 때마다 개발용 DB를 지워버리면 아주 귀찮아진다.
따라서, 테스트할 때만 test.sqlite를 매번 삭제하고 초기화하고 개발용은 그대로 유지하는 것이 좋다.
목적
파일명
설명
개발용
db.sqlite
개발자 본인이 직접 테스트할 때 사용
테스트용
test.sqlite
jest로 엔드투엔드 테스트할 때 사용
NestJS의 app.module.ts를 열어보면, TypeOrmModule 설정에 데이터베이스 파일 경로가 하드코딩되어 있을 가능성이 높다.
TypeOrmModule.forRoot({ type: 'sqlite', database: 'db.sqlite', // 개발용으로 고정됨 ... })
TypeScript
복사
이를 NODE_ENV에 따라 사용할 데이터베이스를 다르게 지정하면 간단하게 문제를 해결할 수 있다.
database: process.env.NODE_ENV === 'test' ? 'test.sqlite' : 'db.sqlite'
TypeScript
복사
하지만 Nest에서는 환경 변수를 읽는 공식 권장 방식이 있다.
조금 복잡해보이지만, 확장성과 유연성을 위해 권장된 구조를 따르는 걸 추천한다.
다음 글에서는 실제 jest 설정 파일과 TypeORM 설정에서 개발용 DB와 테스트용 DB를 어떻게 하면 나눌 수 있을지 자세히 살펴본다.