NestJS

NestJS + TypeORM + pg-mem으로 가벼운 e2e-test 작성하기

kgvovc 2022. 5. 21. 21:20
반응형

포스팅 작성 동기

실무에서 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에서 확인할 수 있다

반응형