본문 바로가기

E2E 테스트 구현

실제 애플리케이션에 HTTP 요청을 보내는 E2E 환경을 구성하고 정상·실패 응답과 데이터 정리 경계를 검증합니다.

앞 글에서는 서비스와 controller를 격리해 단위 테스트를 작성했습니다. 이 글에서는 사용자 관점에서 애플리케이션의 전체 흐름을 확인하는 E2E(End-to-End) 테스트를 구현합니다. 상태 준비, 요청 시나리오, 실패 응답, 데이터 정리 기준을 함께 살펴봅니다.

단위 테스트가 개별 코드 조각의 동작을 확인한다면, E2E 테스트는 실제 요청으로 애플리케이션을 통과하면서 모듈 연결과 저장 결과까지 함께 확인합니다. 애플리케이션이 운영 환경에서 사용자에게 어떻게 동작할지 미리 확인하는 단계입니다.


E2E 테스트란

E2E 테스트는 애플리케이션의 시작부터 끝까지의 흐름을 재현해, 시스템이 의도대로 동작하는지 확인하는 테스트입니다. NestJS와 Supertest를 쓰는 이 글의 E2E 테스트는 브라우저를 띄우지 않고 Nest 애플리케이션의 HTTP 입구까지를 확인합니다. 브라우저 상호작용과 프런트엔드까지 포함하는 시스템 E2E는 Playwright나 Cypress 같은 별도 도구의 범위입니다.

E2E 테스트에는 다음과 같은 특징이 있습니다.

  • 사용자 관점: 실제 사용자가 겪을 수 있는 시나리오를 재현합니다.
  • 범위 선택: 전체 AppModule을 쓸 수도 있고, 목적에 맞는 모듈만 불러오거나 외부 provider를 가짜로 바꿀 수도 있습니다.
  • 통합 동작 확인: 여러 구성 요소가 제대로 연결되어 동작하는지 확인합니다.
  • 느린 실행: 실제 애플리케이션을 초기화해야 하므로 단위 테스트보다 오래 걸립니다.
  • 불안정성(flakiness): 네트워크 지연, 외부 서비스 상태, 비동기 작업 때문에 같은 테스트가 가끔 실패하는 일이 생길 수 있습니다.

NestJS 백엔드에서 E2E 테스트는 주로 HTTP API를 호출해 서버의 응답을 확인하는 방식으로 진행합니다. 이 글은 1장에서 CommonJS로 만든 프로젝트의 Jest 30과 Supertest를 기준으로 합니다. Nest 공식 테스트 문서는 ESM 프로젝트의 기본 러너인 Vitest로 예제를 보여 주지만, Test.createTestingModule(), createNestApplication(), Supertest를 쓰는 방식은 같습니다.

Nest와 Supertest E2E의 HTTP 경계

환경/compile·configureApp/init·상태 준비·HTTP 검증·영속 상태 API 정리·app.close의 준비·검증·정리 순서다.

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

Nest · HTTP E2E lifecycle

HTTP E2E의 준비부터 종료까지

테스트 모듈의 범위와 외부 대체물을 고른 뒤 프로덕션과 HTTP 설정을 공유합니다. 요청·응답뿐 아니라 영속 상태의 정리까지 한 시나리오의 책임으로 봅니다.

앱 종료와 영속 상태 정리는 서로 다른 단계다테스트 환경을 고정한 뒤 선택한 모듈을 컴파일하고 앱 객체를 만든다. 공유 configureApp을 적용해야 prefix와 pipe가 프로덕션과 같고 app.init으로 route와 lifecycle을 준비한다. 필요한 시나리오 상태만 준비하고 Supertest HTTP 요청과 결과·부수 효과를 검증한다. 마지막에는 생성한 영속 상태를 각 자원의 API로 정리한 뒤 app.close로 관리 연결과 lifecycle을 종료한다. close만으로 commit된 row나 발행한 message가 삭제되는 관계는 아니다.
환경 · 모듈 선택DB · 외부 경계
연결 관계환경 · 모듈 선택 · DB · 외부 경계 → TestingModule · compile()
TestingModulecompile()
연결 관계TestingModule · compile() → 앱 객체 생성 · createNestApplication
앱 객체 생성createNestApplication
연결 관계앱 객체 생성 · createNestApplication → 공유 HTTP 설정 · configureApp · prefix
공유 HTTP 설정configureApp · prefix
연결 관계공유 HTTP 설정 · configureApp · prefix → route 초기화 · app.init()
route 초기화app.init()
연결 관계route 초기화 · app.init() → 시나리오 상태 준비 · 결정적 seed · key
시나리오 상태 준비결정적 seed · key
연결 관계시나리오 상태 준비 · 결정적 seed · key → Supertest 요청 · method · path · body
Supertest 요청method · path · body
연결 관계Supertest 요청 · method · path · body → 계약 단언 · 응답 · 관찰 효과
계약 단언응답 · 관찰 효과
연결 관계계약 단언 · 응답 · 관찰 효과 → 영속 상태 정리 · row · queue · 파일
영속 상태 정리row · queue · 파일
정리 뒤 종료정리 뒤 종료영속 상태 정리 · row · queue · 파일 → 앱 종료 · app.close() · 연결
앱 종료app.close() · 연결
HTTP E2E 준비·검증·정리의 순서테스트 환경을 고정한 뒤 선택한 모듈을 컴파일하고 앱 객체를 만든다. 공유 configureApp을 적용해야 prefix와 pipe가 프로덕션과 같고 app.init으로 route와 lifecycle을 준비한다. 필요한 시나리오 상태만 준비하고 Supertest HTTP 요청과 결과·부수 효과를 검증한다. 마지막에는 생성한 영속 상태를 각 자원의 API로 정리한 뒤 app.close로 관리 연결과 lifecycle을 종료한다. close만으로 commit된 row나 발행한 message가 삭제되는 관계는 아니다.
환경 · 모듈 선택DB · 외부 경계
연결 관계환경 · 모듈 선택 · DB · 외부 경계 → TestingModule · compile()
TestingModulecompile()
연결 관계TestingModule · compile() → 앱 객체 생성 · createNestApplication
앱 객체 생성createNestApplication
연결 관계앱 객체 생성 · createNestApplication → 공유 HTTP 설정 · configureApp · prefix
공유 HTTP 설정configureApp · prefix
연결 관계공유 HTTP 설정 · configureApp · prefix → route 초기화 · app.init()
route 초기화app.init()
연결 관계route 초기화 · app.init() → 시나리오 상태 준비 · 결정적 seed · key
시나리오 상태 준비결정적 seed · key
연결 관계시나리오 상태 준비 · 결정적 seed · key → Supertest 요청 · method · path · body
Supertest 요청method · path · body
연결 관계Supertest 요청 · method · path · body → 계약 단언 · 응답 · 관찰 효과
계약 단언응답 · 관찰 효과
연결 관계계약 단언 · 응답 · 관찰 효과 → 영속 상태 정리 · row · queue · 파일
영속 상태 정리row · queue · 파일
연결 관계영속 상태 정리 · row · queue · 파일 → 앱 종료 · app.close() · 연결
앱 종료app.close() · 연결
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의 로그와 식별자를 분리합니다. 한 테스트의 성공은 응답 단언뿐 아니라 다음 테스트가 같은 시작 조건을 얻는 것까지 포함합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 테스트 환경을 고정한 뒤 선택한 모듈을 컴파일하고 앱 객체를 만든다. 공유 configureApp을 적용해야 prefix와 pipe가 프로덕션과 같고 app.init으로 route와 lifecycle을 준비한다. 필요한 시나리오 상태만 준비하고 Supertest HTTP 요청과 결과·부수 효과를 검증한다. 마지막에는 생성한 영속 상태를 각 자원의 API로 정리한 뒤 app.close로 관리 연결과 lifecycle을 종료한다. close만으로 commit된 row나 발행한 message가 삭제되는 관계는 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest와 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 모듈을 컴파일한 뒤 프로덕션과 같은 bootstrap 설정으로 초기화하여 Supertest 요청, 응답과 부수 효과 검증, 영속 상태 정리, 애플리케이션 종료까지 수행하는 HTTP E2E 흐름

Nest · HTTP E2E lifecycle

HTTP E2E의 준비부터 종료까지

테스트 모듈의 범위와 외부 대체물을 고른 뒤 프로덕션과 HTTP 설정을 공유합니다. 요청·응답뿐 아니라 영속 상태의 정리까지 한 시나리오의 책임으로 봅니다.

앱 종료와 영속 상태 정리는 서로 다른 단계다테스트 환경을 고정한 뒤 선택한 모듈을 컴파일하고 앱 객체를 만든다. 공유 configureApp을 적용해야 prefix와 pipe가 프로덕션과 같고 app.init으로 route와 lifecycle을 준비한다. 필요한 시나리오 상태만 준비하고 Supertest HTTP 요청과 결과·부수 효과를 검증한다. 마지막에는 생성한 영속 상태를 각 자원의 API로 정리한 뒤 app.close로 관리 연결과 lifecycle을 종료한다. close만으로 commit된 row나 발행한 message가 삭제되는 관계는 아니다.
환경 · 모듈 선택DB · 외부 경계
연결 관계환경 · 모듈 선택 · DB · 외부 경계 → TestingModule · compile()
TestingModulecompile()
연결 관계TestingModule · compile() → 앱 객체 생성 · createNestApplication
앱 객체 생성createNestApplication
연결 관계앱 객체 생성 · createNestApplication → 공유 HTTP 설정 · configureApp · prefix
공유 HTTP 설정configureApp · prefix
연결 관계공유 HTTP 설정 · configureApp · prefix → route 초기화 · app.init()
route 초기화app.init()
연결 관계route 초기화 · app.init() → 시나리오 상태 준비 · 결정적 seed · key
시나리오 상태 준비결정적 seed · key
연결 관계시나리오 상태 준비 · 결정적 seed · key → Supertest 요청 · method · path · body
Supertest 요청method · path · body
연결 관계Supertest 요청 · method · path · body → 계약 단언 · 응답 · 관찰 효과
계약 단언응답 · 관찰 효과
연결 관계계약 단언 · 응답 · 관찰 효과 → 영속 상태 정리 · row · queue · 파일
영속 상태 정리row · queue · 파일
정리 뒤 종료정리 뒤 종료영속 상태 정리 · row · queue · 파일 → 앱 종료 · app.close() · 연결
앱 종료app.close() · 연결
HTTP E2E 준비·검증·정리의 순서테스트 환경을 고정한 뒤 선택한 모듈을 컴파일하고 앱 객체를 만든다. 공유 configureApp을 적용해야 prefix와 pipe가 프로덕션과 같고 app.init으로 route와 lifecycle을 준비한다. 필요한 시나리오 상태만 준비하고 Supertest HTTP 요청과 결과·부수 효과를 검증한다. 마지막에는 생성한 영속 상태를 각 자원의 API로 정리한 뒤 app.close로 관리 연결과 lifecycle을 종료한다. close만으로 commit된 row나 발행한 message가 삭제되는 관계는 아니다.
환경 · 모듈 선택DB · 외부 경계
연결 관계환경 · 모듈 선택 · DB · 외부 경계 → TestingModule · compile()
TestingModulecompile()
연결 관계TestingModule · compile() → 앱 객체 생성 · createNestApplication
앱 객체 생성createNestApplication
연결 관계앱 객체 생성 · createNestApplication → 공유 HTTP 설정 · configureApp · prefix
공유 HTTP 설정configureApp · prefix
연결 관계공유 HTTP 설정 · configureApp · prefix → route 초기화 · app.init()
route 초기화app.init()
연결 관계route 초기화 · app.init() → 시나리오 상태 준비 · 결정적 seed · key
시나리오 상태 준비결정적 seed · key
연결 관계시나리오 상태 준비 · 결정적 seed · key → Supertest 요청 · method · path · body
Supertest 요청method · path · body
연결 관계Supertest 요청 · method · path · body → 계약 단언 · 응답 · 관찰 효과
계약 단언응답 · 관찰 효과
연결 관계계약 단언 · 응답 · 관찰 효과 → 영속 상태 정리 · row · queue · 파일
영속 상태 정리row · queue · 파일
연결 관계영속 상태 정리 · row · queue · 파일 → 앱 종료 · app.close() · 연결
앱 종료app.close() · 연결
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의 로그와 식별자를 분리합니다. 한 테스트의 성공은 응답 단언뿐 아니라 다음 테스트가 같은 시작 조건을 얻는 것까지 포함합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 테스트 환경을 고정한 뒤 선택한 모듈을 컴파일하고 앱 객체를 만든다. 공유 configureApp을 적용해야 prefix와 pipe가 프로덕션과 같고 app.init으로 route와 lifecycle을 준비한다. 필요한 시나리오 상태만 준비하고 Supertest HTTP 요청과 결과·부수 효과를 검증한다. 마지막에는 생성한 영속 상태를 각 자원의 API로 정리한 뒤 app.close로 관리 연결과 lifecycle을 종료한다. close만으로 commit된 row나 발행한 message가 삭제되는 관계는 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest와 Supertest E2E의 HTTP 경계
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
앱 준비

환경 고정·compile 뒤 앱을 만들고 configureApp·init을 적용합니다.

준비: prefix·pipe·route · 환경: 테스트실제 HTTP 계약이 준비됩니다.
상태와 HTTP 검증

필요한 시나리오 상태를 만들고 HTTP·부수 효과를 확인합니다.

실행: Supertest HTTP · 단언: 결과·부수 효과앱 경계를 넘은 결과를 관찰합니다.
상태 정리 뒤 close

자원 API로 영속 상태를 지운 뒤 app.close합니다.

영속 상태: API 정리 · 앱: close · close: row 삭제 아님시험 소유 자료와 관리 연결을 각각 정리합니다.

위 그림은 HTTP E2E 시나리오 하나의 준비부터 종료까지를 순서대로 보여 줍니다. 테스트 환경과 모듈 범위를 정해 TestingModule을 컴파일하고, 애플리케이션 객체를 만든 뒤 공유 configureApp()으로 운영과 같은 prefix와 pipe를 적용하고 app.init()으로 route를 준비합니다. 그다음 필요한 상태만 준비해 Supertest로 요청을 보내고 응답과 부수 효과를 확인합니다. 마지막 두 단계가 따로 그려진 이유는, 테스트가 만든 데이터를 각 자원의 방법으로 정리하는 일과 app.close()로 연결을 닫는 일이 서로 다르기 때문입니다.


NestJS E2E 테스트 환경 설정

NestJS 12의 CommonJS 템플릿에는 test/app.e2e-spec.ts와 test/jest-e2e.json이 이미 들어 있고, package.json의 test:e2e 스크립트가 --config ./test/jest-e2e.json으로 이 설정 파일을 지정합니다. 그래서 아래 JSON은 프로젝트 루트가 아니라 test 폴더에 둡니다.

test/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 테스트를 따로 실행할 때 유용합니다.
  • rootDir: JSON 설정 파일의 rootDir은 그 파일이 있는 폴더를 기준으로 하므로, "."은 test 폴더를 뜻합니다. 그래서 <rootDir>/src는 test/src가 됩니다. 경로 별칭이 필요하다면 실제 소스 위치인 <rootDir>/../src/$1처럼 지정해야 하며, 이 예제는 상대 경로 import를 쓰므로 따로 매핑하지 않습니다.

E2E 테스트 작성: AppController 예시

AppController의 간단한 HTTP 엔드포인트로 E2E 테스트의 기본 구조를 익혀 보겠습니다. controller와 서비스는 앞 글과 같습니다.

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);
  }
}
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;
  }
}

따라 하려면 AppModule의 controllers에 위 AppController가, providers에 위 AppService가 등록되어 있어야 합니다. 테스트가 AppModule을 가져온다고 해서 다른 파일의 클래스가 자동으로 등록되지는 않습니다. 기본 템플릿의 두 등록은 그대로 두고 해당 파일의 내용만 바꿉니다.

운영과 E2E가 같은 HTTP 설정을 쓰도록, main.ts에만 있던 설정 코드를 함수로 분리합니다. 전역 filter와 interceptor를 코드에서 직접 등록한다면 이 함수에 함께 두고, 의존성 주입이 필요하면 APP_FILTER, APP_INTERCEPTOR provider로 등록합니다.

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();

이제 test/app.e2e-spec.ts를 다음과 같이 작성합니다. 템플릿에 있던 파일을 고쳐도 되고, 새로 만들어도 됩니다.

test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { configureApp } from './../src/configure-app';

describe('AppController (e2e)', () => {
  let app: INestApplication<App>;

  // 각 테스트 스위트가 시작되기 전에 단 한 번 실행됩니다.
  // 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를 가져옵니다. App 타입은 템플릿처럼 supertest/types에서 가져와 INestApplication<App>의 타입 인자로 씁니다.
  • Test.createTestingModule({ imports: [AppModule] }).compile(): 테스트에 쓸 모듈 그래프를 컴파일합니다. 전체 AppModule을 쓸 수도 있고, 목적에 따라 일부 모듈만 고르거나 overrideProvider()로 통제하기 어려운 외부 의존성을 바꿀 수도 있습니다.
  • configureApp(app): createNestApplication()은 main.ts의 설정 코드를 자동으로 실행하지 않습니다. 운영과 테스트가 global prefix, pipe, filter, interceptor 설정을 함께 써야 같은 HTTP 동작을 확인할 수 있습니다.
  • await app.init(): 모듈 그래프의 수명 주기와 route를 초기화합니다. 운영 포트에서 listen()하지 않으며 브라우저도 실행하지 않습니다.
  • await app.close(): Nest가 관리하는 수명 주기와 연결을 종료합니다. 테스트가 만든 DB 행, 큐 메시지, 파일, 외부 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는 .expect({ id: 1, name: 'Test User' })처럼 JSON 응답도 확인할 수 있습니다.

이 sum 예제의 인라인 객체 타입에는 런타임 validation metadata가 없습니다. 그래서 정상 POST 테스트는 공유 설정과 route가 맞는지 확인할 뿐, ValidationPipe의 변환이나 whitelist 규칙까지 증명하지는 않습니다. 그 규칙을 확인하려면 데코레이터가 붙은 DTO를 쓰고, 잘못된 본문이 400이 되는 사례를 따로 작성해야 합니다.

E2E 테스트는 별도의 Jest 설정 파일을 쓰므로 package.json의 test:e2e 스크립트로 실행합니다.

npm run test:e2e

콘솔에 세 테스트가 모두 통과했다는 결과가 나타납니다.

더 깊이: listen하지 않은 서버에 Supertest가 요청하는 방법

app.init()만 호출한 Nest 애플리케이션의 HTTP 서버는 아직 어떤 포트에서도 연결을 기다리지 않습니다. Supertest 문서에 따르면 request()에 넘긴 http.Server가 아직 연결을 기다리는 중이 아니면, Supertest가 그 서버를 임시 포트(ephemeral port)에 직접 바인딩합니다. Node.js에서 포트를 0으로 주고 listen하면 운영체제가 쓰이지 않는 포트를 하나 골라 주므로, 테스트는 운영 포트 3000과 겹치지 않고 병렬로 도는 Jest worker끼리도 포트를 다투지 않습니다.

요청은 실제 TCP 연결을 거쳐 Node.js의 HTTP 파서와 Nest의 HTTP 어댑터를 그대로 통과합니다. 그래서 가짜 요청 객체를 만들어 핸들러를 부르는 방식과 달리, 헤더 처리와 본문 파싱까지 운영과 같은 경로로 확인됩니다. 반대로 app.listen()으로 이미 연 서버를 넘기면 Supertest는 그 주소로 요청하므로, 고정 포트를 쓰는 테스트는 병렬 실행이나 다른 프로세스와 충돌할 수 있습니다.


데이터베이스를 포함한 E2E 테스트

실제 애플리케이션의 E2E 테스트는 데이터베이스를 포함하는 경우가 많습니다. 이때는 DB 연결만 따로 두는 데서 끝내지 말고, 초기 데이터(seed), 외부 의존성, 큐와 파일, mock 기록, 열린 연결까지 자원마다 준비·격리·정리 방법과 실패 신호를 정해야 합니다.

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

Nest · E2E resource matrix

E2E 자원별 격리와 정리

준비 방법, 병렬 실행 경계, 지울 대상, 누수 신호를 자원별로 정합니다. 앱 종료와 데이터 정리는 별도의 책임입니다.

자원별 Setup · Isolation · Cleanup · Failure 기준
자원 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, 종료 지연
Database

worker 경계를 먼저 고른다

Setup
테스트 DSN, migration 적용
Isolation
worker별 schema·DB·container 또는 유효한 transaction 경계
Cleanup
생성 row 삭제·truncate·rollback 중 한 전략
Failure signal
중복 key, 순서 의존, 병렬 충돌
Seed

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

Setup
결정적 factory와 명시적 ID
Isolation
시나리오별 namespace와 고유 key
Cleanup
만든 ID 목록으로 정확히 삭제
Failure signal
시간·랜덤 값에 따라 assertion 변화
External

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

Setup
sandbox 계정 또는 제어 가능한 test double
Isolation
테스트별 tenant·idempotency key
Cleanup
외부 record·webhook·호출 기록 제거
Failure signal
quota, 네트워크, 공유 계정 오염
Queue · file

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

Setup
전용 queue와 임시 경로 준비
Isolation
run ID prefix와 worker별 디렉터리
Cleanup
message drain, 파일·디렉터리 삭제
Failure signal
다음 테스트 소비, 남은 artifact
Mock

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

Setup
provider override와 기본 구현 고정
Isolation
테스트별 instance 또는 명시적 lifecycle
Cleanup
clear·reset; spy·교체 속성은 restoreAllMocks
Failure signal
이전 호출 횟수와 구현이 누적
App lifecycle

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

Setup
공유 bootstrap 뒤 app.init()
Isolation
suite별 app과 추적 가능한 handle
Cleanup
영속 상태 정리 후 app.close()
Failure signal
Jest open handle, timeout, 종료 지연

Transaction rollback은 애플리케이션 요청과 테스트가 같은 transaction 경계를 공유할 때만 모든 쓰기를 되돌립니다. Testcontainers도 강력한 선택지일 뿐 절대적인 정답은 아니며, 기동 비용과 CI 환경, 병렬화 요구를 함께 비교해야 합니다.

위 표는 DB, 초기 데이터, 외부 서비스, 큐·파일, mock, 앱 수명 주기라는 여섯 가지 자원마다 준비, 격리, 정리 방법과 문제가 생겼다는 신호를 정리합니다. 예를 들어 DB는 worker마다 schema나 데이터베이스를 나누어 병렬 실행이 충돌하지 않게 하고, 앱 수명 주기는 영속 상태를 정리한 뒤에 app.close()를 호출하며, 이를 빠뜨리면 Jest가 열린 연결 때문에 종료되지 않는 증상이 나타납니다. 표 아래 문장은 트랜잭션 롤백이 애플리케이션 요청과 테스트가 같은 트랜잭션을 공유할 때만 모든 쓰기를 되돌린다는 점을 덧붙입니다.

  • 독립적인 테스트 데이터: 결정적인 factory와 고유한 key로 필요한 데이터만 만들고, 테스트가 끝나면 DB 행, 큐 메시지, 임시 파일처럼 테스트가 만든 영속 상태를 명시적으로 정리합니다.
  • DB 격리 방법: 트랜잭션 롤백은 빠르지만 애플리케이션과 테스트가 같은 연결과 트랜잭션을 공유할 때만 효과가 있습니다. 병렬로 도는 worker에는 worker마다 schema, 데이터베이스, 컨테이너를 나누는 방법이 더 분명할 수 있습니다. Testcontainers도 좋은 선택지이지만 Docker 기동 비용과 CI 환경의 제약을 함께 따져야 합니다.
  • 환경 변수 관리: 테스트 환경에서는 데이터베이스 연결 정보나 외부 서비스 API 키 같은 환경 변수를 다르게 설정해야 합니다. @nestjs/config 모듈의 envFilePath로 .env.test 같은 파일을 지정해 쓸 수 있습니다.
  • 외부 서비스: 확인하려는 목적에 따라 공급자의 sandbox를 쓰거나 통제할 수 있는 테스트 대역으로 바꿉니다. 결제나 SMS처럼 비용, 할당량, 비결정성이 큰 의존성을 무조건 실제로 호출하는 것이 좋은 E2E의 조건은 아닙니다.
  • mock 수명: 호출 기록만 비우는 clear와 구현까지 지우는 reset 가운데 필요한 정책을 정하고 beforeEach에서 일관되게 적용합니다. mockRestore()로 원래 구현을 되돌릴 수 있는 것은 jest.spyOn()으로 만든 spy뿐이며, 직접 대입한 jest.fn()은 원래 값을 따로 보관했다가 되돌려야 합니다.

NestJS의 Test.createTestingModule()에서는 특정 provider만 바꿔 가짜 버전을 주입할 수 있습니다. 아래는 회원 가입 기능이 이미 있는 프로젝트의 일부입니다. EmailService가 모듈 그래프에 등록되어 있고 /api/register route가 실제로 구현되어 있어야 하며, 앞의 덧셈 예제에 이 테스트 파일만 붙인다고 회원 가입 기능이 생기지는 않습니다.

test/some.e2e-spec.ts (부분 발췌)
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
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<App>;

  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을 불러오면서도 특정 provider만 가짜 구현으로 바꿀 수 있습니다. 실제 데이터베이스 연결은 쓰되 외부 API 호출 같은 특정 로직만 가짜로 두고 싶을 때 유용합니다. 공식 문서에 따르면 같은 방식으로 overrideModule(), overrideGuard(), overrideInterceptor(), overrideFilter(), overridePipe()도 쓸 수 있습니다.

이 예제에서도 app.close()는 Nest의 수명 주기와 연결만 닫습니다. 가입한 사용자 행이나 외부 sandbox 기록을 정리하려면 테스트 정리 단계에서 해당 자원의 API나 저장소를 따로 호출해야 합니다.

E2E 테스트는 실행 속도와 유지보수 비용이 높을 수 있으므로, 핵심 사용자 흐름과 장애가 날 가능성이 큰 경로부터 대상으로 삼습니다.


정리

  • NestJS의 E2E 테스트는 Test.createTestingModule()로 모듈을 컴파일하고 createNestApplication()과 app.init()으로 앱을 초기화한 뒤, Supertest로 app.getHttpServer()에 HTTP 요청을 보냅니다.
  • CommonJS 템플릿의 E2E 설정은 test/jest-e2e.json에 있고, npm run test:e2e가 이 파일을 지정해 실행합니다.
  • createNestApplication()은 main.ts의 설정을 실행하지 않으므로, global prefix와 pipe 같은 설정은 공유 함수로 분리해 운영과 테스트가 함께 씁니다.
  • overrideProvider()로 외부 의존성만 가짜로 바꾸면서 나머지 모듈은 실제로 연결할 수 있습니다.
  • app.close()는 연결과 수명 주기만 닫을 뿐 테스트가 만든 데이터를 지우지 않으므로, 영속 상태는 자원마다 따로 정리합니다.

연습 문제

1. prefix를 바꾸고 문자열 숫자를 보내면

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 등록만으로 잘못된 본문이 거부된다고 판단하면 안 되며, DTO와 오류 응답 사례를 따로 준비해야 그 동작을 확인할 수 있습니다.

2. 테스트에서 설정 함수를 빠뜨리면

누군가 test/app.e2e-spec.ts의 configureApp(app); 줄을 지웠습니다. 운영 서버는 그대로 잘 동작합니다. 세 테스트 가운데 어떤 것이 실패할까요? 반대로 테스트 요청 경로까지 /app/hello로 고쳐 모두 통과하게 만들었다면 무엇이 문제일까요?

해설 보기

global prefix가 적용되지 않으므로 테스트 앱의 실제 route는 /app/hello와 /app/sum이 됩니다. 그래서 /api/app/hello와 /api/app/sum 테스트는 404로 실패하고, 원래 404를 기대하던 세 번째 테스트만 통과합니다.

테스트 경로를 /app/hello로 고쳐 통과시키면, 테스트는 운영에 없는 경로를 확인하는 셈이 됩니다. 운영 클라이언트가 실제로 호출하는 /api/app/hello는 검증되지 않고, 운영에서만 켜 둔 pipe나 filter의 동작도 테스트에서 빠집니다. 그래서 설정은 공유 함수 하나로 운영과 테스트가 함께 쓰게 합니다.

3. 테스트가 끝났는데 Jest가 멈추지 않는다

afterAll의 await app.close();를 지우고 npm run test:e2e를 실행했습니다. 테스트는 모두 통과했지만 터미널이 바로 끝나지 않습니다. 무슨 일이 일어난 것이고, DB를 연결한 프로젝트에서는 어떤 문제가 더 생길 수 있을까요?

해설 보기

Nest 애플리케이션이 연 HTTP 서버와 연결이 닫히지 않아, 테스트가 끝난 뒤에도 Node.js 프로세스에 열린 handle이 남아 있습니다. Jest는 이런 경우 Jest did not exit one second after the test run has completed. 같은 경고를 내고, --detectOpenHandles 옵션으로 어떤 handle이 남았는지 확인할 수 있습니다.

DB를 연결한 프로젝트라면 데이터베이스 연결 풀도 닫히지 않습니다. 테스트 스위트를 여러 번 실행하거나 병렬로 돌리면 연결이 쌓여 데이터베이스의 최대 연결 수에 걸릴 수 있습니다. app.close()는 테스트 데이터를 지우지는 않지만, 이런 연결을 정리하는 데는 꼭 필요합니다.

다음 글 테스트 커버리지와 품질 관리에서는 테스트 커버리지 도구로 테스트 코드가 얼마나 넓게 실행되는지 측정하고, 그 숫자를 품질 관리에 어떻게 활용할지 다룹니다.