시작 하며...
테스트는 프로덕션 레벨에서 특정 로직이 의도한대로 돌아가는가? 를 검증할 수 있어야 한다. 이에 따라 테스트 환경을 어떻게 세팅하는지는 단단한 애플리케이션을 설계하기 위해서 반드시 필요한 과제이다.
간단하게 테스트 환경을 구축하려면, sqlite나 H2와 같은 경량 RDB들을 활용할 수 있다. 그런데, 앞서 언급했던 이런 환경은 실제 프로덕션레벨과 다른 환경이기에 경량 DB가 지원하지 않는 기능들이나 실제 DB와 다르게 동작하는 부분들에 대해서는 테스트 안정성을 보장받기 힘들다.
Testcontainers는 테스트시 테스트 전용 가상 DB 환경을 세팅하기 위한 도커 컨테이너를 간편하게 생성하고, 활용할 수 있도록 해주는 도구이다. 본 포스팅에서는 Testcontainers를 활용해서 NestJS + TypeORM 환경에서 MySQL을 활용한 테스트 환경 구성 방법을 다룬다.
의존성 설치
npm install --save-dev testcontainers @testcontainers/mysql
필요한 의존성을 설치한다. (DB에 따라 다르다.)
testcontainers에 모듈을 직접 사용할 일은 없으나, @testcontainers/mysql에서 활용되기에 설치가 필요하다.
Testcontainer 셋업 적용하기
1. globalSetup, globalTeardown 추가하기
jest에는 globalSetup 이라는 속성과 globalTeardown이라는 값을 jest config 파일에 넣어줄 수 있다. 우리는 테스트 컨테이너를 만들고 종료하는 로직을 각각 셋업과 티어다운 파일에 추가해야한다.
테스트를 실행할때 사용할 config.json 파일 내에 추가해 준다.
- setupFilesAfterEnv : 모든 테스트 파일에서 공통적으로 실행이 필요한 동작들을 명시하는 setup 파일
- globalSetup : 모든 테스트 suites들이 실행되기 전에 딱 한번만 실행할 커스텀 동작들 export 하는 파일
- globalTeardown : 모든 테스트 suites들의 실행이 종료되고, 마지막에 호출될 커스텀 동작을 export 하는 파일
- maxWorkers : jest가 테스트 실행시 사용할 프로세스 개수를 관리하는 변수. 병렬 실행을 방지하기 위해 1 값을 줌.
테스트용으로 사용할 DB인 컨테이너는 단 하나만 생성 된다. Jest가 자식 프로세스를 생성해 병렬적으로 실행하게 둬버리면, 테스트 파일 수만큼 컨테이너가 생성이 되어버려 셋업 동작이 너무 무거워질 우려가 있어 하나만 생성토록 했다.
// test/jest-integration.json
{
"rootDir": "..",
"testRegex": "test/.*\\.(e2e-spec|spec).ts$",
"setupFilesAfterEnv": ["./test/jest.setup.ts"],
... 다른 설정들
"globalSetup": "./test/mysql-global-setup.ts",
"globalTeardown": "./test/mysql-global-teardown.ts",
"maxWorkers": 1
}
2. globalSetup, globalTeardown 작성하기
이제 Testcontainers를 활용해서 생성할 MySQL 컨테이너를 코드에서 관리해보자.
// test/mysql-global-setup.ts
import { MySqlContainer, StartedMySqlContainer } from '@testcontainers/mysql';
const globalAny: any = global;
export default async () => {
console.log('Starting MySQL container...');
const testContainer: StartedMySqlContainer = await new MySqlContainer(
'mysql:8',
)
.withDatabase('denamu')
.start();
console.log('MySQL container started.');
globalAny.__MYSQL_CONTAINER__ = testContainer;
// 환경 변수 설정 (기존에 TypeORM 세팅에 사용하던 키값들과 네이밍을 동일하게 할 것!)
process.env.DB_TYPE = 'mysql';
process.env.DB_HOST = testContainer.getHost();
process.env.DB_PORT = testContainer.getPort().toString();
process.env.DB_USERNAME = testContainer.getUsername();
process.env.DB_PASSWORD = testContainer.getUserPassword();
process.env.DB_DATABASE = testContainer.getDatabase();
console.log('Global setup completed.');
};
테스트 컨테이너를 생성하고 나면, 해당 컨테이너는 랜덤한 포트를 MySQL에 매핑해준다. 그렇기에 우리는 생성된 컨테이너의 정보들을 추후 테스트 환경의 TypeORM 의 DB 설정값에 넣어줄 수 있도록 process.env를 통해 저장해준다.
// test/mysql-global-teardown.ts
const globalAny: any = global;
export default async () => {
console.log('Stopping NestJS application...');
if (globalAny.testApp) {
await globalAny.testApp.close();
delete globalAny.testApp;
}
console.log('Stopping MySQL container...');
if (globalAny.__MYSQL_CONTAINER__) {
await globalAny.__MYSQL_CONTAINER__.stop();
delete globalAny.__MYSQL_CONTAINER__;
}
console.log('Global teardown completed.');
};
테스트 격리를 위한 DB 초기화 로직 작성
결국 생성되는 DB 컨테이너는 한개이기 때문에, 모든 테스트들이 해당 DB를 돌려가며 사용해야 한다.
이때 각 테스트들은 다른 테스트들의 영향을 받지 않기 위해서, 테스트들 간 DB사용이 끝나면 테이블을 비워 테스트에 사용했던 데이터들을 초기화 해주어야 한다.
이 동작을 수행하기 위한 TestService를 작성해주자.
// src/config/test/test.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class TestService {
constructor(private readonly dataSource: DataSource) {}
public async cleanDatabase(): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const entities = this.dataSource.entityMetadatas; // TypeORM이 가지고 있는 Entity 목록
await queryRunner.query('SET FOREIGN_KEY_CHECKS = 0;'); // 외래키 제약조건 임시 비활성화
entities // Entity 목록들을 돌아가며 TRUNCATE를 수행 (모든 테이블 데이터 초기화)
.map(async (entity) => {
await queryRunner.query(`TRUNCATE TABLE ${entity.tableName};`);
});
await queryRunner.query('SET FOREIGN_KEY_CHECKS = 1;');
console.log('[TEST DATABASE]: Clean');
} catch (error) {
throw new Error(`ERROR: Cleaning test database: ${error}`);
}
}
}
TypeORM이 가지고 있는 모든 Entity들을 순회하면서, 테이블에 존재하는 데이터들을 모두 TRUNCATE 해주는 cleanDatabase() 메소드를 추후 테스트에서 사용할 예정이다.
작성한 Service를 Nest가 인식할 수 있도록 module을 작성해주자.
// src/config/test/test.module.ts
import { Global, Module } from '@nestjs/common';
import { TestService } from './test.service';
@Global()
@Module({
imports: [],
providers: [TestService],
exports: [TestService],
})
export class TestModule {}
마지막으로 TestService를 app.module에 추가해주는 것을 잊지 말자.
// src/app.module.ts
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: process.env.ENV_PATH || `${process.cwd()}/configs/.env}`,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) =>
loadDBSetting(configService),
}),
// 필요한 모듈들...
TestModule,
],
controllers: [],
providers: [],
})
export class AppModule {}
환경변수 TypeORM 모듈로 전달하기
필자는 원래 app.module.ts에서 TypeORM의 DB 설정값을 불러오는 부분이 환경변수에서 가져오도록 되어있다.
// src/common/database/loadDbSetting
import { ConfigService } from '@nestjs/config';
export function loadDBSetting(configService: ConfigService) {
const type = configService.get<'mysql' | 'sqlite'>('DB_TYPE');
const database = configService.get<string>('DB_DATABASE');
const host = configService.get<string>('DB_HOST');
const port = configService.get<number>('DB_PORT');
const username = configService.get<string>('DB_USERNAME');
const password = configService.get<string>('DB_PASSWORD');
const entities = [`${__dirname}/../../**/*.entity.{js,ts}`];
const synchronize = true;
const logging = process.env.NODE_ENV === 'debug' ? true : false;
return {
type,
database,
host,
port,
username,
password,
entities,
synchronize,
logging,
};
}
위와 같이 ConfigService에서 필요한 값들을 가져와 설정하게 해두면, 아까 우리는 테스트 컨테이너가 생성한 DB 관련 값들을 process.env를 통해 환경변수로 등록해 두었기 때문에 jest가 Nest 애플리케이션을 생성할 때 기존에 사용하던 프로덕션 DB가 아닌 테스트 컨테이너 DB를 바라보도록 할 수 있다.
SetupFilesAfterEnv 파일 작성하기
// test/jest.setup.ts
const globalAny: any = global;
// NestJS 앱 생성 및 전역변수 등록
beforeAll(async () => {
console.log('Initializing NestJS application...');
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = moduleFixture.createNestApplication();
// 정상적인 앱 실행을 위해 필요한 동작들... (세팅에 따라 다름!!)
const logger = app.get(WinstonLoggerService);
app.setGlobalPrefix('api');
app.use(cookieParser());
app.useGlobalFilters(
new InternalExceptionsFilter(logger),
new HttpExceptionsFilter(),
);
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.init();
globalAny.testApp = app;
console.log('NestJS application initialized.');
});
// NestJS 앱 종료 및 DB 격리를 위한 cleanDatabase() 호출
afterAll(async () => {
const testService = globalAny.testApp.get(TestService);
await testService.cleanDatabase();
console.log('Closing NestJS application...');
if (globalAny.testApp) {
await globalAny.testApp.close();
delete globalAny.testApp;
}
console.log('NestJS application closed.');
});
이제 우리는 SetupFilesAfterEnv 파일을 작성하고, 여기서 모든 테스트 파일들이 공통적으로 가지는 부분들을 작성해 줄 것이다.
- beforeAll() : 테스트 환경에서 사용할 NestJS 애플리케이션을 생성하고, 초기화 한 후 전역변수에 저장한다.
- afterAll() : 사용했던 DB를 비워주기 위한 cleanDatabase() 메소드를 호출하고, NestJS 애플리케이션을 종료시킨다.
실행
jest --config test/jest-integration.json
설정해줬던 config 파일을 사용해 통합테스트를 실행한다.
Github Actions Runner에서의 실행시간 차이
기존에는 테스트환경에서 sqlite (In Memory DB)를 사용해 병렬 실행을 사용했다.
이에 기존 세팅과 새로운 세팅의 실행시간 차이가 얼마나 날까 궁금해서 Github Repository의 테스트 스크립트 실행시간 차이를 보았다.
기존 테스트 실행 시간에 비해 두배 가량 늦어졌다...ㅋㅋㅋㅋ
아마 도커가 테스트를 세팅하는 부분이랑, DB에 접근해서 테이블 초기화 해주는 부분이 시간을 많이 잡아먹는 것 같다.
시간을 두고 천천히 테스트 환경도 개선해 봐야겠다.
'Web' 카테고리의 다른 글
TypeORM 의 Date String 반환 이슈 (0) | 2025.06.11 |
---|---|
자바 vs 노드 당신의 선택은?! (4) | 2025.01.18 |
코딩테스트 준비를 위한 Java 입출력 정리 (0) | 2024.05.23 |
Java record 에 대하여 (2) | 2024.03.10 |