Search
🚦

단위테스트 작성 병목사항 해결하기 - NestJS를 중심으로

1. 들어가기에 앞서

현재 재직중인 회사에 입사를 해서 코드를 살펴보니 테스트 코드의 수가 제한적이고 테스트 코드 작성 문화가 아직 정착이 되어있지 않았다. 전통적인 TDD 까지는 아니지만 풍성한 테스트 코드를 작성하는 문화가 정착된다면 지금보다 더 높은 소프트웨어 품질을 유지할 수 있을 것이라고 생각했다.
스프린트를 진행하다가 할당받은 에픽에 이슈가 생겨 잠깐 몇일 동안 시간이 뜨게 되었는데, 이때 팀 리드분께 말씀드려, 테스트 코드 작성에 대한 Best Practice를 만들어 팀 내부적으로 공유하는 시간을 갖게 되었다. “팀원분들이 어떻게하면 테스트 코드를 잘 작성할 수 있을지, 지금 상황은 어떤지” 등 내용을 조사하여 발표를 하게 되었고, 몇몇 팀원분들이 좋은 피드백을 주고 공감해주셔서 다행이라고 생각했다.
사내에 공유한 내용을 외부에 공유가능한 형태로 다듬어서 어떤식으로 단위 테스트 잘 작성할 수 있을지 포스팅 하고자 한다.

1.1. 테스트 코드의 필요성

테스트 코드에 대한 명언들 "테스트 없는 코드는 동작하지 않는 코드다." - 켄트 벡
"테스트가 없다면 운이 좋으면 코드가 동작할지도 모른다. 테스트가 있다면 운이 좋으면 코드가 고쳐질 것이다." - 켄트 벡
"테스트 코드 없이는 신뢰할 수 있는 코드를 작성할 수 없다." - 마이클 C. 파이썬스
"테스팅은 소프트웨어 공학의 한 부분으로 간주되어야 한다." - 빌 게이츠
"테스트를 먼저 시작하라. 테스트 코드는 설계의 일부다." - 로버트 C. 마틴
"테스트 코드는 실행 가능한 문서다." - 마틴 파울러
테스트 코드(Test Code)는 소프트웨어의 기능과 동작을 검증하고 버그를 발견하기 위해 작성하는 코드를 말한다. 위에 많은 개발자들이 말한 것 처럼 테스트 코드의 중요성은 아무리 강조해도 지나치지 않다고 생각한다. 테스트 코드의 필요성을 요약하자면 다음과 같다.
1.
버그 방지 - 테스트 코드는 새로운 기능 추가나 변경 시 버그를 조기에 발견하고 예방하는 데 도움
2.
신뢰성 확보 - 테스트 코드를 통해 소프트웨어의 안정성과 신뢰성 향상
3.
문서화 - 테스트 코드는 소프트웨어 동작 방식을 문서화하는 역할
4.
유지보수 용이 - 테스트 코드가 있으면 코드 수정이 쉬우며, 리팩토링을 보다 쉽게 할수 있음
다른 이유로는 개발 생산성 향상, 코드 설계 개선 등이 있지만 위 4가지가 가장 핵심적인 테스트코드 작성 목적이라고 볼 수 있다. 풍성하게 작성된 테스트 코드로 달성된 좋은 테스트 커버리지는 소프트웨어 품질에 매우 중요하다고 할 수 있다.

1.2. 테스트의 종류

테스트의 종류는 기준에 따라 매우 다양하다. 그 중 의존성 제거 유무 기준으로는 Solitary Test와 Sociable Test로 나눌 수 있다.
Solitary Test
의존성을 제거하여 테스트 대상만 테스트
테스트 대상 모듈을 격리하여 독립적으로 테스트 진행
예) 외부 의존성(DB등)을 Test Double(Mock, Stub) 등을 활용하여 모듈을 격리
Sociable Test
테스트 대상 모듈을 따로 격리하지않고 테스트 대상과 의존하는 대상을 함께 테스트
예) 외부 의존성인 DB 등을 직접 연동해서 테스트를 진행
일종의 Integration Test
이번 포스팅에서는 팀 리드분께서 원하는 방향이기도 했던, Solitary Test를 작성하는 것을 기준으로 작성하였다.

2. 단위 테스트 코드 작성의 병목 사항

지금까지 백엔드 팀 Repo를 살펴보면 테스트 코드를 작성하려 했던 많은 분들의 노력의 흔적을 찾을 수 있었다. 그러나 안타깝게도 많은 이유들로 인해 테스트 코드가 작성되지 않고 있거나, 혹은 아주 작은 Scope에서만 테스트 코드가 작성되고 있었다. 테스트 코드를 그래도 많이 작성하셨던 분들께 한분 한분 찾아가서 어떤 부분이 가장 병목이냐고 물어보았고, 답변 주신 내용들을 정리해보니 공통적인 내용을 발견할 수 있었다.
1.
절대적인 시간 부족
2.
TestingModule DI 설정
3.
Mock 및 DataSet 설정
1번의 경우, 전사적으로나 리더급에서 공감대를 형성하고 조정을 해주지 않고서는 팀 내부적으로는 개선하기 어렵다고 판단하였기에, 우선 팀 내부적으로 개선 가능한 항목인 2,3번을 집중적으로 살펴보았다.

2.1. TestingModule DI 설정

Nest.js에서는 ModuleInjectable 등을 활용한 강력한 의존성 주입 기능을 제공하고 있다. Nest.js가 갖고 있는 강력한 장점이기도 하지만, 테스트 코드를 작성할때는 매우 강력한 단점으로 다가오는 경우가 있다.
예를 들어, SampleServiceImpl 클래스에 대하여 테스트 코드를 작성하려고 한다고 가정해보자. 예시 코드는 아래와 같이 작성되어있다.
@Injectable() export class SampleServiceImpl implements SampleService { constructor( @Inject('FooReader') private fooReader: FooReader, @Inject('FooStore') private fooStore: FooStore, @Inject('BarProcessor') private barProcessor: BarProcessor, @Inject('BarReader') private readonly barReader: BarReader, @Inject('FoeReader') private foeReader: FoeReader, @Inject('FeeProcessor') private feeProcessor: FeeProcessor, ) {} async doSomething() { // TO BE IMPLEMENTED } }
TypeScript
복사
위 클래스의 테스트 코드를 작성하기 위해서는 Jest의 CreateTestingModule 을 활용하여 테스팅 모듈에 클래스 DI를 주입해줘야하는데, SampleServiceImpl 에 주입되는 외부 의존성을 모두 명시적으로 일일이 주입 해줘야만 했다.
그런데 만약, SampleServiceImpl 에 Injection하는 특정 클래스가 또다른 외부 의존성을 갖고 있다면 그것까지 같이 주입해줘야 SampleServiceImpl 를 정확히 TestingModule 에 설정할 수 있었다. 지금의 샘플 코드는 의존성이 몇개 없어서 시간이 많이 걸리지 않을 수도 있다고 생각할 지 모르겠지만, 재직중인 회사의 레거시 Repo에서는 일부 클래스의 외부 의존성 수가 10개 이상에서 많게는 20개도 있었고, 각각의 의존성이 또다른 의존성을 갖고 있다면 하나의 클래스에 대하여 테스트 코드를 작성하기 위해 준비하는 과정이 여간 번거로운 일이 아닐 수가 없었다.
describe('SampleServiceImpl', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ { provide: 'SampleService', useClass: SampleServiceImpl, }, // SampleServiceImpl의 외부 의존성 DI { provide: 'FooReader', useClass: FooReaderImpl, }, { provide: 'FooStore', useClass: FooStoreImpl, }, { provide: 'BarProcessor', useClass: BarProcessorImpl, }, { provide: 'BarReader', useClass: BarReaderImpl, }, { provide: 'FoeReader', useClass: FoeReaderImpl, }, { provide: 'FeeProcessor', useClass: FeeProcessorImpl, }, // FeeProcessorImp가 외부 의존성이 있다면, 이를 또 다시 설정해줘야함 { provide: 'FeeProcessor2', useClass: FeeProcessorImpl2, }, { provide: 'FeeProcessor3', useClass: FeeProcessorImpl3, } ], }).compile(); }
TypeScript
복사
테스트하려고 하는 특정 클래스의 의존성을 일일이 TestingModule 을 통해 주입하지 말고, 모듈을 그대로 주입해서 쓰면 되지 않느냐고 물어볼 수도 있을 것이다. 그러나 레거시 Repo에서는 모듈간의 의존성 그래프가 제대로 정리되지 않은채 오랜 시간동안 운영되어왔다. 그래서 모듈만 주입해서 사용할 경우 어디서부터 수정해야할 지 모르는 끝도 없는 순환참조 에러가 발생하여 (많게는 20~30개의 모듈간의 순환참조도 발생하더라…) 현재로서는 사용불가능한 방법이었다. 물론 궁극적으로는 의존성 그래프를 다 정리해서 모듈로만으로 주입할 수 있게 리팩토링 해야한다고 생각한다.
에러 메세지 예시
Potential causes:
A circular dependency between modules. Use forwardRef() to avoid it. Read more: Documentation | NestJS - A progressive Node.js framework
The module at index [5] is of type "undefined". Check your import statements and the type of the module.
Scope [ AAAAModule-> BBBModule -> CCCModule -> DDDModule -> EEEModule -> FFFModule -> GGGModule -> HHHModule -> IIIModule -> JJJModule -> KKKModule -> LLLModule -> MMMModule -> NNNModule -> OOOModule -> PPPModule -> QQQModule -> RRRModule -> SSSModule]

2.2. Mock 및 DataSet 설정

어렵게 TestingModule DI 설정을 마쳤다고 하더라도 또다른 장애물이 남아있었다. Solitary Test를 하기 위해서는 외부 의존성을 제거하여 테스트 대상만 격리하여 테스트를 진행해줘야하는데 이를 위한 Mocking 및 Mocking 을 통해 기대하는 값 (Expected Value) 설정이 상당히 불편하게 구현되어 있었다.
예를 들어, 약속을 생성하는 책임을 가진 createAppointment 에 대하여 테스트 코드를 작성한다고 가정해보자.
async createAppointment( createAppointmentDto: CreateAppointmentDto, memberIds: string[], ) { // 중략.. const { name, destinationLocation, destinationName, destinationShortName, startedAt, groupId } = createAppointmentDto; const group = await this.groupService.getGroup(groupId); const members = await this.memberRepository.getMembers({ ids: memberIds, groupId }); // 중략.. const result = await this.dataSource.transaction(async (manager) => { const appointment = await this.appointmentRepository.createAppointment({ name, destinationLocation, destinationName, destinationShortName, startedAt, groupId: group.id, }); const res = await this.participantService.createParticipant( { appointmentId, appointmentStartedAt: startedAt, members }, manager, ); // 중략.. });
TypeScript
복사
이 경우, 아래와 같이 4개의 외부 의존성을 Mocking 해줘야 한다.
this.groupService.getGroup()
this.memberRepository.getMembers()
this.appointmentRepository.createAppointment()
this.participantService.createParticipant()
그리고 Mocking에 대한 Return Value를 각각 하나 하나 생성해줘야하는데, 이걸 단위 테스트 내부에서 작업자가 하드코딩하고 있었다. 만약에 특정 많은 컬럼 (특히, JSON 컬럼)을 가진 테이블의 데이터를 Mocking 해줘야한다면 그 데이터를 일일이 입력하고 있는게 상당히 불편할 수 밖에 없었다. 이러한 방식의 개선이 필요했다.

3. 단위 테스트 병목사항 개선

3.1. 객체 직접 생성

의존성 그래프를 잘 정리하여 모듈을 그대로 주입하여 쓰는것 말고는 TestingModule 를 활용할 수 있는 대안을 아쉽게도 찾지 못했다. 따라서, TestingModule 을 사용하지 않고, 직접 테스트하고자 하는 클래스의 객체를 생성하여 테스트하는 방법을 찾아보았다.
여러가지 삽질의 과정을 거쳐서 JS에서 객체를 생성하는 방법중 하나인 Object.createprototype 을 활용해보니 다소 간단히 외부 의존성 작업을 처리할 수 있는 방법을 찾을 수 있었다. 테스트하고자 하는 클래스의 외부 의존성이 물고 있는 추가적인 외부 의존성을 주입하지 않고도 객체를 생성할 수 있었다. 물론, 정상적인 방식으로 객체를 생성한것이 아니었기에 외부 의존성의 로직 실제로 동작 시키는것은 불가능 했다. 다만, 우리가 추구하는 방식은 Solitary Test 방식으로 Mocking 하는 것이었기에 큰 문제는 없었다.
예시 코드는 아래와 같다.
describe('SampleServiceImpl', () => { beforeAll(async () => { const service = new SampleServiceImpl( Object.create(FooReaderImpl.prototype), Object.create(FooStoreImpl.prototype), Object.create(BarProcessorImpl.prototype), Object.create(BarReaderImpl.prototype), Object.create(FoeReaderImpl.prototype), Object.create(FeeProcessorImpl.prototype), ); }); });
TypeScript
복사
이전 방식과 비교. 훨씬 구현 방식이 간단해졌다.

3.2. DataFactory 생성

Jest에서 Mocking 하는 방식이 아름답진 않지만, 그것보다 더 큰 병목은 Mocking 의 기대 값을 설정하는 것이다. 특히 그 값을 테스트마다 일일이 하드코딩할 경우 병목점이 더 커질게 불보듯 뻔했다. 일부 프레임워크에서 DataSet을 쉽게 만들어주는 기능들이 존재하지만 (예. Python - Factory Boy, Laravel - Faker) 아쉽게도 Node / Nest에서는 유사한 기능을 하는 라이브러리가 없기때문에 DataSet 생성 책임을 가지는 Factory 함수를 직접 만들어 DataSet 생성의 책임을 위임했다.
Factory 함수를 어떻게 구성할지에 대해서는 답이 사실 따로 없다고 생각한다. 이는 도메인 컨텍스트를 지속적으로 파악하고, 어떤식으로 DataSet을 만들때 가장 잘 활용도가 높을지를 팀 내부에서 끊임없이 고민하면서 고도화 하는 수 밖에 없다고 생각한다.
아무튼, Factory 함수의 응집도와 추상화 레벨을 최대한 높여서 잘 만들어놓는다면, DataSet을 만드는 책임을 위임하여 단위 테스트 내부에서 손쉽게 DataSet을 생성할 수 있다.
예시 코드는 아래와 같다.
export async function createSampleEntityFactory(input: Partial<SampleEntity> = {}, count = 1) { // 데이터가 설정되지 않았을 경우, 디폴트 값을 설정한다. const data = await setSampleEntityDefaultValue(input); const res: SampleEntity[] = []; // count 수 만큼 엔티티를 생성한다. for (const _ of range(count)) { res.push(new SampleEntity({ id: uuidv4(), ...data })); } return res; } async function setSampleEntityDefaultValue(data: Partial<SampleEntity>) { if (!data.sampleValue1) data.sampleValue1 = 'sample_value_1'; if (!data.sampleValue2) data.sampleValue2 = 'sample_value_2'; if (!data.sampleValue3) data.sampleValue3 = 'sample_value_3'; if (!data.sampleValue4) data.sampleValue4 = 'sample_value_4'; return data; }
TypeScript
복사
Data Factory는 아래와 같이 활용할 수 있다. 추상화된 DataFactory 함수만 잘 만들어놓으면 단위 테스트 내부에서 일일이 데이터를 만들 필요가 없다.
describe('doSomething', () => { it('예시 테스트 네이밍', async () => { // Given const samples = await createSampleEntityFactory({}, 2); const samplesWithValue = await createSampleEntityFactory({ sampleValue1: 'hello', sampleValue2: 'bye' }, 2); // 중략.. });
TypeScript
복사

3.3. Mocking Library 변경

지금까지 Testing Module DI 와 Mocking DataSet 병목사항을 개선한 방법을 살펴보았다. 여기서 한단계 조금 더 나아가면 Mocking Library 에 대한 고민을 할 수 있게 된다. Nest 기본 테스트 프레임워크인 Jest는 자체 Mocking 기능을 지원해주지만, 아래와 같이 몇가지 아쉬운 점이 있다.
Jest 테스트 프레임워크에 강력하게 종속적. 따라서 Jest 테스트 환경을 설정해줘야하며, Mocha등 새로운 테스트 프레임워크를 사용해야할 경우, 관련 코드를 모두 수정해줘야 함.
Stub이 따로 지원되지 않아 Spy를 통해 우회해서 구현해야함
Stub, Spy, Mock 등 Test double 들의 경계가 모호하게 사용됨.
예) jest.spyOn(FooStoreImpl.prototype, 'findById').mockResolvedValue(res);
cf) Stub, Spy, Mock 비교
Spy - 메소드나 함수의 호출 정보를 기록하고, 실제 동작을 모니터링하며 검증
Stub - 특정한 입력에 대해 정해진 출력이나 동작을 제공하기 위해 사용
Mock - 객체 간의 상호 작용을 검증하고, 예상대로 메소드나 함수가 호출되었는지 여부를 확인하기 위해 사용
Stubbing 하는 메서드를 string으로 선언해줘야하기때문에 IDE 지원을 받을 수 없음.
Stubbing 발생 조건을 Stub 함수에서 직접 처리해야하기때문에 단일 책임 원칙에서 크게 위반됨.
// https://jojoldu.tistory.com/638 향로님의 예시 코드 jest.spyOn(mockRepository, 'findById') .mockImplementation((orderId) => orderId === 1? Order.create(1000, LocalDateTime.now(), 'jest.mock'): undefined);
TypeScript
복사
Jest 라이브러리를 대체할 수 있는 옵션으로 ts-mockito, sinon 등이 있다. 그중 특히 ts-mockito는 Jest가 가지고 있는 단점들을 보완하고 있어, 대체가능한 가장 좋은 옵션으로 보이나 아쉽게도 현재 deprecated 되어 (2020년이 최신 버전) 라이브러리가 더이상 유지보수 되고 있지 않아 고려 대상에서 제외했다. ts-mockito에 대한 내용은 향로님의 포스팅에 자세히 설명되어 있으니 참고해보면 좋을것 같다.
sinon.js은 자바스크립트 진영에서 가장 대표적으로 많이 쓰이는 Mocking Library 이다. sinon을 단순히 stub하여 정해진 출력을 반환하는 용도로만 쓴다면 을 jest와 크게 다른 점을 느끼지 못할 수 도 있다. 그러나 sinon은 jest에 비해 좋은 장점을 갖고 있다.
1.
더 다양한 테스트 더블 유형 지원: Sinon.js는 스파이(Spy), 스텁(Stub), 모크(Mock), 프레임워크에서의 시계 조작 등 다양한 테스트 더블을 생성하고 제어하는 데 사용됨. Jest는 주로 모킹에 중점을 둔다면, Sinon.js는 더 다양한 상황에서 활용할 수 있는 도구를 제공함
2.
메소드 호출 순서 검증: Sinon.js는 메소드 호출 순서를 검증하는 기능을 제공함. 이것은 특정 시나리오에서 메소드 호출 순서가 중요한 경우 유용
3.
메소드의 실제 구현 유지: Sinon.js를 사용하면 메소드의 원래 구현을 유지하면서 일부 동작을 변경할 수 있음. Jest의 모킹은 원래 구현을 대체하므로 실제 동작을 유지하고 일부 동작을 변경하려면 약간의 어려움이 있을 수 있음
4.
더 강력한 동적 스텁: Sinon.js는 메소드가 특정 인수 또는 상황에 따라 동적으로 다른 값을 반환하도록 스텁하는 데 사용할 수 있는 callsFake onCall과 같은 기능을 제공
5.
기타 기능: Sinon.js는 시계 조작, 타이머 관리, HTTP 요청 mocking, 이벤트 mocking, 프로미스 mocking 등 다양한 기능을 제공
6.
Jest 독립성: Sinon.js는 Jest와 독립적으로 사용할 수 있으며, 다른 테스트 프레임워크와도 통합하기 쉬움. 이는 특정 프레임워크에 종속되지 않고 테스트 도구를 선택할 수 있는 유연성을 제공
7.
성숙한 라이브러리: Sinon.js는 장기간에 걸쳐 성숙한 라이브러리로 발전해왔으며, 널리 사용되고 검증된 도구임
Stubbing 하는 메서드를 여전히 string으로 선언해줘야하기때문에 IDE 지원을 받을 수 없는것은 아쉬웠지만 다른 장점들이 많기에 Jest 대신 Sinon.js를 적용하였다.
예시코드는 아래와 같다. 아주간단한 형태로 작성한 것이며 공식 문서를 보면 정말 다양한 형태로 테스트 더블을 활용하고 검증할 수 있다.
describe('doSomething', () => { it('예시 테스트', async () => { // Given const samples = await createSampleEntityFactory({}, 2); const samplesWithValue = await createSampleEntityFactory( { sampleValue1: 'hello', sampleValue2: 'bye', }, 2, ); // stubbing 설정 const getFooStub = sinon.stub(FooReaderImpl.prototype, 'getFoo'); // onFirstCall(), onSecondCall() 를 할용하여 몇번째 호출이냐에따라 다른값을 return 가능 getFooStub.onFirstCall().resolves(samples); getFooStub.onSecondCall().resolves(samplesWithValue); const storeFooStub = sinon.stub(FooReaderImpl.prototype, 'storefoo').resolves(undefined); // When const res = await service.doSomething(); // Then // 행위 검증도 가능 sinon.assert.calledTwice(getFooStub); sinon.assert.calledOnce(storeFooStub); sinon.assert.calledOnce(rsaStoreStoreManyStub); expect(res).toEqual(true); }); });
TypeScript
복사

4. Summary

지금까지 단위테스트를 쉽게 작성하기 위한 여러가지 병목사항을 개선해보았다. 간단히 요약하자면 다음과 같다.
1.
TestingModule DI 복잡성 문제를 피하기 위해, 직접 객체 생성 방식 도입
2.
DataFactory 라는 독립된 모듈을 통해 dataset 생성을 위임함으로써 테스트코드 내부에서 dataset 직접 작성 코드 제거
3.
Jest의 한계를 극복하고 더 다양한 stub/mock 기능을 제공하는 Sinon.js 도입
이 모든게 적용된 예시코드는 다음과 같다.
describe('SampleServiceImpl', () => { let service: SampleServiceImpl; beforeAll(async () => { // 1. 직접 객체 생성 const service = new SampleServiceImpl( Object.create(FooReaderImpl.prototype), Object.create(FooStoreImpl.prototype), Object.create(BarProcessorImpl.prototype), Object.create(BarReaderImpl.prototype), Object.create(FoeReaderImpl.prototype), Object.create(FeeProcessorImpl.prototype), ); }); describe('doSomething', () => { it('예시 테스트', async () => { // 2. DataFactory로 Dataset 생성 위임 const samples = await createSampleEntityFactory({}, 2); const samplesWithValue = await createSampleEntityFactory( { sampleValue1: 'hello', sampleValue2: 'bye', }, 2, ); // 3. Sinon.js 사용 const getFooStub = sinon.stub(FooReaderImpl.prototype, 'getFoo'); getFooStub.onFirstCall().resolves(samples); getFooStub.onSecondCall().resolves(samplesWithValue); const storeFooStub = sinon.stub(FooReaderImpl.prototype, 'storefoo').resolves(undefined); const res = await service.doSomething(); sinon.assert.calledTwice(getFooStub); sinon.assert.calledOnce(storeFooStub); expect(res).toEqual(true); }); }); });
TypeScript
복사
단위 테스트를 작성하는 길은 쉽지 않을 수 있다. 습관화가 안되었다면 상당히 번거롭고 불편하다고 느끼는 개발자분도 많을 것이다. 최소한 비즈니스 로직을 검증하는 단위 테스트라도 작성하여 더 높은 소프트웨어 품질을 달성하는데 이 포스팅이 도움이 되기를 바란다.

5. Reference