Unit Test 정복하기 1
Introduction
개발자로 이직을 하기 위해 여러가지를 배웠지만, 제일 중점적으로 공부하고 배웠던건 역시 “기능을 구현하는 방법” 이었다. 백엔드에서 데이터를 어떻게 불러오고 필터링을 어떻게 코딩할지, 또는 프론트엔드에서 받은 데이터를 어떻게 처리해서 원하는 응답을 다시 프론트엔드로 보낼지 에 대해서 더 열심히 배우고 공부했다. 또 프론트엔드에서는 백엔드에서 받은 데이터를 어떻게 하면 효율적으로 브라우저에 렌더링 할 것인지 에 대해 더 집중을 했다. 물론 유닛테스트 (Unit-Test) 에 대해서도 배우고, 이 테스트의 중요성에 대해서도 배웠지만, 취업을 준비할 당시에는 아마도 제일 중요했던건 “이런 저런 기능을 이런 저런 기술로 구현을 하였다” 를 면접에서 말 할 수 있어야 하는 것이 아니었을까.
하지만 현업에서는 프로젝트마다 다르겠지만, 유닛테스트는 상당히 중요했다. 이런 프로젝트도 있었다. 기능을 구현하고, 유닛테스트를 쓰지 않으면 아예 배포서버에서 빌드가 안되도록 막아 놓는 경우도 있었고, 테스트 커버리지 (Test Coverage) 의 Threshold를 정해놓고 그걸 넘지 못한다면 아예 Github 에 푸쉬를 못하도록 막아 놓는 경우도 있었다.
물론 이런 제한을 대부분의 개발자들은 싫어하지만, 결국에는 이런 것들이 말하고자 하는 것은 유닛 테스트가 그만큼 중요하다는 뜻일 것이다.
앞으로 유닛테스트에 대해서 여러가지 경우를 다뤄볼 예정이고, 그 첫번째는 프론트엔드에서 한 서비스의 유닛 테스트 이다.
Service in Frontend
Service
의 개념을 짧게 설명하자면, 복잡한 비즈니스 로직을 분리 하거나 데이터를 다루는 작업을 중앙 집중화 하여 코드의 유지보수성과 재사용성을 높이기 위해서 서비스를 사용한다. 백엔드와 프론트엔드에서 모두 사용할 수 있는데, 특히 Angular
와 같은 프레임워크에서는 서비스 패턴이 자주 사용된다. React
와 같은 라이브러리에서도 데이터 처리를 위해 사용할 수 있고, 이 글의 예시가 될 서비스도 이런 의미로 사용되었다.
이 프로젝트는 백엔드가 따로 없고, 바로 프론트엔드 코드에서 데이터베이스에 접근해서 데이터를 불러오고 이 불러온 데이터를 서비스에서 처리를 하고 컴포넌트에서 렌더링 하는 방식이 사용되었다.
What to be focused, how to write?
그럼 이제 유닛 테스트를 어떻게 작성하는지, 그리고 작성할 때 어떤 단계를 따라야 하는지에 대해서 다뤄보겠다.
-
테스트 대상 분석
테스트를 해야 할 대상, 예를 들어 어떤 한 서비스,가 어떤 메서드를 갖고 있는지, 그리고 그 각각의 메서드 들은 무엇을 하는지, 어떤 입력을 받고 어떤 출력을 하는지 이해를 해야한다. 그래야 다음 단계인 테스트 케이스를 제대로 정의를 할 수가 있다.
-
주요 기능에 대해 테스트 케이스 정의
각 메서드에 대해 성공적인 시나리오와 실패 또는 에외 시나리오를 고려한다. 각각의 시나리오를 구체적으로 정의하여 테스트 케이스로 나눈다.
-
테스트 코드 작성 로직
Given, When, Then 구조로 테스트를 작성하는 것이 일반적이다.
- Given: 테스트 조건을 설정하는 부분이다. 예를 들어, 메서드
A()
가 제대로 된 값을 반환하기 위해서 다른 메서드B()
가 리턴값b
를 반환해야만 한다면, 메서드A()
의 성공 시나리오의 Given 은 “메서드B()
가 리턴값b
를 반환한다.” 가 될 것이다. - When: 실제로 테스트하려는 메서드를 호출하는 부분이다. 이 단계에서 실제 테스트 대상인 메서드를 실행한다.
- Then: 메서드의 결과가 예상과 일치하는지 확인하는 부분이다. 사용하는 테스트 라이브러리에 따라 다르겠지만, jest 에서는 expect 를 사용하여 결과를 검증한다.
이 때 한 가지 질문이 생길 수 있다.
그러면 Given 에서 쓰이는 다른 메서드는 어떻게 하는가?
이 질문에 대한 답을 주는 것이 바로 Mocking (모킹) 이다. 모킹에 대해서는 따로 다루겠지만, 간단히 설명하자면, 해당 메서드와 같은 가짜 복제품을 만드는 것이다. 이런 모킹의 주요 기능중 하나는 모킹한 메서드의 반환값을 마음대로 지정할 수 있다는 것이다.
- Given: 테스트 조건을 설정하는 부분이다. 예를 들어, 메서드
-
정상 시나리오와 예외 시나리오 구분
- 예상하는 반환 값이 잘 나오는 정상 시나리오와, 에러가 뜨거나 특정 데이터가 누락되는 예외 시나리오를 각각 나누어 테스트를 한다.
- 예외 시나리오에서는 잘못된 입력, 즉 Given 에서 조건이 잘못됐을 때 Error Handling이 잘 되는지 확인한다.
여기까지가 우리가 유닛 테스트를 작성할 때 기본적으로 신경을 써야할 부분들일 것이다. 이제 다음 챕터에서 구체적인 예를 가지고 위의 네가지 단계를 다시 살펴보겠다.
ExampleService
다음은 내가 테스트를 해야 할 서비스 코드의 일부이다. 나는 combineExampleInfoAndRequestAndFunded
메서드를 테스트 할 것이고, 어떤 로직으로 테스트 코드를 짜는지 설명할 것이다.
class ExampleService {
async combineExampleInfoAndRequestAndFunded(
filter?: FilterType
): Promise<Example[] | undefined> {
return Promise.all([
ExampleInfoService.getExampleInfo(),
ExampleRequestService.getExampleRequest(),
ExampleFundedService.getExampleFunded(),
]).then((resps) => {
return resps[0]
.map((ei) => {
return {
// 데이터가 수집된다.
};
})
.filter((example) =>
filter
? // 필터가 잇으면 필터가 되고
: true // 필터가 없으면 모두 true, 즉 모든 값이 반환된다.
) as Example[];
});
}
}
const ExampleService = new ExampleService();
export { ExampleService, ExampleService };
combineExampleInfoAndRequestAndFunded(filter?: FilterType)
위의 코드를 잘 분석해보자.
combineExampleInfoAndRequestAndFunded(filter?: FilterType)
메서드는filter
인자를 받을 수도 있고 아닐 수도 있다.- 반환 값의 타입은
Example[]
또는undefined
이다. Promise.all
을 통해 세 개의 비동기 메서드가 각각 다른 서비스에서 실행된다.- 위에서 나온 결과가 조합되어 반환이 되고,
filter
가 있다면 필터링이 되어서 반환이 된다. 그리고 반환 값의 타입을Example[]
로 선언한다.
이것을 그림으로 표현한다면 다음과 같을 것이다.
여기서 테스트 케이스로 정의될 수 있는건 밑에 두개의 박스이다. Promise.all([])
은 메서드가 값을 반환하기 위해서 필요한 “전제 조건” 같은 것이다. 따라서 Given 에 들어갈 것이고, 세 개의 비동기 메서드들은 모킹이 될 것이다.
정상 시나리오 (filter
가 없을 때)
입력 인자인 filter
가 없을 때 정상 시나리오는 Promise.all([])
에 있는 세 개의 비동기 메서드가 제대로 된 값을 반환하는게 Given 이 되고 테스트 코드는 다음과 같을 것이다.
it("should be return an entire Example-Object, when the method is called without filter", async (req, res) => {
// GIVEN
vi.spyOn(ExampleInfoService, "getExampleInfo").mockResolvedValue([{recordId: "1", name: "Test ExampleInfo", ...}]);
vi.spyOn(ExampleRequestService, "getExampleRequest").mockResolvedValue([{id1: "1", name1: "Test ExampleRequest", ...}]);
vi.spyOn(ExampleFundedService, "getExampleFunded").mockResolvedValue([{id2: "1", name2: "Test ExampleFunded", ...}]);
// WHEN
const result = await exampleService.combineExampleInfoAndRequestAndFunded();
// THEN
expect(result).toBeEqual([{recordId: "1", name: "Test ExampleInfo", id1: "1", name1: "Test ExampleRequest", id2: "1", name2: "Test ExampleFunded" }])
})
예외 시나리오 (filter
가 없을 때)
입력 인자인 filter
가 없을 때 예외 시나리오는 Promise.all([])
에 있는 세 개의 비동기 메서드중 하나 라도 빈 배열을 반환하는게 선제 조건이 될 수 있을 것이고, 코드는 다음과 같을 것이다.
it("should be return an undefined, when the method is called without filter and getExampleInfo returns an empty array", async (req, res) => {
// GIVEN
vi.spyOn(ExampleInfoService, "getExampleInfo").mockResolvedValue([]);
vi.spyOn(ExampleRequestService, "getExampleRequest").mockResolvedValue([{id1: "1", name1: "Test ExampleRequest", ...}]);
vi.spyOn(ExampleFundedService, "getExampleFunded").mockResolvedValue([{id2: "1", name2: "Test ExampleFunded", ...}]);
// WHEN
const result = await exampleService.combineExampleInfoAndRequestAndFunded();
// THEN
expect(result).toBeUndefined()
})
Conclusion
위의 내용들을 요약해보자면, 유닛 테스트를 하기 위해서는 해당 메서드의 비즈니스 로직을 잘 이해하는 것이 중요하다. 그래야 무엇이 전제 조건 (Given)이 되는지, 메서드 실행 후 반환 값이 전제 조건에 따라서 어떻게 나오는지 알 수 있기 때문이다. 메서드를 잘 이해해야 정상 시나리오와 에러를 발생시키는 예외 시나리오도 잘 짤 수 있다. 그리고 모킹을 해야하는 메서드가 무엇이 되어야 하는지도 잘 알 수 있을 것이다.
댓글남기기