NestJS 에서 Repository 테스트 하기 (PostgreSQL, Jest)

UI SEOK YU·2023년 6월 21일
0

TypeScript

목록 보기
4/4
post-thumbnail

레포지토리에 대한 테스트를 작성했다.

OutboundPortAdopter 에서 실제로 DB와 연결되는 Repository의 메서드를 테스트 하기 위해 테스트용 DB를 연결하고 beforeAll, beforeEach, AfterEach, AfterAll 만 잘 설정하고 각 메서드를 실행시켜, 결과값을 적절하게 예상하면 쉽게 끝날 것이라 생각했다.

그러나 예상과는 다르게 3일에 걸쳐 설정을 완료했으며, 그 과정에서 헤맸던 우여곡절을 적어본다.

test와 DB 연결하기

나는 현재 서버에서 repository 메서드를 mikroORM 을 통해 entityManager로 구현하고 있었다.
entityManager로 작성하면 entityRepository 처럼 일일히 엔티티에 대한 리포지토리를 생성할 필요없이 하나의 매니저로 관리할 수 있다.

describe('Todo Repository', () => {
  let todoRepository: TodoRepository;
  let orm: MikroORM;
  let em: EntityManager;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [ConfigModule.forRoot(), MikroOrmModule.forRoot(testConfig)],
      providers: [TodoRepository],
    }).compile();

    todoRepository = module.get<TodoRepository>(TodoRepository);
    orm = module.get<MikroORM>(MikroORM);      //<---------------- undefined !!
    em = module.get<EntityManager>(EntityManager);
  });
  ...

처음에는 이렇게 모듈을 가지고 인스턴스를 생성하여 테스트를 진행하려고 했다.
그러나 생성된 module 인스턴스에서 orm을 불러올 수 가 없었다. (이건 지금도 왜 안되는지 모르겠다)
또한 테스트 실행 이후에 정상적인 종료를 위해서는, entitymanager가 orm으로부터 추출된 것이어야 했다.
만약 entitymanager가 여러 개 생성된다면, orm 을 종료시켜도 em는 작업하느라 정상종료가 안 될 수도 있다고 판단했다.

따라서, 나는 orm을 인스턴스로 초기화 시키고, 해당 orm에서 em 를 가져와 그것을 repository에 주입하기로 했다.
이러면 하나의 orm에 포함된 em 를 가지고 테스트하므로 afterAll에서 정상적으로 close()를 통해 데이터베이스와 연결을 종료할 수 있을 것이다.

describe('Todo Repository', () => {
  let todoRepository: TodoRepository;
  let em: EntityManager;
  let orm: MikroORM;

  beforeAll(async () => {
    orm = await MikroORM.init({ ...testConfig, driver: PostgreSqlDriver });
    em = orm.em;
    todoRepository = new TodoRepository(em);
    ...
  }
  ...
  afterAll(async () => {
    await em.nativeDelete(Users, {});
    await orm.close();
  });

데이터베이스 수정하기

em.getReference()

테스트케이스를 작성하기 위해서는 해당 메서드의 실행결과를 직접 하드코딩해 두어야 한다.

하지만 데이터베이스의 테이블을 인스턴스화한 엔티티를 보면, 외래키로 연관된 속성들은 해당 테이블의 엔티티 타입으로 설정되어 있다.

import { Entity, ManyToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { Users } from './Users';

@Entity()
export class Todos {

  [OptionalProps]?: 'completed';

  @PrimaryKey({ columnType: 'int8' })
  TodoId!: string;

  @ManyToOne({ entity: () => Users, fieldName: 'user', onUpdateIntegrity: 'cascade' })
  user!: Users;

  @Property({ length: 50 })
  name!: string;

  @Property({ length: 6 })
  startTime!: Date;

  @Property({ length: 6 })
  endTime!: Date;

  @Property({ default: false })
  completed: boolean = false;

  @Property({ length: 1000, nullable: true })
  description?: string;

}

Todos 의 경우, 해당 Todo가 누구의 것인지 적는 user가
단순히 userId : string(=bigInt) 이 아닌, User 라는 엔티티 타입으로 설정되어 있다.
이것은 데이터베이스에서 테이블의 연관관계를 인스턴스에서 표시하기 위함이다.

그렇다면 테스트 케이스를 작성할 때, 어떻게 될까?

    expect(result).toEqual({
      id: '50',
      name: '8월 8일에 추가한 투두 테스트1',
      startTime: new Date('2023-08-08 16:00:00'),
      endTime: new Date('2023-08-21 18:00:00'),
      description: 'test로 생성된 투두입니다.',
      completed: false,
      user: {
        userId : '1',
        password : 'asdfasdfasdfa',
        nickname: 'testuser',
        loginMethod : 'LOCAL'
	    ...
      });

이 테스트 코드처럼 저기 user에 대한 모든 정보를 일일히 쳐주어야 한다.
그럴경우, 메인키가 아닌 다른 컬럼의 값이 바뀌게 되면 일일히 모두 수정해야하고,
단순히 Todo와 User 관계뿐만아 아니라 다른 엔티티가 포함된 테스트에서도 이 하드 코딩을 해줘야 한다는 말이다.

차라리 findOne을 통해서 유저를 받아오고 그것을 넣어주는 것도 시도해 보았지만, 중요하지 않은 쿼리를 날리는 과정이 추가되기에, 최적의 방법은 아닌 것 같았다.

Users 엔티티의 기본 키인 userId 만을 가지고 넣을 수 있는 방법을 찾아보았고,
엔티티를 기본키로만 참조할 수 있는entityManager.getReference()를 사용하기로 했다.
이 메서드는 엔티티를 데이터베이스에서 실제로 찾아오지는 않고, 해당 엔티티 타입으로 엔티티를 '찾았다 치고' 사용할 수 있다.
일종의 연산지연이니 더 경제적으로 사용할 수 있을 것 같다.

getSchemaGenerator()

그런데 문제가 생겼다. em.getReference(Users, '1') 이런식으로 작성하면, never 값에 string을 할당할 수 없다고 나온다.
never 값이 뜨는 걸 보니, 엔티티의 기본 키에대한 타입 추론이 잘못된 것 같았다.

여러 실험을 통해 알아 낸 것은, getReference 메서드가 PrimaryKey를 데코레이터나 옵션값으로 찾아내는 것이 아닌,
엔티티의 'id' 속성을 찾아 인식하더라.

나는 User의 기본키를 userId 와 같이 작성하고 있어서, 기본키를 찾지못해 never 타입으로 추론하고 있는 것이었다.

이를 위해 모든 엔티티의 기본키를 id 값으로 바꾸고 데이터베이스에 generate로 테이블 속성을 수정하고자 했다.

  const orm = await MikroORM.init(testConfig); // MikroORM 설정을 사용하여 초기화
  const generator = orm.getSchemaGenerator();  // 스키마 생성기
  await generator.updateSchema();

그런데 제약조건때문에 한 번에 다 수정하면 잘 안먹히더라.. 제약조건이나 순서까지 따져서 수정하면 배보다 배꼽이 더 큰 격이라

결국 테이블이 많지 않아서 노가다로 pgAdmin 에서 오리지널 데이터베이스를 일일히 수정했다.

그 후 위의 코드로 테스트 데이터베이스를 생성했다.

테스트

이제 모든 준비는 끝났다.

테스트 데이터베이스도 만들어 두었겠다, 테스트 코드만 작성하면 된다.

먼저 테스트를 하기전에 필요한 데이터들을 생성하고, 각 테스트 별로 초기화가 필요한 것들을 작성해 두었다.

describe('Todo Repository', () => {
  let todoRepository: TodoRepository;
  let em: EntityManager;
  let orm: MikroORM;

  beforeAll(async () => {
    orm = await MikroORM.init({ ...testConfig, driver: PostgreSqlDriver });
    em = orm.em;
    todoRepository = new TodoRepository(em);

    const newUser1 = em.create(Users, {
      id: '1',
      email: 'oboTestUser1@obo.com',
      nickname: 'whiteOBO',
      password: '123123',
    });
    const newUser2 = em.create(Users, {
      id: '2',
      email: 'oboTestUser2@obo.com',
      nickname: 'blackOBO',
      password: '123123',
    });

    await em.persistAndFlush(newUser1);
    await em.persistAndFlush(newUser2);
  });

  beforeEach(async () => {
    const newTodos = [];
    newTodos.push(
      em.create(Todos, {
        id: '15',
        name: '4월 15일에 추가한 투두 테스트2',
        startTime: new Date('2023-04-15 16:00:00'),
        endTime: new Date('2023-04-15 18:00:00'),
        description: 'beforeEach로 생성된 투두입니다.',
        completed: false,
        user: em.getReference(Users, '2'),
      }),
    );
	...
    newTodos.push(
      em.create(Todos, {
        id: '45',
        name: '7월 21일에 추가한 투두 테스트2',
        startTime: new Date('2023-07-21 16:00:00'),
        endTime: new Date('2023-07-23 18:00:00'),
        description: 'beforeEach로 생성된 투두입니다.',
        completed: false,
        user: em.getReference(Users, '2'),
      }),
    );
    for (const newTodo of newTodos) {
      await em.persistAndFlush(newTodo);
    }
  });

  afterEach(async () => {
    await em.nativeDelete(Todos, {});
    em.clear();
  });

  afterAll(async () => {
    await em.nativeDelete(Users, {});
    await orm.close();
  });

그리고 테스트케이스는 getReference 메서드를 활용하여 컴팩트하게 예상 출력값을 작성했다.

test('create', async () => {
    const params = {
      id: '50',
      name: '8월 8일에 추가한 투두 테스트1',
      startTime: new Date('2023-08-08 16:00:00'),
      endTime: new Date('2023-08-21 18:00:00'),
      description: 'test로 생성된 투두입니다.',
      completed: false,
      userId: '1',
    };

    const result: TodoCreateOutboundPortOutputDto = await todoRepository.create(
      params,
    );

    expect(result).toEqual({
      id: '50',
      name: '8월 8일에 추가한 투두 테스트1',
      startTime: new Date('2023-08-08 16:00:00'),
      endTime: new Date('2023-08-21 18:00:00'),
      description: 'test로 생성된 투두입니다.',
      completed: false,
      user: em.getReference(Users, '1'),
    });
  });

성공했다!
이제 위와 같이 나머지 메서드들도 테스트하면 되겠다.

테스트코드 완성버전 :
https://github.com/ObO314/ObO-back/blob/main/src/todo/outbound-adapter/todo.repository.spec.ts

0개의 댓글