LIKET-통합 테스트 깔끔하게 개선하기

민경찬·2024년 11월 25일
12

백엔드

목록 보기
21/22
post-thumbnail

라이켓은 다양한 문화생활 정보를 공유하고 나만의 문화생활 기록을 남길 수 있는 서비스를 제공하고 있습니다.
태그별, 지역별로 관심있는 정보들만 골라보고 쉽게 문화생활을 즐겨보세요.

-> https://liket.site

안녕하세요. LIKET팀의 백엔드 개발자 민경찬입니다. 오늘은 Nest.jsPrisma, Jest를 이용해서 만들었던 통합 테스트 코드를 개선해본 이야기를 하려고합니다.


🔥 Nest + Prisma 조합에서 E2E 테스트 작성하기

Nest.jsPrisma조합에서 E2E테스트 코드를 작성하는 방법에 대해서 한 번 훑어보겠습니다.

저희의 E2E 테스트 목표는 API의 전과정을 테스트하는 것이였습니다.
그러기 위해서는 다음의 과제가 주어집니다.

  1. 손쉬운 테스트 인프라 셋업
  2. 각 테스트가 다른 테스트에 영향을 받지 않아야함

간단하게 하나씩 알아보겠습니다.

1. 손쉬운 인프라 셋업

Docker ComposeNPM script는 아주 훌륭한 해결책이 되어줍니다.

docker-compose.yml에 각종 인프라를 띄우도록 설정한 후 package.json에서 이를 실행할 수 있도록 명령어를 만들어주면 누구나 쉽게 테스트 인프라를 띄울 수 있습니다.

npm run user-server:test:infra:up

이렇게 입력하면 인프라가 띄워지며

npm run user-server:test:infra:down

이렇게 입력하면 인프라가 삭제됩니다.

2. 다른 테스트에 영향을 받지 않도록 설계

테스트가 진행될 때 데이터가 DB에 삽입되기 때문에 테스트 끼리 영향을 줄 수 있습니다.

그러나 문제는 어떤 테스트가 어떻게 영향을 주었는지 코드로 판단하기가 정말 어렵다는 것입니다.
이를 해결하기 위해서 다음의 것들을 고려해보았습니다.

  1. 각 테스트 케이스가 끝날 때 DB 데이터를 통째로 날려버리기
  2. 실제 사용하는 RDB말고 메모리 DB 모킹하기
  3. 테스트 케이스에 트랜잭션 걸어버리기

모든 테스트 케이스마다 필요한 데이터를 차곡차곡 넣어놓고 다시 삭제한다는 것은 코드적으로도, 속도 측면에서도 비효율적이라고 생각하였습니다. 그래서 메모리 DB에 모킹해서 속도만이라도 어떻게 해결해보는 것은 어떨까 싶었지만 이는 실제 배포 환경을 반영하지 못할 수 있을 것 같다는 생각이 들었습니다.

그래서 테스트 케이스에 트랜잭션을 걸어버리기로 하였습니다.

Prisma 테스팅을 위해서 프록시 패턴을 통해 테스트 단위로 트랜잭션을 걸어버릴 수 있는 라이브러리가 존재합니다.

@chax-at/transactional-prisma-testing

해당 라이브러리를 통해 테스트 케이스에 트랜잭션을 걸어버렸고 생각보다 쉽게 적용할 수 있었습니다.

🤔 테스트 코드 진화하기

테스트 코드의 첫 시작부터 살펴보겠습니다.

describe('Auth (e2e)', () => {
  let app: INestApplication;
  let appModule: TestingModule;

  beforeEach(async () => {
    appModule = await Test.createTestingModule({ 
      imports: [AppModule],
    })
      .compile(); // 테스팅 모듈 만들기
    
    app = appModule.createNestApplication(); // 앱 만들기

    await app.init();
  });

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

  ...

Nest.js에서 기본적으로 제시하는 방법입니다. module과 app을 만들어 시작하는 것이죠. 여기에 Prisma 트랜잭션을 끼얹어야합니다.

(코드가 얼만큼 증가하는지에 초점을 맞춰주세요!)

describe('Auth (e2e)', () => {
  let app: INestApplication;
  let appModule: TestingModule;
  let prisma; // Prisma Client 생성
  let prismaTestingHelper;

  beforeEach(async () => {
    if (!prismaTestingHelper) { // Prisma 트랜잭션 설정
	  prismaTestingHelper = new PrismaTestingHelper(new PrismaProvider());
      prisma = this.prismaTestingHelper.getProxyClient();
    }
    
    appModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(PrismaProvider)
      .useValue(prisma)
      .compile();
    app = appModule.createNestApplication();
    
    await prismaTestingHelper.startNewTransaction(); // 트랜잭션 시작

    await app.init();
  });

  afterEach(async () => {
    prismaTestingHelper.rollbackCurrentTransaction(); // 트랜잭션 롤백
    await appModule.close();
    await app.close();
  });

  ...

코드가 상당히 길어졌습니다. app에 대한 글로벌 설정도, 로그인 사용자를 쉽게 가져올 그 어떤 것도 준비되어있지 않습니다.

분리가 필요하겠죠.

describe('Auth (e2e)', () => {
  let app: INestApplication;
  let appModule: TestingModule;
  const prismaSetting = PrismaSetting.setup();

  beforeEach(async () => {
    await prismaSetting.BEGIN();

    appModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(PrismaProvider)
      .useValue(prismaSetting.getPrisma())
      .compile();
    
    app = appModule.createNestApplication();
    AppGlobalSetting.setup(app);
    await app.init();
  });

  afterEach(async () => {
    prismaSetting.ROLLBACK();
    await appModule.close();
    await app.close();
  });

app에 대한 전역 설정(미들웨어, 파이프라인)을 담당할 클래스와 Prisma 트랜잭션 설정을 담당할 클래스를 만들어 이를 관리하였습니다.

여기까지 테스트 코드를 작성할 충분한 환경을 조성했다고 생각했습니다.
통합 테스트가 200개가 넘어가기 전까지만 해도 말이죠...

😵‍💫 발생했던 문제점들...

새로운 테스트 코드를 만들 때마다, 위 코드처럼 beforeEachafterEach를 작성해주면 될 것이라고 생각하였습니다.
실제로 큰 문제없이 200개의 통합 테스트 코드들이 잘 돌아갔습니다.

실수가 발생하기 전까지 말이죠...

1. 디버깅이 쉽지 않은 실수 발생

appModule = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(PrismaProvider)
  .useValue(prismaSetting.getPrisma())
  .compile();

beforeEach내부에서 테스팅 모듈을 만드는 코드입니다. 여기서 실수로 overrideProvider를 빼먹으면 어떻게될까요?

appModule = await Test.createTestingModule({
  imports: [AppModule],
})
  .compile();

이렇게 말이죠. 대참사가 발생합니다. 테스트 케이스에 트랜잭션을 보장하는 PrismaClient가 Override되지 않았으니 테스트가 끝난 후 ROLLBACK되지 않습니다.

테스트 케이스가 독립적으로 실행되어야하지만 이젠 그렇지 않다는 것이죠. 에러가 발생하는 것도 아니며 이것이 당장 눈에 보이지 않을 수도 있습니다.

한참 눈에 안 보이다가 갑자기 다른 테스트 케이스에서 실수가 발생한 지점에서의 테스트 케이스가 남겨놓은 데이터로 인해 이해할 수 없는 Fail이 뜬다면 이는 정말 지옥입니다.

정말 지옥의 디버깅을 거쳤습니다. 에러가 뜨는 것도 아닌데 테스트 케이스를 하나씩 돌려보면 초록불이 들어오는 상황이였거든요.

정말 아무것도 아닌 실수였죠.

2. 너무 자유분방함

E2E테스트를 진행하다보면 프로바이더를 직접 꺼내와야할 때도 있으며 로그인이 필요할 때도 있습니다. 모킹이 필요할 때도 있으며 데이터베이스에 접근해서 직접 값을 넣어할 때도 있습니다.

다양한 방법으로 필요한 것을 꺼내와 사용할 수 있습니다. 근데 다양한 방법이 문제가 됩니다. 각 개발자들은 어떤 방법으로 어떻게 테스트를 구현할지 고민해야하며 당연히 시간적으로 손해입니다.

높은 품질의 테스트는 당연히 나올 수 없겠죠.

문제의 결론

실수하기에 쉬운 코드입니다. 실수를 알아차리는 것은 더욱 어렵게 구성되어있습니다.

자유도가 너무 높습니다. 테스트 케이스를 늘릴 때마다 구현 방법을 너무 많이 고민해야합니다. 그러나 어떤 케이스가 존재할지 고민하는 것도 모자릅니다.

따라서 실수가 발생하지 않도록 beforeEachafterEach를 개선할 필요가 있습니다.
또한 테스트 케이스 작성 시 각종 기능을 제공하는 툴이 있어야합니다.

⭐️ 개선 시작하기

다음을 목표로 개선을 시작하였습니다.

  1. beforeEachafterEach가 가벼워진다.
  2. PrismaProvider의 예시 처럼 override를 강제할 수 있도록 한다.
  3. 테스팅 모듈을 유연하게 커스터마이징할 수 있어야한다.

3번에 대해서 조금 더 자세하게 얘기해보겠습니다. 중복 코드를 제거하고 코드를 강제하다보면 유연함이 떨어질 수 있습니다.

() => {
  return appModule = await Test.createTestingModule({
    imports: [AppModule],
  })
    .overrideProvider(PrismaProvider)
    .useValue(prismaSetting.getPrisma())
    .compile();
}

appModule을 리턴하는 코드를 함수로 만들었다고 가정해보겠습니다. 이 경우 중복 코드는 제거할 수 있지만 추가적으로 provider를 override해야하는 경우에 대해서 대처하기가 까다롭습니다.

중복 코드를 제거하였으며 더 이상 PrismaProvider를 override하지 않아버리는 실수는 발생할 수 없게되었습니다. 그러나 그 결과 유연성이 많이 떨어지게 되었죠.

이를 모두 커버할 방법이 필요합니다. Nest.js에서 제공하는 기능에 대한 유연성을 보장하면서 어느정도 규격화된 코드가 필요한 것이죠.

그러려면 Nest.js에서 제공하는 테스팅 기능을 들여다볼 필요가 있습니다.

1. Nest.js Testing 기능 뜯어보기

Test.createTestingModule({
  imports: [AppModule],
});

createTestingModuleTestingModule을 반환하는 것이 아닌 TestingModuleBduiler를 반환합니다.

export declare class Test {
    private static readonly metadataScanner;
    static createTestingModule(metadata: ModuleMetadata): TestingModuleBuilder;
}

TestingModuleBuilder은 테스팅 모듈을 커스터마이징하는 다양한 기능을 제공합니다.

export declare class TestingModuleBuilder {
  	// ...
    overrideFilter<T = any>(typeOrToken: T): OverrideBy;
    overrideGuard<T = any>(typeOrToken: T): OverrideBy;
    overrideInterceptor<T = any>(typeOrToken: T): OverrideBy;
    overrideProvider<T = any>(typeOrToken: T): OverrideBy;
    overrideModule(moduleToOverride: ModuleDefinition): OverrideModule;
    compile(): Promise<TestingModule>;
    // ...
}

builder패턴을 사용하고 있으며 builder를 통해 커스터마이징이 끝났다면 마지막으로 compile 메서드를 호출하여 TestingModule을 만들어냅니다. 우리는 이것을 appModule이라는 변수에 담았습니다.

appModule = await Test.createTestingModule({
  imports: [AppModule],
}).compile();

이렇게 말이죠.

마지막으로 TestingModulecreateNestApplication을 호출함으로써 module을 통해 NestApplication을 만들어 app에 저장합니다.

appModule = await Test.createTestingModule({
  imports: [AppModule],
}).compile();

app = appModule.createNestApplication();

최종적으로 코드는 위 처럼 생겼습니다.

Nest.js가 TestingModule을 제공하기 위해 어떤식으로 코드를 정의했는지 빠르게 알아봤습니다. 글과 코드로 보면 난잡하니 그림으로 한 번더 마지막으로 정리해보겠습니다.

2. 코드 사용 패턴 예상하기

기존 beforeEach는 필수 작성 코드가 너무나도 많습니다.

beforeEach(async () => {
  await prismaSetting.BEGIN();

  appModule = await Test.createTestingModule({
    imports: [AppModule],
  })
    .overrideProvider(PrismaProvider)
    .useValue(prismaSetting.getPrisma())
    .compile();
  app = appModule.createNestApplication();
  AppGlobalSetting.setup(app);
  await app.init();
});

이것을 단 한 줄의 코드로 해결할 수 있게 되어야합니다.

beforeEach(async () => {
  test.init();
});

또한 필요하다면 TestingModule을 커스터마이징할 수 있는 여지를 남겨줘야합니다.

beforeEach(async () => {
  test
  	.override(FooProvider)
  	.useValue(foo)
  	.init();
});

당연히 필요한 서비스 객체나 프로바이더를 뽑아올 수도 있어야합니다.

const prisma = test.get(PrismaProvider)

테스트 케이스를 구현하는 입장에서 app과 appModule에 접근할 수 있다는 것은 구현 방향성을 늘리는 결과라고 생각했습니다. app과 appModule에 접근할 것 없이 하나의 객체가 모든 것을 제공한다면 구현을 어떻게 할지 고민하지 않아도 된다고 생각했거든요.

3. TestHelper 클래스 만들기

테스트 케이스를 쉽게 구현하도록 TestHelper 클래스를 만들었습니다. 해당 클래스의 목표는 appappModule을 관리하며 각종 유틸을 제공하는 것입니다.

export class TestHelper {
  private app: INestApplication;
  private appModule: TestingModule;

  public static create() {
    return new TestHelper();
  }
  
  public async init() {
    await this.prismaSetting.BEGIN();

    // TestingModuleBuilder 인스턴스 생성
    const testingModuleBuilder = Test.createTestingModule({
      imports: [AppModule],
    });

    // Prisma override 강제하기
    testingModuleBuilder
      .overrideProvider(PrismaProvider)
      .useValue(this.prismaSetting.getPrisma());

    // TestingModule 생성
    this.appModule = await testingModuleBuilder.compile();
    
    // TestingApp 생성
    this.app = this.appModule.createNestApplication();

    await this.app.init();
  }
  
  public async destroy() {
    this.prismaSetting.ROLLBACK();
    await this.appModule.close();
    await this.app.close();
  }
}

create메서드를 통해 인트턴스를 생성하고 initdestory를 통해 쉽게 환경을 초기화할 수 있습니다.

이렇게 말이죠.

const test = TestHelper.create();

beforeEach(async () => {
  await test.init();
});

afterEach(async () => {
  await test.destroy();
});

하지만 아직 override에 대한 유연성을 제공하지 못하고 있습니다.

TestHelper클래스에서 메서드를 추가하여 해결할 수 있습니다.

overrideProviderMap에 오버라이딩할 tokenvalue를 저장하고 init메서드가 호출되는 시점에 TestingModuleBuilder 객체를 통해 착착 넣어주면 됩니다.

// TestHelper 클래스 내부...

private readonly overrideProviderMap: Map<any, any> = new Map();

public overrideProvider<T = any>(provider: T): OverrideBy {
  const addProvider = (value: any) => {
    this.addProviderToMap(provider, value);
    return this;
  };

  return {
    useValue: (value) => addProvider(value),
  };
}

private addProviderToMap(key: any, value: any) {
  this.overrideProvider[key] = value;
}

test가 초기화되기 전에 provider를 override할 수 있는 메서드를 만들어줍니다.

public async init() {
  // ...
  const testingModuleBuilder = Test.createTestingModule({
    imports: [AppModule],
  });

  for (const mapKey of this.overrideProviderMap.keys()) {
    testingModuleBuilder
      .overrideProvider(mapKey)
      .useValue(this.overrideProviderMap[mapKey]);
  }
  // ...
}

그 후, init메서드 내부에서 override할 프로바이더를 전부 꺼내 TestingModuleBuilder 객체에 빌드해주면 됩니다.

인터셉터나 필터와 같이 주입 가능한 것들도 메서드로 만들어 기능을 제공할 수 있습니다.

4. 그 외...

그 외에 이런 메서드도 추가하였습니다.

  1. Provider를 뽑아올 수 있는 get메서드
  2. 특정 사용자로 로그인하고 accessToken을 발급하는 getLoginUsers메서드
  3. app의 httpServer를 가져올 수 있는 getServer메서드

😁 테스트 코드 어떻게 되었을까요?

테스트 코드를 설정하는 코드량은 확 줄게되었습니다.

로그인 사용자도 쉽게 가져올 수 있습니다.

it('Success with token', async () => {
  const loginUser = test.getLoginUsers().user1; // 로그인 사용자

  const response = await request(test.getServer())
  .get('/banner/all')
  .set('Authorization', `Bearer ${loginUser.accessToken}`)
  .expect(200);
});

트랜잭션 상태가 걸린 Prisma도 get메서드를 통해 쉽게 가져올 수 있게되었습니다.

it('Increase view count - no login', async () => {
  const idx = 1;

  const content = await test
  .get(PrismaProvider)
  .cultureContent.findUniqueOrThrow({
    where: {
      idx,
    },
  });

  const response = await request(test.getServer())
  .get(`/culture-content/${idx}`)
  .expect(200);
});

👋 결론

중복 코드를 제거함과 동시에 유연성을 챙긴다는 것은 그리 쉬운 일이 아니더라구요.
Nest.js가 제공하는 기능을 분석하는 과정이 꽤 재미있었지만 그것을 다시 래핑하여 기능을 제공한다는 것은 그리 쉬운 과정은 아니였습니다.

단순 메서드 접근 제한자로 TestHelper로 사용할 수 있는 기능을 제한하는 것 뿐만이 아닌 overrideProvider메서드 다음에는 반드시 useValue와 같은 메서드만 올 수 있도록 구현하는 과정이 험난했습니다. builder패턴을 직접 구현하려니 마냥 쉬운 것은 아니더라구요.

긴 글 읽어주셔서 감사합니다. 통합 테스트 개선에 대해 고민하는 많은 분들께 도움이 되셨으면 좋겠습니다.

더 좋은 방법이나 레퍼런스가 있다면 댓글로 공유 부탁드립니다. 감사합니다.

0개의 댓글