Repository의 단위 테스트 수행(feat. NestJS, Prisma)

윤학·2024년 2월 5일
0

Nestjs

목록 보기
12/12
post-thumbnail

이번 글에서는 Repository라고 작성했지만 이름이 무엇이건 DB에 접근하는 계층의 단위 테스트를 해보려 한다.

통합 테스트도 있다 보니 또 작성해야 하나 생각할 수 있지만, 데이터가 원하는 형태로 조회되는지, DB에서의 계산 로직이 정상적으로 수행되는지 확인하기 위해 몇 번 작성 하다 보니 편해서 간단히라도 작성하려 하고 있다.

더군다나 여러 가지 ORM들이 많은데, 평소에 TypeORM을 사용하다 현재는 Prisma를 사용하고 있어 혼자 이것저것 해보니 적응하는 데 도움도 많이 됐다.

그럼 어떻게 작성하는지 간단하게 살펴보자.

Prisma Client

먼저 prisma 클라이언트를 통해 DB에 연결해야 하는데, 연결만 하는 것이 아니라 내부적으로 테스트에 필요한 메서드를 만들어 사용할 것이기에 확장하여 테스트를 위한 클래스를 만들어보자.

export class TestPrismaService extends PrismaClient {
  constructor() { super(testPrismaOptions) };

  async truncate(...tables: PrismaModel[]) {
    for( const table of tables ) {
      const query = `TRUNCATE TABLE ${table} RESTART IDENTITY CASCADE`;
      await this.$executeRawUnsafe(query);
    };
  };

  async dropForeignKeyConstraint(table: PrismaModel) {
    const query = dropForeignKeyConstraintQuery(table);
    await this.$executeRawUnsafe(query);
  }

  async addForeignKeyConstraint(table: PrismaModel) {
    const query = addForeignKeyConstraintQuery(table);
    await this.$executeRawUnsafe(query);
  }
}

testPrismaOptions에는 DB URL이나 Log Level과 같이 본인의 테스트 환경으로 작성하면 된다.

내부적으로 만든 메서드를 하나씩 살펴보자면

truncate()

TypeORM을 사용했을 땐 TypeORM에서 제공하는 clear() 메서드를 이용해서 테스트 이후에 테이블을 다시 초기화하곤 했는데, Prisma에는 유사한 기능이 없어 Truncate 명령어를 사용하기 위해 만들었다.

Prisma의 deleteMany()와 같은 메서드로 테이블을 전부 비울 수 있긴 하지만, 테이블의 AUTO_INCREMENT를 초기화하기 위해 이 방법을 선택했다.

파라미터에 받는 PrismaModel Type은 따로 타입을 지정하지 않고 테이블 이름을 문자열로 사용할 경우 오타나 기억력 때문에 불편해서 필요한 테이블은 아래와 같이 추가하며 사용하고 있다.

`export type PrismaModel = 
  "Cart" | 
  "User" | 
  "Post" | 
  "Comment" | 

dropForeignKeyConstraint()

addForeignKeyConstraint()와 같이 외래키 제약조건을 일시적으로 컨트롤하기 위해 만들었다.

예로, Post와 Comment가 1:N 관계일 때, CommentRepository에 대해 메서드를 만들고 테스트를 해보고 싶은데 Post에 Comment가 가지고 있는 PostId가 없으면 제약조건 때문에 오류가 나기 때문에 무시하고 테스트하기 위해 이 메서드를 만들었다.

Join이 많을수록 연관된 테이블들의 데이터들을 모두 작성하지 않고 테스트를 할 수 있어 편하지만, 필자의 방법은 각 테이블에 대한 ALTER 문을 별도의 파일에 모아서 작성하고 사용하고 있어 테이블이 많아질수록 비효율적이고 관리 면에서 힘들거라 생각한다.

더 문제는 모든 테스트를 한 번에 돌리면 병렬적으로 실행되기 때문에 DB에 접근하는 테스트들은 서로 외래키 제약조건을 설정할 때 충돌이 발생해 테스트가 일정하지 않다.

이걸 해결하기 위해 globalSetup이나 Prisma Client를 전역 변수처럼 공유하는 방법도 시도해 봤지만 실패하여 repository 파일들은 파일을 지정해서 테스트를 하거나, jest 설정을 통해 테스트에서 제외하게끔 놔뒀다.

그럼 실제로 예시를 한번 작성해보자.

describe('CartRepository Test', () => {
  let cartRepository: CartRepository, prisma: TestPrismaService;

  beforeAll(() => {
    prisma = new TestPrismaService();
    cartRepository = new CartRepositoryImpl(prisma);
  });
  
  describe('findManyByUserUid()', () => {
    const [userUid, insertTry] = ['12844000-c30f-0ab0-17a7-000021000012', 2];
    
    /* Test 하기 전 데이터 미리 저장 */
    beforeAll(async () => {
      const setUpCart = CartFixtures.createOne(userUid);

      for( let i = 0; i < insertTry; i++ ) {
        await cartRepository.save(setUpCart);
      };
    });

    afterAll(async () => await prisma.truncate("Cart"));

    test('일치하는 Uid가 없을 경우에 빈 배열이 반환되는지 확인', async() => {
      const wrongUserUid = '99999999-9999-9999-9999-999999999999';

      const result = await cartRepository.findManyByUserUid(wrongUserUid);

      expect(result).toEqual([]);
    });
  });
});

사실 일반적인 메서드들은 데이터 넣고 조회하고 어떻게 보면 당연해 보이는 결과들인데 위 메서드를 만들고 결과가 너무 궁금해서 테스트를 해보겠다고 서버를 켠다면 테이블이 비어 있는지 확인하고.. 사전에 필요한 카트 데이터를 SQL이나 스웨거, 포스트맨으로 넣고.. 넣을 때 인증이라도 걸려있으면 로그인하고 토큰 세팅하고... 더 복잡해지는 것 같긴 하다.

위에서는 ORM을 통한 예시였지만, 다른 데이터베이스로 작성되었다고 해도

describe('프로필 조회 랭킹 저장소(ProfileViewRankRepository', () => {
    let profileViewRankRepository: ProfileViewRankRepository;
    let redis: RedisClientType;
    
  	const testRankKey = 'test:profile:view:rank';

    beforeAll( async () => {
        await ConnectRedis();

        const module = await Test.createTestingModule({
            providers: [
                {
                    provide: ConfigService,
                    useValue: {
                        get: jest.fn().mockReturnValue(testRankKey)
                    }
                },
                {
                    provide: 'REDIS_CLIENT',
                    useValue: getRedis()
                },
                ProfileViewRankRepository
            ]
        }).compile();

        redis = getRedis();
        profileViewRankRepository = module.get(ProfileViewRankRepository);
    });

    afterAll(async () => await DisconnectRedis() );
    
  	describe('increment()', () => {
        it('해당 아이디를 가진 프로필의 횟수는 1증가해야 한다.', async () => {
            const profileId = '1';
            await profileViewRankRepository.increment(profileId);

            const afterIncrement = await redis.ZSCORE(testRankKey, profileId);
            const expectedScroe = 1;

            expect(afterIncrement).toBe(expectedScroe);

            await redis.ZREM(testRankKey, profileId);
        })
    })
})

결국에는 추상화시킨 DB 로직만 테스트하면 되기에 비슷한 메커니즘이라 생각한다.

예시에서 Redis의 커멘드를 직접 사용하고 있는 ZREM이나 ZSCORE을 delete()나 getScore()로 만들어 테스트에 적용할 수 있겠다.

나중을 생각하며 꾸준히 작성해보자!!

profile
해결한 문제는 그때 기록하자

0개의 댓글