NestJS 프로젝트에서 E2E 테스트를 구현하며 고려한 목표는 “실제 환경과 최대한 유사하게 테스트를 수행하는 것” 이었다.
이를 위해 mock을 지양하고, 실제 DB와 연동하여 테스트 데이터를 시딩하고 트리거까지 포함한 E2E 테스트 환경을 구축했다.
이 글에서는 그 과정과 구조, 그리고 git action을 통한 자동화된 테스트 실행 방식까지 공유한다.
사내 인트라넷 서비스를 개발하면서, 단순한 CRUD를 넘어 통계 및 비즈니스 판단 로직이 점점 많아지기 시작했다.
사용자별 잔여 복지 금액 계산, 지각 여부 판단, 사용 가능 횟수 제한 등의 기능들이 그 예이다.
따라서 실제 유사 데이터를 기반으로 흐름을 검증할 수 있는 E2E 테스트의 필요성이 커졌다.
처음에는 테스트를 위해 별도의 테스트용 DB를 운영 서버에 하나 더 만들어두고, 테스트 환경에서 그 DB를 바라보도록 구성했다. 하지만 곧 여러 문제가 발생했다.
따라서 테스트를 실행할 때마다 Docker를 활용해 테스트용 DB를 새로 초기화하고, 시딩(seeding)과 트리거(trigger) 설정까지 자동화하는 방식을 도입했다.
실제 운영 환경과 유사한 상태를 매번 새로 구성하여 테스트 정확도를 높이고 mock 없이 실제 흐름을 검증하는 구조로 개선하였다.
※ seeding : 더미데이터 생성하기
※ trigger : mysql 트리거
더 나아가, 이 테스트 환경을 GitHub Actions와 연동해 dev 브랜치에 머지될 때마다 테스트가 자동으로 실행되도록 구성함으로써 팀원들의 테스트 참여를 유도하고자 했다.
테스트용 DB typeorm 세팅 (DATABASE_CONFIG_TEST)
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModuleAsyncOptions } from '@nestjs/typeorm';
// 실제 DB
export const DATABASE_CONFIG: TypeOrmModuleAsyncOptions = {
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'mariadb',
host: configService.get<string>('DB_HOST'),
port: configService.get<number>('DB_PORT'),
username: configService.get<string>('DB_USER'),
password: configService.get<string>('DB_PW'),
database: configService.get<string>('DB_NAME'),
charset: 'utf8mb4',
entities: ['dist/**/*.entity.js'],
synchronize: true,
}),
};
// 테스트 DB
export const DATABASE_CONFIG_TEST: TypeOrmModuleAsyncOptions = {
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'mariadb',
host: configService.get<string>('TEST_DB_HOST'),
port: configService.get<number>('TEST_DB_PORT'),
username: configService.get<string>('TEST_DB_USER'),
password: configService.get<string>('TEST_DB_PW'),
database: configService.get<string>('TEST_DB_NAME'),
charset: 'utf8mb4',
entities: [__dirname + '/../**/*.entity.{ts,js}'],
synchronize: true,
}),
};
테스트 앱 초기화
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync(DATABASE_CONFIG_TEST), // 테스트용 DB를 바라보도록 !!
AuthModule,
...
],
}).compile();
const app: INestApplication = moduleFixture.createNestApplication();
....
});
afterAll(async () => {
await app.close();
});
package.json
"scripts": {
"build": "nest build",
...
...
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
🛠️ 테스트 DB 환경 구성
테스트용 DB는 로컬에서 docker-compose를 활용해 자동으로 생성되도록 구성했다.
.env.test
을 적용하였다.docker-compose.test.yaml
services:
benefit-test-db:
image: mariadb:latest
container_name: benefit-test-db
environment:
MYSQL_ROOT_PASSWORD: 11111111
MYSQL_DATABASE: benefit_test
MYSQL_PASSWORD: 11111111
ports:
- '3307:3306'
command: --default-authentication-plugin=mysql_native_password
database.config.ts
export const TEST_TYPEORM_CONFIG: TypeOrmModuleAsyncOptions = {
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService): Promise<TypeOrmModuleOptions> => ({
type: 'mariadb',
host: configService.get<string>('TEST_DB_HOST'),
port: configService.get<number>('TEST_DB_PORT'),
username: configService.get<string>('TEST_DB_USER'),
password: configService.get<string>('TEST_DB_PW'),
database: configService.get<string>('TEST_DB_NAME'),
charset: 'utf8mb4',
entities: [join(__dirname, '../entity/**/*.entity{.ts,.js}')],
synchronize: true,
}),
};
.env.test
NODE_ENV=test
...
TEST_DB_HOST=localhost
TEST_DB_PORT=3307
TEST_DB_USER=root
TEST_DB_PW=11111111
TEST_DB_NAME=benefit_test
🛠️ 테스트 스크립트 구성
테스트는 아래와 같은 흐름으로 실행된다 :
test:db:start
DB 컨테이너 실행 (DB initialize)sleep 4
테스트 실행 전 4초 동안 잠시 대기 jest --runInBand
테스트 직렬 실행 test:db:stop
테스트 완료 후 DB 종료👉 npm run test !!
( ※ 직렬로 실행됨에도 불구하고 --runInBand
를 사용한 이유는 맨 아래 부록에... )
package.json
"scripts": {
"test:db:start": "docker compose --env-file .env.test -f docker-compose.test.yaml up -d",
"test:db:stop": "docker compose -f docker-compose.test.yaml down -v",
"test": "npm run test:db:start && sleep 4 && jest --runInBand --verbose && npm run test:db:stop"
},
...
"jest": {
...
"setupFilesAfterEnv": [ // 테스트 실행 전, setup.ts 파일을 먼저 실행
"<rootDir>/test/setup.ts"
]
}
🛠️ 시딩과 트리거 설정
테스트 코드 실행 전, TypeORM을 통해 DB에 직접 연결한 뒤 다음과 같은 작업을 수행한다:
해당 작업은 setup.ts 파일 안에서 실행되며, 테스트 실행 전에 setup.ts 파일을 먼저 실행하도록 미리 package.json에서 지정해준다.
이러한 시딩과 트리거 설정은 beforeAll 블록 안에서 한 번만 수행되며, 테스트 전체에서 공유된다. 테스트 종료 후에는 afterAll에서 DB 연결을 종료한다.
setup.ts
describe('CommuteController(e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: '.env.test',
isGlobal: true,
}),
TypeOrmModule.forRootAsync(TEST_TYPEORM_CONFIG),
AuthModule,
UserModule,
...
],
}).compile();
const app: INestApplication = moduleFixture.createNestApplication();
await app.init();
});
afterEach(async () => {
if (app) {
await app.close()
}
});
/**
* ✅ 출근 요청 & 검증 함수
*/
async function checkInAndVerify( ... ) { .... }
/**
* ✅ 퇴근 요청 & 검증 함수
*/
async function checkOutAndVerify( ... ) { .... }
/**
* ✅ 테스트 케이스 작성
*/
describe('Normal 출근 (지각X)', () => {
it("'정상 출근'으로 잘 표시되었는가?", async () => {
await checkInAndVerify(...);
});
it("'정상 퇴근'으로 잘 표시되었는가?", async () => {
await checkOutAndVerify(...);
});
});
describe('Normal 출근 (지각O)', () => {
it("'정상 출근(지각)'으로 잘 표시되었는가?", async () => {
await checkInAndVerify(...);
});
it("'정상 퇴근(지각)'으로 잘 표시되었는가?", async () => {
await checkOutAndVerify(...);
});
});
});
beforeAll, beforeEach, afterEach, afterAll 등의 테스트 라이프사이클 훅의 실행 순서를 정확히 이해하고 사용하는 것이 중요하다.
특히 데이터베이스를 사용하는 테스트의 경우, 하나의 테스트에서 변경된 데이터가 다른 테스트에 영향을 주지 않도록 라이프사이클 관리가 꼭 필요하다.
Thanks To @JH8459 !! 🙇
(참고)
https://www.borntodare.me/unit_test_improvement_nest_2
✅ 왜 자동화를 도입했는가?
앞서 말했듯이, 통계 및 비즈니스 로직이 많아지면서 수정 시 로직이 꼬이거나 계산이 어긋날 경우 실제 운영 환경에 큰 문제가 발생할 수 있었다.
이 때문에 E2E 테스트의 중요성이 점점 커졌고 테스트를 놓치거나 실패한 코드를 dev 브랜치에 머지하는 실수를 방지할 필요가 생겼다.
또한 코드 변경 후 테스트를 깜빡하거나 로컬 환경마다 테스트 결과가 달라지는 상황도 자주 발생했다.
이를 해결하고자 CI 기반 테스트 자동화를 도입했고, dev 브랜치로 PR이 생성되면 자동으로 E2E 테스트가 실행되도록 구성해 테스트를 개발 프로세스에 자연스럽게 녹여내고자 했다.
✅ GitHub Action으로 테스트 자동화하기
e2e-test.yaml
name: E2E Test & Merge to Dev
on:
pull_request:
branches: ['dev']
jobs:
e2e-test:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Timezone to GMT+9
run: sudo timedatectl set-timezone Asia/Seoul
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 22.4.1
- name: Install Dependencies
run: npm install
- name: Create .env.test
run: |
touch .env.test
echo "TEST_DB_NAME=${{ secrets.TEST_DB_NAME }}" >> .env.test
...
- name: Run E2E Tests
run: npm run test
- name: Close PR if Tests Fail
if: failure()
run: |
PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
echo "❌ E2E 테스트 실패! PR #$PR_NUMBER을 닫습니다."
curl -X PATCH -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER \
-d '{"state":"closed"}'
✅ 앞으로의 개선 방향
현재 테스트 로직은 실행 시간에 대해 크게 고려하지 않고 있다. ( --runInBand
말곤 딱히...)
사내 서비스 특성상 지금은 테스트 속도가 큰 문제가 되지 않지만, 향후 테스트 대상 모듈이 많아지면 실행 시간이 길어질 수 있다.
따라서 테스트 병렬화를 도입해 전체 실행 시간을 단축하는 방안도 고려해봐야겠다.
💜 --runInBand
를 사용한 이유?
https://stackoverflow.com/questions/43864793/why-does-jest-runinband-speed-up-tests