라이켓은 다양한 문화생활 정보를 공유하고 나만의 문화생활 기록을 남길 수 있는 서비스를 제공하고 있습니다.
태그별, 지역별로 관심있는 정보들만 골라보고 쉽게 문화생활을 즐겨보세요.
안녕하세요. LIKET팀의 백엔드 개발자 민경찬입니다. 오늘은 Nest.js
와 Prisma
, Jest
를 이용해서 만들었던 통합 테스트 코드를 개선해본 이야기를 하려고합니다.
Nest.js
와 Prisma
조합에서 E2E테스트 코드를 작성하는 방법에 대해서 한 번 훑어보겠습니다.
저희의 E2E 테스트 목표는 API의 전과정을 테스트하는 것이였습니다.
그러기 위해서는 다음의 과제가 주어집니다.
간단하게 하나씩 알아보겠습니다.
Docker Compose
와 NPM script
는 아주 훌륭한 해결책이 되어줍니다.
docker-compose.yml
에 각종 인프라를 띄우도록 설정한 후 package.json
에서 이를 실행할 수 있도록 명령어를 만들어주면 누구나 쉽게 테스트 인프라를 띄울 수 있습니다.
npm run user-server:test:infra:up
이렇게 입력하면 인프라가 띄워지며
npm run user-server:test:infra:down
이렇게 입력하면 인프라가 삭제됩니다.
테스트가 진행될 때 데이터가 DB에 삽입되기 때문에 테스트 끼리 영향을 줄 수 있습니다.
그러나 문제는 어떤 테스트가 어떻게 영향을 주었는지 코드로 판단하기가 정말 어렵다는 것입니다.
이를 해결하기 위해서 다음의 것들을 고려해보았습니다.
모든 테스트 케이스마다 필요한 데이터를 차곡차곡 넣어놓고 다시 삭제한다는 것은 코드적으로도, 속도 측면에서도 비효율적이라고 생각하였습니다. 그래서 메모리 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개가 넘어가기 전까지만 해도 말이죠...
새로운 테스트 코드를 만들 때마다, 위 코드처럼 beforeEach
와 afterEach
를 작성해주면 될 것이라고 생각하였습니다.
실제로 큰 문제없이 200개의 통합 테스트 코드들이 잘 돌아갔습니다.
실수가 발생하기 전까지 말이죠...
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이 뜬다면 이는 정말 지옥입니다.
정말 지옥의 디버깅을 거쳤습니다. 에러가 뜨는 것도 아닌데 테스트 케이스를 하나씩 돌려보면 초록불이 들어오는 상황이였거든요.
정말 아무것도 아닌 실수였죠.
E2E테스트를 진행하다보면 프로바이더를 직접 꺼내와야할 때도 있으며 로그인이 필요할 때도 있습니다. 모킹이 필요할 때도 있으며 데이터베이스에 접근해서 직접 값을 넣어할 때도 있습니다.
다양한 방법으로 필요한 것을 꺼내와 사용할 수 있습니다. 근데 다양한 방법이 문제가 됩니다. 각 개발자들은 어떤 방법으로 어떻게 테스트를 구현할지 고민해야하며 당연히 시간적으로 손해입니다.
높은 품질의 테스트는 당연히 나올 수 없겠죠.
실수하기에 쉬운 코드입니다. 실수를 알아차리는 것은 더욱 어렵게 구성되어있습니다.
자유도가 너무 높습니다. 테스트 케이스를 늘릴 때마다 구현 방법을 너무 많이 고민해야합니다. 그러나 어떤 케이스가 존재할지 고민하는 것도 모자릅니다.
따라서 실수가 발생하지 않도록 beforeEach
와 afterEach
를 개선할 필요가 있습니다.
또한 테스트 케이스 작성 시 각종 기능을 제공하는 툴이 있어야합니다.
다음을 목표로 개선을 시작하였습니다.
beforeEach
와 afterEach
가 가벼워진다.PrismaProvider
의 예시 처럼 override를 강제할 수 있도록 한다.3번에 대해서 조금 더 자세하게 얘기해보겠습니다. 중복 코드를 제거하고 코드를 강제하다보면 유연함이 떨어질 수 있습니다.
() => {
return appModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaProvider)
.useValue(prismaSetting.getPrisma())
.compile();
}
appModule
을 리턴하는 코드를 함수로 만들었다고 가정해보겠습니다. 이 경우 중복 코드는 제거할 수 있지만 추가적으로 provider를 override해야하는 경우에 대해서 대처하기가 까다롭습니다.
중복 코드를 제거하였으며 더 이상 PrismaProvider를 override하지 않아버리는 실수는 발생할 수 없게되었습니다. 그러나 그 결과 유연성이 많이 떨어지게 되었죠.
이를 모두 커버할 방법이 필요합니다. Nest.js에서 제공하는 기능에 대한 유연성을 보장하면서 어느정도 규격화된 코드가 필요한 것이죠.
그러려면 Nest.js에서 제공하는 테스팅 기능을 들여다볼 필요가 있습니다.
Test.createTestingModule({
imports: [AppModule],
});
createTestingModule
은 TestingModule
을 반환하는 것이 아닌 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();
이렇게 말이죠.
마지막으로 TestingModule
의 createNestApplication
을 호출함으로써 module을 통해 NestApplication을 만들어 app
에 저장합니다.
appModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = appModule.createNestApplication();
최종적으로 코드는 위 처럼 생겼습니다.
Nest.js가 TestingModule
을 제공하기 위해 어떤식으로 코드를 정의했는지 빠르게 알아봤습니다. 글과 코드로 보면 난잡하니 그림으로 한 번더 마지막으로 정리해보겠습니다.
기존 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에 접근할 것 없이 하나의 객체가 모든 것을 제공한다면 구현을 어떻게 할지 고민하지 않아도 된다고 생각했거든요.
테스트 케이스를 쉽게 구현하도록 TestHelper
클래스를 만들었습니다. 해당 클래스의 목표는 app
와 appModule
을 관리하며 각종 유틸을 제공하는 것입니다.
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
메서드를 통해 인트턴스를 생성하고 init
과 destory
를 통해 쉽게 환경을 초기화할 수 있습니다.
이렇게 말이죠.
const test = TestHelper.create();
beforeEach(async () => {
await test.init();
});
afterEach(async () => {
await test.destroy();
});
하지만 아직 override에 대한 유연성을 제공하지 못하고 있습니다.
TestHelper
클래스에서 메서드를 추가하여 해결할 수 있습니다.
overrideProviderMap
에 오버라이딩할 token
과 value
를 저장하고 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
객체에 빌드해주면 됩니다.
인터셉터나 필터와 같이 주입 가능한 것들도 메서드로 만들어 기능을 제공할 수 있습니다.
그 외에 이런 메서드도 추가하였습니다.
get
메서드getLoginUsers
메서드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
패턴을 직접 구현하려니 마냥 쉬운 것은 아니더라구요.
긴 글 읽어주셔서 감사합니다. 통합 테스트 개선에 대해 고민하는 많은 분들께 도움이 되셨으면 좋겠습니다.
더 좋은 방법이나 레퍼런스가 있다면 댓글로 공유 부탁드립니다. 감사합니다.