오늘은 NestJS에서 E2E(End-to-End) 테스트를 구성하며 있었던 이야기를 소개해보려고 합니다.
Jest로 테스트를 구성하였고, 크게 3가지의 목표를 달성하고 싶었습니다.
1. Mocking을 최소화하여, 최대한 실제 시나리오와 유사하게 동작하도록 하는 것.
2. DB 특성을 가져갈 수 있도록, 테스트에서도 mysql DB를 사용할 것.
3. 자동화된 자체 QA를 수행할 수 있도록, Github Action과 연동되어 CI(지속적통합)를 지원할 수 있어야 할 것.
0. 개발환경
nest : 10.3.0
typescript: 5.3.3
typeorm : 0.3.19
jest : 29.7.0
docker-compose : 1.29.2
1. 문제의 발생
처음에는 테스트용 DB(이하 Test_DB)를 AWS RDS로 구성했습니다. 실제 DB(Prod_DB)와 동일한 구성을 유지하면서 test용 DB 유저를 생성하고, 테스트할 때마다 완전히 Drop을 진행해도 되는, 빈 스키마를 구성했습니다.
로컬환경에서 테스트는 성공적이었습니다. 테스트 과정을 도식화해보면 아래와 같습니다.
ORM을 이용하고 있기에 TypeORM에서 제공하는 CLI를 이용하여 쉽게 '초기화', '동기화', '초기 데이터 추가' 과정을 구현할 수 있었습니다.
// bin/ormconfig.ts
const ormconfig = async (): Promise<DataSource> => {
const config = <{ db: DataSourceOptions }>await configuration();
const dataSource = new DataSource({
...config.db,
logging: false,
logger: 'file',
namingStrategy: new SnakeNamingStrategy(),
entities: [`src/**/*.entity{.ts,.js}`],
migrations: [`src/seeds/**/*.{js,ts}`],
});
return dataSource;
};
export default ormconfig();
package.json에 CLI를 저장해두고, npm run test:e2e:auto 라는 script를 만들어서 한 명령어로 일련의 과정을 직렬적으로 처리하였습니다. 로컬에서는 성공적인 테스트를 구현할 수 있었습니다.
여러 레퍼런스나 공식 docs에서 E2E 테스트 방법에 대해서는 상세히 다루고 있으니 따로 첨부하진 않겠습니다,
* 참고 : https://docs.nestjs.com/fundamentals/e2e-testing
"scripts": {
"test:e2e:auto": "npm run test:entity:drop && npm run test:entity:sync && npm run test:seed:run && jest --config ./test/jest.e2e.ts -i --verbose",
"test:entity:sync": "NODE_ENV=test ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d bin/ormconfig.ts schema:sync",
"test:entity:drop": "NODE_ENV=test ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d bin/ormconfig.ts schema:drop",
"test:seed:run": "NODE_ENV=test ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d bin/ormconfig.ts migration:run",
},
그대로 Github Action을 구성하였고, Pull Request를 trigger로 CI가 동작하게 함으로서, PR이 들어오면 merge를 위해서 Test를 의무적으로 통과하도록 만들었습니다.
그런데...
로컬에서도 잘 구현되고, PR을 날려서 확인했을 때에도 잘 돌아갔던 테스트가 이상하게 특정상황에서 에러를 뱉기 시작했습니다. package의 버전관리를 위해 설정된 dependabot에서 특정 시간이 되면 패키지 갱신을 하도록 PR을 날리는데, 모든 Test가 실패하는 현상이 나타났습니다.
2. 구조분석 및 문제의 원인
에러가 난 부분을 확인해보았습니다. 분명 테스트 DB가 정상적으로 초기화되고, 동기화되었는데 Table을 찾을 수 없다는 에러가 났습니다...
생각해보면 당연한 일이었습니다. 테스트는 PR마다 동시에 일어날 수 있는데, 하나의 테스트 DB를 공유하고 있으니 초기화와 동기화의 타이밍에 따라 다른 테스트 환경설정을 간섭할 수 있는 것이었습니다.
3. 문제의 해결
크게 2가지 방법을 고민해봤습니다.
1. 테스트 trigger를 Queue 같은 FIFO 구조의 무엇인가에 넣어서, 각 테스트가 순차적으로 돌아갈 수 있도록 제어하는 것.
2. 테스트 환경 자체를 각 테스트마다 격리시켜서, 병렬적으로 처리될 수 있는 공간을 확보하는 것.
구글링을 해본 결과 (2)번 방법의 레퍼런스들을 찾을 수 있었습니다. docker를 활용하면 각 테스트에 격리된 공간을 사용하면서도, mysql 엔진의 특성까지 살려서 구현할 수 있다는 점입니다.
// docker-compose.test.yml
services:
db:
image: mysql/mysql-server:latest
ports:
- ${TEST_DB_PORT}:3306
environment:
- MYSQL_ROOT_HOST=%
- MYSQL_ROOT_PASSWORD=${TEST_DB_PASSWORD}
- MYSQL_DATABASE=${TEST_DB_NAME}
먼저, docker-compose로 환경 구성파일을 만들어줍니다.
// package.json
"test:docker:up": "docker-compose -f docker-compose.test.yml up -d",
"test:docker:down": "docker-compose -f docker-compose.test.yml down"
docker를 up down 시킬 수 있는 script도 추가해줍니다. docker-compose 문법에 대해서는 다른 블로그들에서 자세히 설명하고 있으니 따로 다루진 않겠습니다.
// .github/workflows/test.yml
name: NestJS-Test
on:
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
run: npm ci
- name : Test DB up
run: npm run test:docker:up
- name: Run e2e Tests
run: npm run test:e2e:auto
- name : Test DB down
run: npm run test:docker:down
핵심 test.yml 파일입니다. ubuntu에 node를 설치하고, 패키지들을 설치해준 뒤, 테스트 로직에 맞춰 명령어를 실행합니다.
4. 트러블슈팅
잘 될 줄 알았지만... 새로운 문제가 발생했습니다. docker up을 한 직후, E2E 테스트를 위한 환경구성 코드를 돌렸더니 아래와 같은 에러가 발생했습니다.
Error: Connection lost: The server closed the connection. at Socket.<anonymous> (/workspaces/nestjs-rest-api-templete-v1/node_modules/mysql2/lib/connection.js:117:31) at Socket.emit (node:events:514:28) at Socket.emit (node:domain:488:12) at TCP.<anonymous> (node:net:337:12) { fatal: true, code: 'PROTOCOL_CONNECTION_LOST' } |
서버에 연결을 실패했다는 말인 것 같습니다. 처음에는 docker로 DB 서버 생성에 실패했다고 생각했지만, mysql CLI로 접근해보니 서버 생성은 정상적으로 이루어졌습니다.
다만, 서버 생성에 약간에 로딩 시간이 걸린다는 점이 문제였습니다.
mysql이 올라오는 과정에서 연결을 시도하다보니, PROTOCOL_CONNECTION_LOST 에러가 난 것이었습니다.
TypeORM에서는 딱히 자체적으로 내장함수가 없는 듯하여, 재귀적으로 t초마다 연결을 재시도하는 방식으로 구현해보았습니다.
// bin/ormconfig.ts
const wait = (timeToDelay: number) => new Promise((resolve) => setTimeout(resolve, timeToDelay));
const ormconfig = async (): Promise<DataSource> => {
const config = <{ db: DataSourceOptions }>await configuration();
const dataSource = new DataSource({
...config.db,
logging: false,
logger: 'file',
namingStrategy: new SnakeNamingStrategy(),
entities: [`src/**/*.entity{.ts,.js}`],
migrations: [`src/seeds/**/*.{js,ts}`],
});
async function connectionCheck() {
await dataSource
.initialize()
.then(() => {
console.log('[SUCCESS] Data Source has been initialized!');
})
.catch(async (err: DatabaseConnectionException) => {
console.error('[FAILED] Error during Data Source initialization');
if (err.code === 'PROTOCOL_CONNECTION_LOST') {
console.log('[RECONNECT] Retrying connection');
await wait(2000);
await connectionCheck();
} else {
throw err;
}
});
return dataSource;
}
await connectionCheck();
await dataSource.destroy();
return dataSource;
};
export default ormconfig();
connect가 될 때까지 연결을 재시도하게 되었고, 정상적으로 독립된 환경에서 test를 구현할 수 있었습니다.
이후 동시에 test가 trigger 되더라도, 성공적으로 테스트를 구현할 수 있었습니다.
참고자료
* node.js - nodejs mysql Error: Connection lost The server closed the connection - Stack Overflow