NestJS + TypeORM + pg-mem으로 가벼운 e2e-test 작성하기
포스팅 작성 동기
실무에서 e2e-test를 작성하는데, 처음엔 testcontainers라는 라이브러리를 이용해서 e2e-test를 작성했더니, 케이스 하나당 500ms나 걸렸다. 원인을 찾아보니 이 라이브러리가 내부적으로 Docker를 이용해서 좀 무거워서 그랬던 것 같다.
그래서 다른 좋은 방법이 없을까 리서칭 중 pg-mem이라는 in-memory emulation of a postgres database 라이브러리를 발견! pg-mem을 사용했더니 빠른 경우 케이스 하나당 15ms으로 시간이 많이 줄었다.
그래서 이번 포스팅에서는 pg-mem을 사용한 (비교적) 가벼운 e2e-test 작성법에 대해 알아보고자 한다.
프로젝트 세팅
프로젝트 세팅 과정을 건너뛰고 싶다면 nestjs-typeorm-e2e-test의 initial-setting 브랜치에서 작업을 시작하자.
1. 패키지 인스톨
먼저 패키지 인스톨부터 해주자.
패키지 관리 툴로는 yarn
을 사용한다.
$ yarn add @nestjs/core @nestjs/common rxjs reflect-metadata # NestJS install
$ yarn add @nestjs/typeorm typeorm@0.2 pg # TypeORM, postgresql install
$ yarn add -D ts-jest ts-node typescript jest prettier @types/node @types/jest @nestjs/testing pg-mem # dev dependencies install
$ yarn add @nestjs/platform-express # express install
$ yarn add uuid # uuid install
플랫폼은 express로 선택했다!
2. 설정 파일들 세팅
tsconfig.json
{
"compilerOptions": {
"target": "ES2018",
"lib": ["ES2018", "ESNext"],
"module": "CommonJS",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"strictNullChecks": true,
"sourceMap": true,
"outDir": "./dist",
"removeComments": true,
"resolveJsonModule": true,
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
.prettierrc
{
"parser": "typescript",
"printWidth": 120,
"singleQuote": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"overrides": [
{
"files": "*.json",
"options": {
"parser": "json"
}
}
]
}
jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*.spec.ts', '**/*.test.ts'],
globals: {
'ts-jest': {
isolatedModules: true,
},
},
};
3. Member 컴포넌트 정의
src 디렉토리 안에
AppModule
, MemberController
, MemberEntity
, MemberRepository
, MemberSerivce
, server.ts
를 정의해주자.
MemberEntity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('member')
export default class MemberEntity {
@PrimaryGeneratedColumn('uuid')
id: number;
@Column()
name: string;
}
MemberRepository.ts
import { EntityRepository, Repository } from 'typeorm';
import MemberEntity from './MemberEntity';
@EntityRepository(MemberEntity)
export default class MemberRepository extends Repository<MemberEntity> {
async createMember(name: string) {
const entity = this.create({ name });
await this.save(entity);
}
async findMemberById(id: number) {
return this.findOne({
where: {
id,
},
});
}
async findMembers() {
return this.find();
}
}
MemberService
import { Injectable } from '@nestjs/common';
import MemberRepository from './MemberRepository';
@Injectable()
export default class MemberService {
constructor(private memberRepo: MemberRepository) {}
async createMember(name: string) {
await this.memberRepo.createMember(name);
}
async findMemberById(id: number) {
const member = await this.memberRepo.findMemberById(id);
return member;
}
async findMembers() {
return this.memberRepo.findMembers();
}
}
MemberController
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import MemberService from './MemberService';
@Controller('/members')
export default class MemberController {
constructor(private memberService: MemberService) {}
@Get('/:id')
async findMemberById(@Param('id') id: number) {
return this.memberService.findMemberById(id);
}
@Get()
async findMembers() {
return this.memberService.findMembers();
}
@Post()
async createMember(@Body('name') name: string) {
await this.memberService.createMember(name);
return {
success: true,
};
}
}
엔티티를 API에 그대로 노출시키는 것은 좋지 않지만 간략화를 위해 엔티티를 그대로 반환한다.
이제 정의한 Member 컴포넌트들을 AppModule
에 등록해주자.
AppModule.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import MemberRepository from './MemberRepository';
import MemberEntity from './MemberEntity';
import MemberController from './MemberController';
import MemberService from './MemberService';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
database: 'testdb',
username: 'mojo',
password: '',
entities: [MemberEntity],
synchronize: true,
logger: 'advanced-console',
logging: true
}),
TypeOrmModule.forFeature([MemberRepository]),
],
providers: [MemberService],
controllers: [MemberController],
})
export class AppModule {}
DB 설정 정보는 본인의 환경에 맞게 세팅해주자.
자동으로 entity와 table schema를 동기화 시켜주기 위해 synchronize: true
옵션을 켜주자.
여기서 typeorm이 실행하는 쿼리를 보기 위해 logger: 'advanced-console'
, logging: true
옵션을 켜주자.
이제 마지막으로 서버를 띄우는 코드가 포함된 server.ts
파일만 작성해주자.
server.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './AppModule';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap().then(() => {
console.log(`🚀 Server ready at http://localhost:3000`);
});
이제 e2e-test를 하기 위한 준비가 끝났다.
위에서 MemberController에 정의한 3개의 API에 대한 e2e-test를 작성해보자.
e2e-test 작성
pg-mem 라이브러리를 좀 더 쉽게 이용하기 위한 pgTestHelper
클래스를 정의해주자.
이 클래스는 pg-helper.ts 왼쪽 링크를 참고했다.
PgTestHelper.ts
import { DataType, IBackup, IMemoryDb, newDb } from 'pg-mem';
import { Connection } from 'typeorm';
import { v4 } from 'uuid';
export class PgTestHelper {
db: IMemoryDb;
connection: Connection;
backup: IBackup;
async connect(entities?: any[]) {
this.db = newDb({ autoCreateForeignKeyIndices: true });
this.db.public.registerFunction({ implementation: () => 'test', name: 'current_database'});
this.connection = await this.db.adapters.createTypeormConnection({
type: 'postgres',
entities: entities,
logger: 'advanced-console',
logging: true,
});
await this.sync();
this.backup = this.db.backup();
return this.connection;
}
restore() {
this.backup.restore();
}
async disconnect() {
await this.connection.close();
}
async sync() {
await this.connection.synchronize();
}
}
- connect: 파라미터로 받은 엔티티들을 등록한 후, connection을 반환해준다.
- restore: DB 테이블을 초기 상태로 되돌리는 메서드. 매 테스트 케이스를 수행한 뒤 (
@AfterEach
) 이 메서드를 호출해서 clean up 해줄것이다.
Member.e2e.test.ts
import { Test } from '@nestjs/testing';
import { AppModule } from '../AppModule';
import { Connection } from 'typeorm';
import { PgTestHelper } from './PgTestHelper';
import MemberEntity from '../MemberEntity';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
describe('Member (e2e-test)', function () {
let app: INestApplication;
let pgTestHelper: PgTestHelper;
beforeAll(async () => {
pgTestHelper = new PgTestHelper();
await pgTestHelper.connect([MemberEntity]);
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(Connection)
.useValue(pgTestHelper.connection)
.compile();
app = module.createNestApplication();
await app.init();
});
afterEach(() => {
pgTestHelper.restore();
});
it('should return []', async () => {
return request(app.getHttpServer())
.get('/members')
.then(res => {
console.log(res.body);
});
});
});
overrideProvider
메서드를 통해 Connection을 pgTestHelper를 통해 얻은 Connection으로 재정의를 해주자.
그런 다음 생성된 app에 대해 GET /members
API를 호출하면, 아직 생성된 member가 없으니 빈 배열을 반환할 것이다.
그러나, 막상 테스트를 실행해보면 다음과 같은 에러가 발생한다.
ERROR: function uuid_generate_v4() does not exist ...
이는 pg-mem 라이브러리가 uuid_generate_v4()라는 function을 구현을 안해놔서 그렇다.
PgTestHelper에 가서 uuid_generate_v4()
함수를 등록해주러 가자.
async connect(entities?: any[]) {
this.db = newDb({ autoCreateForeignKeyIndices: true });
this.db.public.registerFunction({ implementation: () => 'test', name: 'current_database' });
// <-- 코드 추가 -->
this.db.registerExtension('uuid-ossp', schema => {
schema.registerFunction({
name: 'uuid_generate_v4',
returns: DataType.uuid,
implementation: v4,
impure: true,
});
});
// <-- 코드 추가 -->
this.connection = await this.db.adapters.createTypeormConnection({
type: 'postgres',
entities: entities,
logger: 'advanced-console',
logging: true,
});
await this.sync();
this.backup = this.db.backup();
return this.connection;
}
uuid function을 등록하는 코드를 추가하고 나면, 빈 배열을 반환받는 것을 확인할 수 있다.
그럼 이제 본격적으로 테스트를 작성해보자.
createMember
it('should createMember successfully ', function () {
return request(app.getHttpServer())
.post('/members')
.send({ name: 'mojo' })
.expect({ success: true });
});
getMembers
it('should return two members', async () => {
await request(app.getHttpServer()).post('/members').send({ name: 'member1' });
await request(app.getHttpServer()).post('/members').send({ name: 'member2' });
await request(app.getHttpServer())
.get('/members')
.then(res => {
console.log(res.body);
expect(res.body).toEqual([
{ id: expect.any(String), name: 'member1' },
{ id: expect.any(String), name: 'member2' },
]);
});
});
// console.log
[
{ id: '716a5f8e-79fc-42f9-ac72-349085bbc258', name: 'member1' },
{ id: '0842948f-f0a1-45ef-b71d-1ab472ed7789', name: 'member2' }
]
이렇게 e2e-test를 작성해봤는데, 실행 시간을 체크해보면, 내 로컬 환경에서 테스트 케이스 하나당 15~20ms 정도 걸리는 것을 확인했다.
testcontainers를 이용해 테스트했을 때와 비교해서 약 10배정도 차이난다..!
전체 소스 코드는 nestjs-typeorm-e2e-test에서 확인할 수 있다