본문 바로가기

study/Back-end

typeorm-transactional-tests 라이브러리 톺아보기!

배경

현재 NestJS, TypeORM을 사용하여 진행하고 있는 프로젝트의 통합 테스트 코드를 작성하던 중, 동일한 id를 포함하여 새로운 유저 생성할 경우 id의 UNIQUE 규칙에 맞지 않아 문제가 발생했다. 아이디를 다르게 설정하면 해결되긴 하지만 독립적으로 테스트를 실행시켜보고 싶다는 생각이 들어 트랜잭션을 활용해보았다.

 

  • beforeAll, afterAll에서 queryRunner를 연결하고 해제하는 작업 진행
  • beforeEach에서 트랜잭션 시작, afterEach에서 트랜잭션 롤백 및 Mocking 제거
beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [TypeOrmModule.forRoot(typeORMTestConfig)],
      providers: [PurchaseService, PurchaseRepository, UsersRepository],
    }).compile();

    purchaseService = moduleFixture.get<PurchaseService>(PurchaseService);
    dataSource = moduleFixture.get<DataSource>(DataSource);
    queryRunner = dataSource.createQueryRunner();
    await queryRunner.connect();
  });

  beforeEach(async () => {
    await queryRunner.startTransaction();
  });

  afterEach(async () => {
    await queryRunner.rollbackTransaction();
    jest.restoreAllMocks();
  });

  afterAll(async () => {
    await queryRunner.release();
  });

문제점

  • 트랜잭션을 시작하면, 모든 작업이 queryRunner.manager가 실행할 수 있도록 Mocking이 필요하여 Mocking 코드가 굉장히 많다...!
  • TypeORM의 함수를 실행할 때마다 Mocking을 해야한다..?!
describe("purchaseDesign & getDesignPurchaseList 메서드", () => {
    it("메서드 정상 요청", async () => {
      const user = User.create({
        ...userMockData,
        premium: premiumStatus.FALSE,
        credit: 500,
      });
      await queryRunner.manager.save(user);

      jest.spyOn(user, "save").mockImplementation(async () => {
        return queryRunner.manager.save(user);
      });

      const purchase = new Purchase();
      jest.spyOn(Purchase, "create").mockReturnValue(purchase);
      jest.spyOn(purchase, "save").mockImplementation(async () => {
        return queryRunner.manager.save(purchase);
      });
      jest.spyOn(Purchase, "find").mockImplementation(async (options) => {
        return queryRunner.manager.find(Purchase, options);
      });
      jest.spyOn(Purchase, "findOne").mockImplementation(async (options) => {
        return queryRunner.manager.findOne(Purchase, options);
      });

      const purchaseDesignDto = new PurchaseDesignDto();
      purchaseDesignDto.domain = "GROUND";
      purchaseDesignDto.design = "GROUND_GREEN";

      const { credit } = await purchaseService.purchaseDesign(
        user,
        purchaseDesignDto,
      );
      expect(credit).toBe(0);

      const result = await purchaseService.getDesignPurchaseList(user);
      expect(result).toStrictEqual({
        ground: ["#254117"],
        sky: [],
      });
    });
  });

 

라이브러리로 해결!


typeorm-transactional-tests 라이브러리

 

GitHub - viniciusjssouza/typeorm-transactional-tests: Add transactional tests to typeorm projects

Add transactional tests to typeorm projects. Contribute to viniciusjssouza/typeorm-transactional-tests development by creating an account on GitHub.

github.com

 

  • 코드 내부에서 트랜잭션에서 실행되도록 도와준다.
  • 트랜잭션 단위로 테스트를 실행하게 되면 커넥션을 생성하는 비용이 없어 더 빠르게 테스트를 실행할수 있다!

라이브러리 사용법

  • typeorm 0.3.X 버전에서 Connection -> DataSource로 사용
  • beforeEach에서 연결된 커넥션 객체를 기준으로 transaction 생성 및 시작 / afterEach에서 트랜잭션을 롤백시키고 커넥션을 반환
beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [TypeOrmModule.forRoot(typeORMTestConfig)],
      providers: [...],
    }).compile();

    dataSource = moduleFixture.get<DataSource>(DataSource);
  });

beforeEach(async () => {
  transactionalContext = new TransactionalTestContext(dataSource);
  await transactionalContext.start();
});

afterEach(async () => {
  await transactionalContext.finish();
});

어떻게 동작하고 있을까?

Monkey patching : 다른 사람이 작성한 코드(일반적으로 라이브러리나 프레임워크)를 변경하거나 확장하는 프로그래밍 기법

transactionContext.start()

this.queryRunner = this.buildWrappedQueryRunner();
  • 커넥션에서 queryRunner를 만들고 wrap 함수의 인자로 전달하여 실제 사용할 queryRunner를 반환
this.monkeyPatchQueryRunnerCreation(this.queryRunner);
  • 원본 createQueryRunner 함수가 originQueryRunnerFunction에 저장
  • DataSource의 createQueryRunner 메소드를 queryRunner를 반환하는 함수로 대체

DataSource에서 createQueryRunner를 호출했을 때 매개변수로 받은 queryRunner가 반환된다!

  • 이렇게 만들어진 queryRunner를 통해 트랜잭션을 시작!
constructor(private readonly connection: DataSource) {}

async start(): Promise<void> {
  if (this.queryRunner) {
    throw new Error('Context already started');
  }
  try {
    **this.queryRunner = this.buildWrappedQueryRunner();
    this.monkeyPatchQueryRunnerCreation(this.queryRunner);**

    await this.queryRunner.connect();
    await this.queryRunner.startTransaction();
  } catch (error) {
    await this.cleanUpResources();
    throw error;
  }
}

private **buildWrappedQueryRunner()**: QueryRunnerWrapper {
    const queryRunner = this.connection.createQueryRunner();
    return **wrap**(queryRunner);
  }

private **monkeyPatchQueryRunnerCreation(queryRunner: QueryRunnerWrapper)**: void {
    this.originQueryRunnerFunction = DataSource.prototype.createQueryRunner;
    DataSource.prototype.createQueryRunner = () => queryRunner;
}

wrap()

  • wrap 함수도 monkeyPatchQueryRunnerCreation 함수처럼 release 함수를 가로채어, 원래의 release 함수가 호출되지 않도록 한다.
  • releaseQueryRunner 함수: 원래의 release 메소드를 복원하고 호출하는 역할
const wrap = (originalQueryRunner: QueryRunner): QueryRunnerWrapper => {
  release = originalQueryRunner.release;
  originalQueryRunner.release = () => {
    return Promise.resolve();
  };

  (originalQueryRunner as QueryRunnerWrapper).releaseQueryRunner = () => {
    originalQueryRunner.release = release;
    return originalQueryRunner.release();
  };

  return originalQueryRunner as QueryRunnerWrapper;
};

transactionContext.finish()

await this.queryRunner.rollbackTransaction();
  • 트랜잭션 롤백
this.restoreQueryRunnerCreation();

 

  • createQueryRunner를 이전 상태로 돌려놓는다.
  • monkeyPatchQueryRunnerCreation 함수 과정 반대로!
await this.cleanUpResources();
  • release()를 호출하고 이전 상태로 돌려놓는다.
  • wrap 함수 과정 반대로!
constructor(private readonly connection: DataSource) {}

async finish(): Promise<void> {
  if (!this.queryRunner) {
    throw new Error('Context not started. You must call "start" before finishing it.');
  }
  try {
    **await this.queryRunner.rollbackTransaction();
    this.restoreQueryRunnerCreation();**
  } finally {
    **await this.cleanUpResources();**
  }
}

private restoreQueryRunnerCreation(): void {
  DataSource.prototype.createQueryRunner = this.originQueryRunnerFunction;
}

private async cleanUpResources(): Promise<void> {
  if (this.queryRunner) {
    await this.queryRunner.releaseQueryRunner();
    this.queryRunner = null;
  }
}

왜 이렇게 구현되어있을까?

TypeORM에서 QueryRunner는 특정 데이터베이스 연결에서 쿼리를 실행하는 데 사용

QueryRunner 인스턴스의 생명주기는 createQueryRunner로 생성하고 release로 종료

  • createQueryRunner() : 데이터베이스 연결에 대한 새로운 QueryRunner 인스턴스를 생성하는 데 사용
  • release() : 쿼리 실행이 끝나면 release를 호출하여 QueryRunner를 종료

따라서 DataSource에서 createQueryRunner를 호출했을 때, 현재 사용하고 있는 queryRunner를 반환해 트랜잭션에 포함시킨다.

쿼리 실행이 끝났을 때가 아닌, 트랜잭션이 종료될 때 queryRunner가 사용하던 데이터베이스 연결을 반환해야 하기 때문에 release 함수를 아무 역할도 하지 않게 만들고 transactionContext.finish()가 호출될 때 진짜 release를 호출한다.