단위 테스트를 작성할 때, 데이터베이스에 접근하는 Repository 계층은 개인적으로 항상 고민이 되던 부분 중 하나였습니다.
Repository 객체를 모킹하여 해결할 수도 있지만, 개인적으로는 고전파 스타일의 테스트 작성을 지향해서 ^^; 모킹을 줄이고 싶었고,
최근에는 제 기준, 이를 해결해 준 멋진 해결책 중 하나인 testcontainers 라이브러리를 열심히 사용해 보고 있습니다!
본 포스팅에서는 Jest + testcontainers 라이브러리를 활용하여, 단위 테스트를 작성하는 간단한 과정을 기록해 보려 합니다!
잘못된 내용에 대한 피드백은 언제나 감사드립니다! (_ _)
예제 코드는 아래와 같은 기술 스택을 사용하여 작성하였습니다!
→ testcontainers For Node js 공식 문서
Testcontainers is a library that supports tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.
→ 도커 컨테이너에서 실행될 수 있는, 데이터베이스 및 여러 경량화된 인스턴스를 제공하는
테스트를 위한 라이브러리!
저는 testcontainers 라이브러리로 PostgreSQL 컨테이너를 실행할 예정이므로, 아래 라이브러리를 설치해 주었습니다! :)
npm install @testcontainers/postgresql
TestService 라는 임의의 클래스의 로직을 테스트함을 가정합니다!Container를 실행하고, 연결을 설정하는 등의 과정에는 시간이 다소 소요되므로,
우선 jest의 기본 timeout인 5000ms를 넉넉하게 늘려줍니다! :)
jest.setTimeout(20000); // 20000ms
describe("TestService", () => {
// 테스트 작성은 후술하겠습니다 :)
});
모든 테스트가 시작하기 전 실행되는, beforeAll 구문에서,
오늘의 주인공인 testcontainers 를 활용하여 테스트에 활용할 PostgreSQL Container를 실행해 줍시다!
[ test.service.spec.ts ]
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
describe("TestService", () => {
let postgresContainer: StartedPostgreSqlContainer;
beforeAll(async () => {
// container를 실행합니다!
postgresContainer = await new PostgreSqlContainer().start();
}
});
});
2-1에서 실행한 Container를 바탕으로, 테스트에 사용할 TestingModule을 정의하고, Service를 가져와 보겠습니다!
[ test.service.spec ]
describe("TestService", () => {
let service:TestService;
let postgresContainer: StartedPostgreSqlContainer;
beforeAll(async () => {
// container를 실행합니다!
postgresContainer = await new PostgreSqlContainer().start();
}
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports:[
// testcontainers로 실행한 container의 연결 정보를 넣어줍니다!
TypeOrmModule.forRoot({
type: "postgres",
host: postgresContainer.getHost(),
port: postgresContainer.getPort(),
username: postgresContainer.getUsername(),
password: postgresContainer.getPassword(),
database: postgresContainer.getDatabase(),
entities:[TestEntity], // 해당 테스트에서 사용할 Entity를 넣어줍니다!
synchronize: true,
// synchronize 옵션을 켜서, 실행한 postgresContainer에 Entity 정보들이 모두 반영되도록 해줍니다 :)
})
],
providers:[TestService]
}).compile();
service = module.get<TestService>(TestService);
});
}
실행을 위해, 간단한 유닛 테스트 하나를 추가해 보겠습니다!
describe("TestService", () => {
let service:TestService;
let postgresContainer: StartedPostgreSqlContainer;
beforeAll(async () => {
// 위와 동일합니다! :)
});
beforeEach(async () => {
// 역시 위와 동일합니다! :)
});
it("should be defined", () => {
expect(service).toBeDefined();
});
}
→ Docker Desktop 등을 통해 확인해 보면, 실행 과정에서 임의의 이름으로 도커 Container가 실행되고, 테스트가 종료됨과 함께 종료되는 부분을 확인할 수 있습니다!
위 코드를 실행하면, 아래와 같은 에러가 발생합니다..!
→ jest did not exit one second after the test run has completed..
-> container ( DB )와의 연결을 정상적으로 종료해 주지 않아 발생하는 문제!!
테스트를 정상적으로 종료하기 위해, 아래 부분을 추가해 줍니다!
import { DataSource } from "typeorm"
import { getDataSourceToken } from "@nestjs/typeorm"
describe("TestService", () => {
...
let dataSource:DataSource;
...
beforeEach(async () => {
// 위와 동일하게 Module을 compile합니다.
const module = await Test.createTestingModule({
imports:[
TypeOrmModule.forRoot({
type: "postgres",
host: postgresContainer.getHost(),
port: postgresContainer.getPort(),
username: postgresContainer.getUsername(),
password: postgresContainer.getPassword(),
database: postgresContainer.getDatabase(),
entities:[TestEntity],
synchronize: true,
})
],
providers:[TestService]
}).compile();
service = module.get<TestService>(TestService);
// 이 부분을 추가! => DataSource 객체를 가져옵니다.
dataSource = module.get<DataSource>(getDatasourceToken());
})
afterAll(async() => {
// DB와의 연결을 종료
await dataSource.destroy();
// testcontainers로 실행했던 컨테이너를 중지!
await postgresContainer.stop();
})
→ 테스트가 깔끔하게 종료됩니다 :)
이제, DB와 실제 상호작용하듯 테스트를 작성할 수 있습니다!
describe("TestService", () => {
let service:TestService;
let dataSource:DataSource;
beforeAll(async () => {
// 컨테이너 실행!
});
beforeEach(async () => {
// 모듈 compile
// Service와 DataSource 조회
});
afterAll(async () => {
// db와의 연결 종료
// testcontainers로 실행한 컨테이너 중지
})
it("저장된 모든 Test의 정보를 조회해야 합니다.", async () => {
// given
// given 조건에 테스트 데이터를 저장해 줍니다.
await datasource.getRepository(TestEntity).save({
title:"테스트를 위한 테스트 데이터"
});
// when
// service에서, 전체 데이터를 조회하는 임의의 로직을 실행
const result = await service.getAllTests();
// then
// 조회한 데이터가 1개인지를 확인합니다 :)
expect(result).toHaveLength(1);
})
}
이렇듯 실제 DB와 상호작용하듯 테스트를 작성하다 보면,
위 테스트에서 test라는 데이터가 insert된 것처럼, 개별 테스트마다 DB의 상태가 달라질 수 있습니다.
이렇게 되면, 한 테스트가 다른 테스트에 의도치 않은 영향을 줄 수 있으므로..!
매 테스트마다 DB의 모든 데이터를 삭제해 주는 로직이 필요할 것 같습니다 :)
describe("TestService", () => {
...
let dataSource:DataSource;
...
afterEach(async () => {
const allEntities = dataSource.createEntityManager().connection.entityMetadatas;
// 전체 테이블 이름을 조회하고 , 로 join합니다.
const tableNames = entities.map((entity) => `"${entity.tableName}"`),join(", ");
// 모든 테이블을 truncate하고, identity를 재시작해 줍니다 :)
await dataSource.query(`TRUNCATE TABLE ${tableNames} RESTART IDENTITY CASCADE;`);
})
})
[ test.service.spec.ts ]
describe("TestService", () => {
let service:TestService;
let dataSource: DataSource;
let postgresContainer: StartedPostgreSqlContainer;
beforeAll(async () => {
postgresContainer = await new PostgreSqlContainer().start();
}
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports:[
TypeOrmModule.forRoot({
type: "postgres",
host: postgresContainer.getHost(),
port: postgresContainer.getPort(),
username: postgresContainer.getUsername(),
password: postgresContainer.getPassword(),
database: postgresContainer.getDatabase(),
entities:[TestEntity],
synchronize: true,
})
],
providers:[TestService]
}).compile();
service = module.get<TestService>(TestService);
dataSource = module.get<DataSource>(getDatasourceToken());
});
afterEach(async () => {
const allEntities = dataSource.createEntityManager().connection.entityMetadatas;
// 전체 테이블 이름을 조회하고 , 로 join합니다.
const tableNames = entities.map((entity) => `"${entity.tableName}"`),join(", ");
// 모든 테이블을 truncate하고, identity를 재시작해 줍니다 :)
await dataSource.query(`TRUNCATE TABLE ${tableNames} RESTART IDENTITY CASCADE;`);
});
afterAll(async() => {
// DB와의 연결을 종료
await dataSource.destroy();
// testcontainers로 실행했던 컨테이너를 중지!
await postgresContainer.stop();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
})