본문으로 건너뛰기

안동민 개발노트

본문 시작

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 테스트의 역할

NestJS 백엔드 애플리케이션에서 E2E 테스트는 주로 HTTP/REST API 엔드포인트를 호출해 서버 응답을 검증하는 방식으로 진행됩니다.

프론트엔드가 있는 경우 Cypress, Playwright 같은 도구를 함께 사용해 브라우저 상호작용까지 포함할 수 있습니다.

NestJS는 기본적으로 JestSupertest 라이브러리를 사용해 E2E 테스트 환경을 제공합니다.

다음 기준선은 테스트 환경과 데이터 준비부터 HTTP 호출, 검증, 정리까지 Nest/Supertest E2E의 책임 경계를 보여줍니다.

테스트 환경을 고정하고 선택한 Nest 모듈을 컴파일한 뒤 프로덕션과 같은 bootstrap 설정으로 초기화하여 Supertest 요청, 응답과 부수 효과 검증, 영속 상태 정리, 애플리케이션 종료까지 수행하는 HTTP E2E 흐름

Nest · Supertest HTTP boundary

HTTP E2E는 “앱을 띄웠다”가 아니라 같은 계약으로 준비, 호출, 단언, 정리를 완료했다는 뜻입니다. 테스트 모듈의 범위와 외부 대체물을 고른 뒤, 프로덕션 bootstrap 설정을 공유하고 결과와 부수 효과를 함께 확인합니다.

HTTP E2E lifecycle

한 시나리오의 준비부터 종료까지

  1. 테스트 환경과 모듈 범위를 고정한다

    테스트 DB와 환경 변수를 선택하고 TestingModule을 컴파일합니다. 전체 AppModule 또는 목적에 맞는 모듈을 쓰고, 외부 프로바이더는 필요한 만큼 override합니다.

  2. Nest 애플리케이션 객체를 만든다

    createNestApplication()은 테스트용 앱 객체를 만들지만 main.ts의 명령형 bootstrap 코드를 자동 실행하지 않습니다.

  3. 프로덕션과 같은 HTTP 설정을 적용한다

    공유 configureApp으로 프로젝트가 실제 사용하는 global prefix, pipes, filters, interceptors를 같은 순서로 적용합니다.

  4. route와 lifecycle을 초기화한다

    app.init()은 선택한 그래프를 준비합니다. 프로덕션 포트에 listen하지 않고 브라우저도 구동하지 않습니다.

  5. 결정적인 시나리오 상태를 seed한다

    고정 factory와 고유 key로 필요한 row만 만들고, 외부 sandbox나 test double의 시작 상태도 함께 고정합니다.

  6. Supertest로 실제 HTTP 계약을 호출한다

    getHttpServer()에 method, path, header, body를 보내 global prefix와 구성된 HTTP 경계를 통과합니다. DTO validation은 잘못된 body 사례로 따로 단언합니다.

  7. 응답과 관찰 가능한 효과를 단언한다

    status, headers, body뿐 아니라 DB 반영, 이벤트 발행, 외부 호출처럼 시나리오가 약속한 persistence와 side effect를 확인합니다.

  8. 영속 상태를 정리한 뒤 앱을 닫는다

    row, queue, 파일, sandbox 기록, mock history를 자원별로 정리하고 마지막에 app.close()로 Nest lifecycle과 열린 연결을 종료합니다.

선택 가능한 실제성

E2E라고 모든 의존성이 실제일 필요는 없다

핵심 모듈과 DB는 실제로 연결하되 결제·메일처럼 비결정적이거나 비용이 큰 경계는 sandbox 또는 provider override를 쓸 수 있습니다. 중요한 것은 선택을 시나리오 이름과 setup에 드러내는 일입니다.

공유 bootstrap

configureApp이 계약 차이를 막는다

테스트에서 global prefix나 validation pipe가 빠지면 통과한 route가 프로덕션에서는 실패할 수 있습니다. 명령형 설정은 한 함수로 공유하거나 DI 기반 global provider로 등록합니다.

관찰 범위

Supertest 경계와 브라우저 E2E를 구분한다

이 흐름은 Nest HTTP 서버까지 검증합니다. DOM, 라우팅, 사용자 클릭과 프론트엔드 네트워크를 검증하려면 별도의 Playwright·Cypress 시스템 E2E가 필요합니다.

teardown 오해 금지

app.close()는 데이터 삭제 명령이 아니다

Nest가 관리하는 lifecycle과 연결은 닫지만 이미 commit된 DB row, 발행된 message, 생성된 파일이나 외부 sandbox 데이터는 각 자원의 cleanup으로 되돌려야 합니다.

실패 지점을 좁히려면 setup, request, assertion, cleanup의 로그와 식별자를 분리합니다. 한 테스트의 성공은 응답 단언뿐 아니라 다음 테스트가 같은 시작 조건을 얻는 것까지 포함합니다.


NestJS E2E 테스트 환경 설정

새 NestJS 프로젝트를 생성하면 test 폴더에 app.e2e-spec.ts 파일과 jest-e2e.json 설정 파일이 기본으로 제공됩니다.

jest-e2e.json (E2E 테스트를 위한 Jest 설정)
jest-e2e.json
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": "\\.e2e-spec\\.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}
  • testRegex: \.e2e-spec\.ts$ 패턴으로 끝나는 파일만 E2E 테스트로 인식하여 실행합니다. 이는 단위 테스트와 E2E 테스트를 분리하여 실행할 때 유용합니다.
  • rootDirtest 폴더라면 <rootDir>/srctest/src를 뜻합니다. 별칭이 필요하면 실제 소스 위치인 <rootDir>/../src/$1처럼 프로젝트 구조에 맞춰 명시해야 하며, 이 예제는 상대 import를 사용하므로 별도 매핑을 두지 않습니다.

E2E 테스트 작성: AppController 예시

AppController의 간단한 HTTP 엔드포인트를 테스트하는 예시를 통해 E2E 테스트의 기본 구조를 이해해 보겠습니다.

AppController 코드 (이전과 동일)
src/app.controller.ts
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 코드 (이전과 동일)
src/app.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }

  sum(a: number, b: number): number {
    return a + b;
  }
}

프로덕션과 E2E가 같은 HTTP 계약을 사용하도록 main.ts에만 있던 명령형 bootstrap 설정을 함수로 분리합니다. 전역 filter와 interceptor를 코드에서 직접 등록한다면 이 함수에 함께 두고, DI가 필요하면 APP_FILTER, APP_INTERCEPTOR 프로바이더로 등록합니다.

src/configure-app.ts
import {
  INestApplication,
  ValidationPipe,
} from '@nestjs/common';

export function configureApp(app: INestApplication): void {
  app.setGlobalPrefix('api');
  app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
}
src/main.ts
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 파일

NestJS가 기본으로 제공하는 app.e2e-spec.ts 파일을 수정하여 사용하거나, 새로운 E2E 테스트 파일을 생성할 수 있습니다.

test/app.e2e-spec.ts
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이 되는 사례를 별도로 단언해야 합니다.

테스트 실행

E2E 테스트는 별도의 Jest 설정 파일을 사용하므로, package.json에 정의된 스크립트를 사용합니다.

npm run test:e2e

콘솔에 테스트 통과 결과가 나타날 것입니다.


데이터베이스 연동 E2E 테스트 시 고려사항

실제 애플리케이션의 E2E 테스트는 데이터베이스를 포함하는 경우가 많습니다.

이때는 DB 연결만 분리하는 데서 끝내지 말고, seed와 외부 의존성, queue·파일, mock 기록, 열린 handle까지 자원별 준비·격리·정리·실패 신호를 정해야 합니다.

데이터베이스, seed, 외부 서비스, queue와 파일, mock, 애플리케이션 lifecycle을 Setup, Isolation, Cleanup, Failure 단계로 나누어 E2E 테스트의 자원별 책임을 비교한 행렬

Nest · E2E resource matrix

격리는 도구 하나가 아니라 자원마다 다른 네 단계 계약입니다. 준비 방법, 병렬 실행 경계, 지울 대상, 누수 신호를 함께 정해야 실행 순서와 worker 수가 달라져도 같은 결과를 얻습니다.

자원별 Setup · Isolation · Cleanup · Failure 기준
자원 Setup Isolation Cleanup Failure signal
Database 테스트 DSN, migration 적용 worker별 schema·DB·container 또는 유효한 transaction 경계 생성 row 삭제·truncate·rollback 중 한 전략 중복 key, 순서 의존, 병렬 충돌
Seed 결정적 factory와 명시적 ID 시나리오별 namespace와 고유 key 만든 ID 목록으로 정확히 삭제 시간·랜덤 값에 따라 assertion 변화
External sandbox 계정 또는 제어 가능한 test double 테스트별 tenant·idempotency key 외부 record·webhook·호출 기록 제거 quota, 네트워크, 공유 계정 오염
Queue · file 전용 queue와 임시 경로 준비 run ID prefix와 worker별 디렉터리 message drain, 파일·디렉터리 삭제 다음 테스트 소비, 남은 artifact
Mock provider override와 기본 구현 고정 테스트별 instance 또는 명시적 lifecycle clear·reset; jest.spyOn만 restore 이전 호출 횟수와 구현이 누적
App lifecycle 공유 bootstrap 뒤 app.init() suite별 app과 추적 가능한 handle 영속 상태 정리 후 app.close() Jest open handle, timeout, 종료 지연
Database

worker 경계를 먼저 고른다

Setup 테스트 DSN과 migration · Isolation schema, DB, container 또는 transaction · Cleanup delete, truncate, rollback · Failure 중복 key와 병렬 충돌

Seed

필요한 상태만 결정적으로 만든다

Setup factory와 명시적 ID · Isolation run namespace · Cleanup 생성 ID로 삭제 · Failure 시간·랜덤·실행 순서 의존

External

sandbox와 test double을 목적에 맞게 선택한다

Setup 제어 가능한 계정·대체물 · Isolation tenant와 idempotency key · Cleanup 외부 record와 호출 기록 · Failure quota와 공유 계정 오염

Queue · file

DB 밖의 영속 흔적도 추적한다

Setup 전용 queue와 임시 경로 · Isolation run ID prefix · Cleanup message drain과 파일 삭제 · Failure 다음 테스트가 이전 artifact 소비

Mock

호출 이력과 구현 수명을 구분한다

Setup provider override · Isolation 테스트별 instance · Cleanup clear, reset, jest.spyOn restore 선택 · Failure 호출 횟수와 구현 누적

App lifecycle

영속 상태 정리 뒤 handle을 닫는다

Setup 공유 bootstrap과 init · Isolation suite별 app · Cleanup 자원 정리 후 close · Failure 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)해 모킹된 버전을 주입할 수 있습니다.

예시: 특정 서비스 오버라이드
test/some.e2e-spec.ts (부분 발췌)
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: 'test@example.com', 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('test@example.com', 'Welcome testuser!');
      });
  });
});

overrideProvider()를 사용하면 전체 AppModule을 로드하면서도 특정 프로바이더만 가짜 구현으로 대체할 수 있습니다.

이는 실제 데이터베이스 연결은 사용하되, 외부 API 호출과 같은 특정 로직만 모킹하고 싶을 때 유용합니다.

이 예제에서도 app.close()는 Nest lifecycle과 연결을 닫을 뿐입니다. 등록된 사용자 row나 외부 sandbox 기록을 정리하려면 해당 자원의 API 또는 저장소를 테스트 teardown에서 별도로 호출해야 합니다.


E2E 테스트는 실제 요청 흐름, 모듈 연결, 데이터 저장소 연동을 함께 검증하는 테스트입니다.

실행 속도와 유지보수 비용이 높을 수 있으므로 핵심 사용자 흐름과 장애 가능성이 큰 경로를 우선 대상으로 삼아야 합니다.

Jest와 Supertest는 NestJS 애플리케이션의 HTTP 경계를 검증하는 데 사용할 수 있습니다.

이것으로 8장 테스팅 전략의 두 번째 절을 마칩니다.

다음 절에서는 테스트 커버리지 도구를 사용하여 테스트 코드의 품질을 측정하고 개선하는 방법에 대해 알아보겠습니다.