NestJS + DB 연동 기반 E2E 테스트 자동화 구축기

Seung Hyeon ·2025년 3월 29일
0

백엔드

목록 보기
20/21
post-thumbnail

Introduction

NestJS 프로젝트에서 E2E 테스트를 구현하며 고려한 목표는 “실제 환경과 최대한 유사하게 테스트를 수행하는 것” 이었다.
이를 위해 mock을 지양하고, 실제 DB와 연동하여 테스트 데이터를 시딩하고 트리거까지 포함한 E2E 테스트 환경을 구축했다.
이 글에서는 그 과정과 구조, 그리고 git action을 통한 자동화된 테스트 실행 방식까지 공유한다.

Overview

사내 인트라넷 서비스를 개발하면서, 단순한 CRUD를 넘어 통계 및 비즈니스 판단 로직이 점점 많아지기 시작했다.
사용자별 잔여 복지 금액 계산, 지각 여부 판단, 사용 가능 횟수 제한 등의 기능들이 그 예이다.
따라서 실제 유사 데이터를 기반으로 흐름을 검증할 수 있는 E2E 테스트의 필요성이 커졌다.

처음에는 테스트를 위해 별도의 테스트용 DB를 운영 서버에 하나 더 만들어두고, 테스트 환경에서 그 DB를 바라보도록 구성했다. 하지만 곧 여러 문제가 발생했다.

  • 테스트 DB를 여러 명이 공유하다 보니 테스트 결과가 서로 영향을 주는 일이 생겼고
  • 테이블 구조나 트리거가 변경되면 테스트 데이터가 예상과 달라져서 테스트가 실패하거나 무의미해졌으며
  • 매번 수동으로 상태를 초기화하거나 DB를 직접 조작해야 하는 번거로움도 있었다.

따라서 테스트를 실행할 때마다 Docker를 활용해 테스트용 DB를 새로 초기화하고, 시딩(seeding)과 트리거(trigger) 설정까지 자동화하는 방식을 도입했다.
실제 운영 환경과 유사한 상태를 매번 새로 구성하여 테스트 정확도를 높이고 mock 없이 실제 흐름을 검증하는 구조로 개선하였다.

※ seeding : 더미데이터 생성하기
※ trigger : mysql 트리거

더 나아가, 이 테스트 환경을 GitHub Actions와 연동해 dev 브랜치에 머지될 때마다 테스트가 자동으로 실행되도록 구성함으로써 팀원들의 테스트 참여를 유도하고자 했다.


1. 초기 접근 방식: 테스트용 DB를 서버에 두고 공유

테스트용 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"
},

2. 개선 방향: Docker 기반 테스트 DB 자동 초기화

🛠️ 테스트 DB 환경 구성

테스트용 DB는 로컬에서 docker-compose를 활용해 자동으로 생성되도록 구성했다.

  • docker-compose.test.yaml 파일을 사용해 mariadb 컨테이너를 띄운다.
  • 이때, mariadb의 기본 포트(3306)를 로컬의 3307 포트로 포트포워딩하여 localhost:3307으로 DB접속이 가능하도록 설정했다.
  • 환경 변수 파일 .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

🛠️ 테스트 스크립트 구성

테스트는 아래와 같은 흐름으로 실행된다 :

  1. test:db:start DB 컨테이너 실행 (DB initialize)
  2. sleep 4 테스트 실행 전 4초 동안 잠시 대기
  3. jest --runInBand 테스트 직렬 실행
  4. 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에 직접 연결한 뒤 다음과 같은 작업을 수행한다:

  • 기본 더미데이터 seeding
  • E2E 테스트에 필요한 트리거 설정

해당 작업은 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


3. 자동화 도입: GitHub Action과 CI 테스트 연동

✅ 왜 자동화를 도입했는가?

앞서 말했듯이, 통계 및 비즈니스 로직이 많아지면서 수정 시 로직이 꼬이거나 계산이 어긋날 경우 실제 운영 환경에 큰 문제가 발생할 수 있었다.
이 때문에 E2E 테스트의 중요성이 점점 커졌고 테스트를 놓치거나 실패한 코드를 dev 브랜치에 머지하는 실수를 방지할 필요가 생겼다.
또한 코드 변경 후 테스트를 깜빡하거나 로컬 환경마다 테스트 결과가 달라지는 상황도 자주 발생했다.

이를 해결하고자 CI 기반 테스트 자동화를 도입했고, dev 브랜치로 PR이 생성되면 자동으로 E2E 테스트가 실행되도록 구성해 테스트를 개발 프로세스에 자연스럽게 녹여내고자 했다.

✅ GitHub Action으로 테스트 자동화하기

  1. dev 브랜치에 PR이 생성되면 워크플로우가 시작된다.
  2. 테스트 실행 시, 시간대 차이로 인해 시간 관련 테스트가 실패하지 않도록 서버 시간을 KST로 고정한다.
  3. Node.js 환경을 구축한다.
  4. npm run test 명령어로 E2E 테스트를 실행한다.
  5. 만약 테스트가 실패하면 해당 PR를 자동으로 닫는 로직을 추가했다.

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 를 사용한 이유?

  • Jest에서 모든 테스트 파일을 순차적으로 실행하는 옵션이다.
  • 공통 모듈을 한 번만 import해서 재사용할 수 있고, Node.js의 require 캐시가 그대로 유지돼서 import 속도가 훨씬 빨라질 수 있다. (병렬 실행은 프로세스 간 메모리 공유가 안 되기 때문에 캐시가 무효화된다.)
  • 병렬성이 사라지지만, I/O 비용이 큰 DB 연결, 모듈 초기화, 시딩 작업 등을 공유하거나 캐시할 수 있어서 전체 실행 시간은 오히려 줄어들 수 있다.
  • 즉, 테스트 파일 수가 적고 테스트 간 공통 모듈이 많을 경우, --runInBand가 병렬 실행보다 유리할 수 있다.

https://stackoverflow.com/questions/43864793/why-does-jest-runinband-speed-up-tests

profile
안되어도 될 때까지

0개의 댓글