[Nest.js/TypeORM] Repository의 일부만을 mocking해 Custom Repository의 method들 test하기

lsjbh45·2022년 11월 30일
5

Custom Repository test의 문제점

대부분의 경우에서 unit test 시에는 테스트를 위한 모듈을 구성할 때 테스트 대상과 mocking의 대상이 분리되어 있다. 예를 들자면 특정 service layer를 테스트하기 위해서는 테스트 대상인 service 자체, 그리고 해당 service에서 inject해 사용하는 하위 layer인 repository들을 mocking한 mock repository들을 테스트 모듈의 provider로 지정해 주게 된다. mock repository의 구성에는 여러 방식이 있지만, jest.mock()으로 inject 대상인 repository의 모든 요소들을 mocking하고, dynamic provider 패턴으로 작성한 mock repository provider를 테스트 모듈의 provider로 지정해주면 문제 없이 테스트를 위한 구성을 마칠 수 있다.

jest.mock('./repository/Data.repository')

type MockRepository<T = any> = Partial<Record<keyof T, jest.Mock>>

describe('DataService', () => {
  let DataService: DataService
  let DataRepository: MockRepository<DataRepository>

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DataService,
        {
          provide: getRepositoryToken(DataRepository),
          useClass: DataRepository
        },
      ]
    }).compile()
  })
})

그런데 이런 방식으로는 TypeORM의 Repository class를 확장해 필요한 custom method들을 구현한 custom repository를 테스트할 때 문제에 부딪히게 된다. custom repository 내부의 custom method들이 unit test의 대상이 되는데, 이 method들의 구현에 Repository class가 기본적으로 지원하는 'insert', 'findOne', 'save' 등의 method들이 사용되었기 때문에 동시에 mocking의 대상이 되어야 하기 때문이다. 같은 repository의 요소들이 동시에 일부는 test의 대상이, 다른 일부는 mocking의 대상이 되는 것이다.

@EntityRepository(Data)
export class DataRepository extends Repository<Data> {
  // custom method
  async loadData(offset: number, limit: number): Promise<Data[]> {
    return this.find({
      order: { createdAt: 'DESC' },
      skip: offset * limit,
      take: limit,
    });
  }
}

이 문제를 해결하기 위해서 구글링을 많이 해 보았었는데, 유용한 정보를 찾기가 상당히 어려웠다. 특히 TypeORM 0.3 이상에서는 @EntityRepository decorator가 deprecated되면서 custom repository 패턴을 사용하지 못하게 되면서 리팩터링이 필요한지 고민해 보기도 했다. 그럼에도 진행하는 프로젝트에서는 0.2 버전의 TypeORM에서 아직 migration 계획이 없기에 custom repository 패턴이 여전히 유효하고, 0.3 이상의 버전에서도 사용할 수 있도록 온라인에서 여러 공유가 이루어지고 있는 것 같아 구조는 그대로 유지하고 테스트 작성 측면에서의 대안을 찾아보고자 했다. 이 포스트에서는 해당 문제에 살펴본 대안들과 예상되었던 문제점들을 기록하고, 선택한 해결책과 그 구현을 다루어 보고자 한다.

대안들과 문제점

Repository Test에 In-Memory DB 적용

describe('DataRepository', () => {
  let module: TestingModule;
  let dataRepository: DataRepository;

  beforeEach(async () => {
    module = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type: 'sqlite',
          database: ':memory:',
          entities,
          synchronize: true,
        }),
      ],
      providers: [DataRepository],
    }).compile();

    dataRepository = module.get(DataRepository);
  });

  afterEach(async () => {
    await module.close();
  });
});

Database 연결을 생성할 때 typeSQLite로, database:memory:로 설정하면 별도의 테스트 데이터베이스를 구축하지 않고 In-Memory database에서 테스트를 수행할 수 있다. 연결 생성은 app.module에서 구축하는 것과 유사하게 진행할 수 있다. 이 방식을 사용하는 것은 간단하고 빠르게 repository method들의 작동을 확인할 수 있다는 장점이 있지만, database connection을 수행하는 것이 unit test의 범위를 벗어나 있고, Repository의 method들과의 호출 관계를 명확히 테스트하기 어렵다. 결정적으로 SQLite가 지원하지 않는 Dialect나 Column Type을 사용한다면 사용할 수 없는 방식이기에 확장성이 떨어진다는 단점이 존재한다.

원본 Repository를 provider로 제공

describe('DataRepository', () => {
  let DataRepository: DataRepository;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [DataRepository],
    }).compile();

    dataRepository = module.get(DataRepository);
  });

  describe('loadData', () => {
    it('test', async () => {
      dataRepository.findOne = jest.fn(async (obj: any) => obj);
      // findOneSpy = jest.spyOn(dataRepository, 'findOne').mockImplementation(async (obj: any) => obj)
      expect(await dataRepository.findByDataId(1)).toStrictEqual({ id: 1 });
    });
  });
});

이 방식은 repository 전체를 전혀 mocking하지 않은 채로 모듈의 provider로 instantiate해 주는 방식이다. 그렇게 되면 테스트가 필요한 method들은 원래 작동하던 방식을 유지해 테스트할 수 있게 되고, mocking할 부분 또한 각 테스트에서 필요한 부분을 따로 지정해 mocking해줄 수 있다. 테스트하려는 부분에 따라 jest.fn()이나 jest.spyOn()으로 method들을 mocking하게 되며, 이때 기존의 method 구현을 대체하는 것이기 때문에, 대부분 overloading을 사용해 정의되어 있는 Repository의 method들은 typing을 신경써서 구현해주지 않으면 compile error가 발생하게 된다. 이 방식은 테스트 시에 모든 method들의 원래 작동 방식에 접근할 수 있기 때문에 추후 테스트 코드의 확장이나 변경 시에 예상치 못한 부분에서 문제가 발생할 수 있다는 단점이 있고, 일관성이 떨어지는 측면이 존재한다.

직접 mock repository provider 작성

export const MockDataRepository = {
  // methods from Repository
  create: jest.fn(),
  save: jest.fn(),
  find: jest.fn(),
  findOne: jest.fn(),
  // methods from DataRepository (extends Repository<Data>)
  loadData: DataRepository.prototype.loadData
}

export type MockDataRepositoryType = Partial<Record<keyof DataRepository, jest.Mock>> | Partial<DataRepository>

export const MockDataRepositoryProvider = {
  provide: getRepositoryToken(DataRepository),
  useValue: MockDataRepository
}
describe('DataRepository', () => {
  let DataRepository: MockDataRepositoryType;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [MockDataRepositoryProvider],
    }).compile();

    dataRepository = module.get(DataRepository);
  });
});

그렇다면 Repository의 method들은 mocking해주고, custom repository의 method들은 그대로 가져오도록 provider를 정의해 주면 되는 것이 아닐까? dynamic provider 문법은 useClass 대신 useValue property를 지정해 class instance 대신 object 형태의 provider를 module에 제공할 수 있게 한다. Repository의 method들은 jest.fn()으로 mocking하고, custom repository의 method들은 생성자 함수의 prototype 객체에 저장된 method를 그대로 가져오도록 object를 정의할 수 있다. 이 object를 값으로 가지는 useValue property를 기반으로 하는 MockRepositoryProvider를 정의하고, 이를 테스트 모듈의 provider로 지정해 주면 큰 문제 없이 테스트를 진행할 수 있다.

Mock Repository Factory로 Provider 구성 자동화

export type MockRepository<T = any> = Partial<Record<keyof T, jest.Mock>> | Partial<T>

export class MockRepositoryFactory {
  static getMockRepository<T>(type: new (...args: any[]) => T): MockRepository<T> {
    const mockRepository: MockRepository<T> = {}

    Object.getOwnPropertyNames(Repository.prototype)
      .filter((key: string) => key !== 'constructor')
      .forEach((key: string) => {
        mockRepository[key] = jest.fn()
      })

    Object.getOwnPropertyNames(type.prototype)
      .filter((key: string) => key !== 'constructor')
      .forEach((key: string) => {
        mockRepository[key] = type.prototype[key]
      })

    return mockRepository
  }
}
describe('StudyRepository', () => {
  let studyRepository: MockRepository<StudyRepository>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        {
          provide: getRepositoryToken(StudyRepository),
          useValue: MockRepositoryFactory.getMockRepository(StudyRepository),
        },
      ],
    }).compile();

    studyRepository = module.get(getRepositoryToken(StudyRepository));
  });
});

위 방식을 사용하기 위해서는 테스트에 필요한 모든 method들과 테스트 대상이 되는 모든 method들을 일일히 나열해 provider의 value object를 구성해야 하고, 이 과정을 모든 custom repository에 대해 반복해야 한다. 불필요하게 중복되는 부분들을 줄이고, 간단하게 mock repository를 구성할 수 있도록 MockRepositoryFactory를 정의했다. factory의 getMockRepository method는 repository class 정보를 받아 해당하는 mock repository object를 반환하도록 정의되어 있으며, 이 object를 dynamic provider의 useValue로 지정하면 mock repository 구성이 완료된다.

class 문법으로 정의된 method들은 내부적으로 생성자 함수의 prototype 객체에 저장된다. 따라서 각각 Repository.prototype, type.prototype의 property를 참조해 mock repository를 구성한다. custom repository에서 추가로 정의한 method들은 Object.getOwnPropertyDescriptor()로 살펴보면 enumerable: false로 지정되어 있기 때문에 Object.key() 대신 enumerable 여부와 무관하게 object property key들을 반환하는 Object.getOwnPropertyNames()를 사용한다. 이때 생성자 함수의 prototype 객체라면 모두 정의된 method들 외에도 생성자 함수를 가리키는 constructor property를 enumerable: false인 상태로 포함하고 있기 때문에 Array.filter()를 사용해 제외해 준다.

개선 가능한 부분

{
  // ...
  constuctor: function DataRepository() {
    return mockConstructor.apply(this, arguments);
  },
  // ...
}

jest.mock()을 호출해 repository 전체를 mocking하는 경우 class 형태로 만들어진 mock repository로 해당 class가 변환된다. 이때 해당 mock repository의 prototype 객체의 constructor property를 살펴보면 mockConstructor.apply()를 호출해 실제 해당 mock repository를 반환하는 생성자 함수임을 확인할 수 있다. 위에서 구현한 factory에서는 mock repository의 property들을 포함한 object를 반환해서 useValue property의 provider를 사용하도록 했고, class를 구성하는 것이 아니라 constructor 정보를 제외했다. 하지만 생성자 정보가 필요하다면 이 부분을 활용해 mock repository class를 반환하도록 구현해서 useClass property의 provider를 사용하는 것도 가능할 것이다.

profile
개발을 공부하며 깊게 고민했던 트러블슈팅 과정을 공유하고자 합니다.

1개의 댓글

comment-user-thumbnail
2023년 8월 8일

ㅠㅠ

답글 달기