최근 NestJS를 처음 공부하면서 연습삼아 테스트 코드를 짜 보던 중,
interceptor에 대한 단위 테스트를 해 보고 싶었던 적이 있었습니다.
사실 controller단에서 테스트를 해도 성능 테스트는 잘 되었을 것 같지만, 왠지 test coverage에 파란 불이 들어오는 걸 보고 싶었달까요...ㅎㅎ
그렇게 관련 자료를 좀 찾아보다가, 생각보다 intercept 자체를 테스트하는 경우는 많지 않은 듯하여 기록삼아 남겨 두는 글입니다.
사실 굳이 필요한 부분인지 아직 잘 모르겠지만..ㅎㅎ 개인적으로 재미있었던 부분이라..^^
아래에서 언급할 코드들은, 포스팅의 순서와 동일한 순서로 작성되지는 않았으며, 기록과 설명의 편의를 위해 임의로 구분된 순서임을 밝힙니다 :)
api.service.ts => axios 에러 발생시 errorHandlerService의 handleAxios를 호출
import { CACHE_MANAGER, Inject, Injectable, LoggerService } from '@nestjs/common';
import axios from 'axios';
import { Cache } from 'cache-manager';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { ErrorHandlerService } from '../common/error-handler/error-handler.service';
import { ApiDomainProvider } from './api.domain.provider';
import { ParamDto } from './dto/api.dto';
import { ApiGetDto } from './dto/api.dto';
@Injectable()
export class ApiService {
constructor(
private errorHandlerService:ErrorHandlerService,
private apiDomainProvider:ApiDomainProvider,
) {}
async callAxiosGet(urlParam:ParamDto):Promise<callAxiosDto> {
try {
const completeUrl = this.apiDomainProvider.getCompleteUrl(urlParam.param);
const result = await axios.get(completeUrl);
return {
data:result.data.data
};
} catch (error) {
this.errorHandlerService.handleAxios(error);
}
}
}
error-handler.service.ts => axios 에러에 맞는 Custom Error를 반환
import { Injectable } from '@nestjs/common';
import { Exception } from '../exceptions/interface/exception.interface';
import { NetworkError } from '../exceptions/common/notfound.exception';
import { ClientError } from '../exceptions/axios/client.error';
import { ServerError } from '../exceptions/axios/server.error';
import { DefaultError } from '../exceptions/common/default.exception';
import { INITIAL_CLIENT_ERROR_CODE, INITIAL_SERVER_ERROR_CODE } from '../constants/constants';
@Injectable()
export class ErrorHandlerService {
handleAxios(error):Exception{
if (error.response) {
const statusCode = error.response.status;
// client Error
if (statusCode >= INITIAL_CLIENT_ERROR_CODE && statusCode < INITIAL_SERVER_ERROR_CODE) {
return new ClientError();
}
// server Error
if (statusCode >= INITIAL_SERVER_ERROR_CODE) {
return new ServerError();
}
} else if (error.request) { // No Response
return new NetworkError();
} else {
return new DefaultError();
}
}
}
client.error.ts => Custom Error 정의
import { Exception } from '../interface/exception.interface';
export class ClientError implements Exception {
public readonly statusCode = 400;
public readonly name = 'Client Error';
public readonly message = '유효하지 않은 요청입니다.';
public readonly type = 'client';
}
server.error.ts => Custom Error 정의
import { Exception } from '../interface/exception.interface';
export class ServerError implements Exception {
public readonly statusCode = 500;
public readonly name = 'Server Error';
public readonly message = '유효하지 않은 응답입니다.';
public readonly type = 'server';
}
제가 연습용 서버에서 작성했던, errorHandlerService
의 일부분인 handleAxios
함수와, 해당 함수에서 사용한 Custom Error의 일부분입니다.
service
단에서 Axios 관련 에러가 발생하면,
errorHandlerService
의 handleAxios
가 적절한 Custom Error를 반환하고,
interceptor
가 이를 intercept하여 Custom Error에 맞는 적절한 Http Exception을 클라이언트에 반환하는 형태로 코드를 작성하였습니다!
**UseInterceptors
를 활용하여 controller
단에 Interceptor를 붙이는 부분은 해당 포스팅과 크게 관련이 없어 생략하도록 하겠습니다 :)
#0.연습 서버에서 Interceptor의 역할에서 언급한 기능을 다음과 같은 Interceptor 코드로 구현하였습니다.
api.interceptor.ts => Interceptor 작성
import { BadRequestException, CallHandler, ExecutionContext, HttpException, HttpStatus, Injectable,
InternalServerErrorException, NestInterceptor, NotFoundException } from "@nestjs/common";
import { catchError, Observable } from "rxjs";
import { Exception } from "../exceptions/interface/exception.interface";
import { DEFAULT_ERROR_MESSAGE, GET_ERROR_MESSAGES, TX_MESSAGES } from "../../common/constants/constants";
@Injectable()
export class ApiInterceptor implements NestInterceptor {
intercept(context:ExecutionContext, next:CallHandler): Observable<Exception> {
return next.handle().pipe(
catchError((error) => {
if (error.type === 'network') {
throw new NotFoundException(TX_MESSAGES.NETWORK_UNSTABLE);
}
if (error.type === 'client') {
throw new BadRequestException(GET_ERROR_MESSAGES.CLIENT);
}
if (error.type === 'server') {
throw new InternalServerErrorException(GET_ERROR_MESSAGES.SERVER);
}
throw new HttpException(DEFAULT_ERROR_MESSAGE, HttpStatus.BAD_REQUEST);
})
)
}
}
해당 Interceptor 작성 과정에서도 역시 Nest JS 공식문서를 참고하였습니다 :D
api.interceptor.spec.ts
import { ExecutionContext } from "@nestjs/common";
import { createMock } from "@golevelup/ts-jest";
import { ApiInterceptor } from "./api.interceptor"
describe('ApiInterceptor', () => {
let interceptor:ApiInterceptor;
let executionContext:ExecutionContext;
beforeEach(() => {
interceptor = new ApiInterceptor();
executionContext = createMock<ExecutionContext>();
});
it('apiInterceptor가 정의되어야 함.', () => {
expect(interceptor).toBeDefined();
});
});
위의 코드는 @golevelup/ts-jest
의 createMock
함수를 사용하여
ExecutionContext
를 모킹하였다는 점을 제외하면, NestJS에서 자동으로 생성해 주는 테스트 파일의 형태와 동일한 것으로 기억합니다.
@golevelup/ts-jest
의 createMock
는 NestJS의 공식 문서에서 HINT로 잠시 언급되기도 하는데,
npm.js의 패키지 설명에 따르면, 모든 sub property를 jest.fn()
으로 모킹한 mock Object를 생성해 준다고 합니다!
재밌게도 패키지 설명에서 createMock
사용의 예시로, 이번에 사용할 ExecutionContext
의 모킹을 소개해 주고 있는데,
저도 그 설명을 따라서 createMock
을 사용해 ExecutionContext
를 모킹하여 사용하였습니다 :)
모킹한 ExecutionContext
는 #1. [Interceptor 작성] 부분에서 확인하실 수 있듯,
intercept
함수를 테스트 실행할 때 첫 번째 인자로 넣어 줄 예정입니다!
이제 #1.[작성한 Interceptor]에서 throw하는 4가지 에러들에 대한 테스트 케이스를 작성하고, 2번째 인자로 handle method를 넣어주는 부분까지 작성해 보도록 하겠습니다!
2-1. createMock 사용하기에서 내용을 추가한 코드로, 일부 코드가 중복됩니다.
api.interceptor.spec.ts
import { ExecutionContext } from "@nestjs/common";
import { createMock } from "@golevelup/ts-jest";
import { throwError } from "rxjs";
import { ClientError } from "../exceptions/axios/client.error";
import { ServerError } from "../exceptions/axios/server.error";
import { NetworkError } from "../exceptions/common/notfound.exception";
import { ApiInterceptor } from "./api.interceptor"
import { DEFAULT_ERROR_MESSAGE, GET_ERROR_MESSAGES, TX_MESSAGES } from "../constants/constants";
describe('ApiInterceptor', () => {
let interceptor:ApiInterceptor;
let executionContext:ExecutionContext;
beforeEach(() => {
interceptor = new ApiInterceptor();
executionContext = createMock<ExecutionContext>();
});
it('apiInterceptor가 정의되어야 함.', () => {
expect(interceptor).toBeDefined();
});
describe('intercept', () => {
it('NotFoundException을 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new NetworkError()))
}
});
});
it('BadRequestException을 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new ClientError()))
}
});
});
it('InternalServerErrorException을 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new ServerError()))
}
});
});
it('조건에 해당하지 않는 Error는 기본 메시지를 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new Error()))
}
});
});
});
ApiInterceptor
의 intercept
함수를 테스트하고자 하는 것이므로,
intercept
라는 describe 문을 새로 작성하고, 그 내부에 4가지 에러 반환 경우 해당하는 테스트를 작성하였습니다!
각 테스트의 내부에서, 본격적으로 intercept
의 테스트를 위해 interceptor.intercept()
를 호출하는데,
첫 번째 인자로는 조금 전에 모킹한 ExecutionContext
를, 두 번째 인자로 들어가야 할 CallHandler
부분에는, 모킹한 handle method가 포함된 객체를 넣어 주었습니다.
NestJS 공식 문서 및 본 포스팅의 #1. [Interceptor 작성] 부분에서 확인하실 수 있듯,
NestInterceptor
는 두 번째 인자로 handle
method를 implement하는 CallHandler
타입을 받고, Observable
타입의 값을 반환합니다. 따라서, 저도 이에 맞추어
CallHandler
의 handle
method를 원하는 기능으로 구현하여, 의도된 테스트를 실행해 볼 수 있도록, intercept
의 두 번째 인자로 "제가 전제하는 기능을 실행하는 handle
method가 포함된 객체"로 CallHandler 타입을 대체해 보는 방식으로 테스트 코드를 작성하였습니다!
{
handle:() => {
return (throwError(() => new ClientError()))
}
}
위의 코드에서 throwError
는
RxJS 문서에 따르면, subscribe
가 호출될 때마다 Error를 생성하는 Observable
을 생성한다고 합니다!
NestJS 공식 문서에 따르면 CallHandler의 handle
method는 Observable
을 반환하므로, 이를 모킹할 때에도 반환값 타입을 동일하게 맞춰 주기 위해 위와 같은 구문으로 throwError
를 사용했습니다 :)
P.S RxJS
및 Observable
에 대한 자세한 설명은 공식 문서를 참고하시면 좋을 듯합니다!
참고 : RxJS 문서
2-2. 테스트 케이스 정의 && handle method 인자로 넣어주기에서 내용을 추가한 코드로, 일부 코드가 중복되며 해당 코드가 최종 작성본입니다!
api.interceptor.spec.ts
import { ExecutionContext } from "@nestjs/common";
import { createMock } from "@golevelup/ts-jest";
import { throwError } from "rxjs";
import { ClientError } from "../exceptions/axios/client.error";
import { ServerError } from "../exceptions/axios/server.error";
import { NetworkError } from "../exceptions/common/notfound.exception";
import { ApiInterceptor } from "./api.interceptor"
import { DEFAULT_ERROR_MESSAGE, GET_ERROR_MESSAGES, TX_MESSAGES } from "../constants/constants";
describe('ApiInterceptor', () => {
let interceptor:ApiInterceptor;
let executionContext:ExecutionContext;
beforeEach(() => {
interceptor = new ApiInterceptor();
executionContext = createMock<ExecutionContext>();
});
it('apiInterceptor가 정의되어야 함.', () => {
expect(interceptor).toBeDefined();
});
describe('intercept', () => {
const mockNextFn = jest.fn();
const mockCompleteFn = jest.fn();
it('NotFoundException을 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new NetworkError()))
}
});
const result = resultObservable.subscribe({
next() {
mockNextFn();
},
error(error) {
expect(error.status).toBe(404);
expect(error.response.error).toBe('Not Found');
expect(error.response.message).toBe(TX_MESSAGES.NETWORK_UNSTABLE);
},
complete() {
mockCompleteFn();
}
});
expect(mockNextFn).toHaveBeenCalledTimes(0);
expect(mockCompleteFn).toHaveBeenCalledTimes(0);
});
it('BadRequestException을 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new ClientError()))
}
});
const result = resultObservable.subscribe({
next() {
mockNextFn();
},
error(error) {
expect(error.status).toBe(400);
expect(error.response.error).toBe('Bad Request');
expect(error.response.message).toBe(GET_ERROR_MESSAGES.CLIENT);
},
complete() {
mockCompleteFn();
}
});
expect(mockNextFn).toHaveBeenCalledTimes(0);
expect(mockCompleteFn).toHaveBeenCalledTimes(0);
});
it('InternalServerErrorException을 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new ServerError()))
}
});
const result = resultObservable.subscribe({
next() {
mockNextFn();
},
error(error) {
expect(error.status).toBe(500);
expect(error.response.error).toBe('Internal Server Error');
expect(error.response.message).toBe(GET_ERROR_MESSAGES.SERVER);
},
complete() {
mockCompleteFn();
}
});
expect(mockNextFn).toHaveBeenCalledTimes(0);
expect(mockCompleteFn).toHaveBeenCalledTimes(0);
});
it('조건에 해당하지 않는 Error는 기본 메시지를 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new Error()))
}
});
const result = resultObservable.subscribe({
next() {
mockNextFn();
},
error(error) {
expect(error.status).toBe(400);
expect(error.message).toBe(DEFAULT_ERROR_MESSAGE);
},
complete() {
mockCompleteFn();
}
});
expect(mockNextFn).toHaveBeenCalledTimes(0);
expect(mockCompleteFn).toHaveBeenCalledTimes(0);
})
});
});
이 부분에서는 크게 3가지 부분을 추가하여 테스트 코드를 완성하였습니다!
**
1. describe 내부에 mockNextFn
, mockCompleteFn
작성
2. subscribe
호출과 next
, error
, complete
작성
3. expect
를 활용한 테스트
const mockNextFn = jest.fn();
const mockCompleteFn = jest.fn();
describe의 바로 아래에 위의 두 줄의 코드를 추가하였습니다.
해당 코드들은 다음 2-3-3. expect
를 활용한 테스트 부분에서 사용할 예정입니다!
subscribe
호출과 next
, error
, complete
작성P.S. 하나의 예시만을 언급하며 정리하였습니다!
const result = resultObservable.subscribe({
next() {
},
error(error) {
},
complete() {
}
});
사실 subscribe라는 개념에 대해서는 저도 잘 모르겠지만...ㅠㅠ
문서의 설명에 따르면, 간단히 "Observable의 실행" 정도로 볼 수 있는 것 같습니다.
느낌인 것으로 이해하였습니다!
expect
를 활용한 테스트 const result = resultObservable.subscribe({
next() {
mockNextFn();
},
error(error) {
expect(error.status).toBe(404);
expect(error.response.error).toBe('Not Found');
expect(error.response.message).toBe(TX_MESSAGES.NETWORK_UNSTABLE);
},
complete() {
mockCompleteFn();
}
});
expect(mockNextFn).toHaveBeenCalledTimes(0);
expect(mockCompleteFn).toHaveBeenCalledTimes(0);
#2-2. [테스트 케이스 정의 && handle method 인자로 넣어주기] 부분에서 4가지 경우 모두 handle
method가 무조건 에러를 반환하는 형태로 모킹하였기 때문에,
에러가 발생하면 실행될 부분인 error()
에서
에러의 status Code, 응답 내용과 메시지가 예상한 대로 interceptor에 의해 잘 변환되는지를 테스트하였습니다!
**그리고 추가로, next()와 complete()의 실행으로 테스트가 의도한 바와 다르게 통과해 버리는 경우를 막기 위하여,
2-3-1에서 정의한 mockNextFn
과 mockCompleteFn
을 각각
next()
와 complete()
의 실행부에 넣어,
expect().toHaveBeenCalledTimes(0)
을 통해 next()
와 complete()
가 테스트 과정에서 실행되지 않음을 확인하였습니다 :)
위의 과정을 거쳐 완성된 코드는 다음과 같습니다 :)
api.interceptor.spec.ts
import { ExecutionContext } from "@nestjs/common";
import { createMock } from "@golevelup/ts-jest";
import { throwError } from "rxjs";
import { ClientError } from "../exceptions/axios/client.error";
import { ServerError } from "../exceptions/axios/server.error";
import { NetworkError } from "../exceptions/common/notfound.exception";
import { ApiInterceptor } from "./api.interceptor"
import { DEFAULT_ERROR_MESSAGE, GET_ERROR_MESSAGES, TX_MESSAGES } from "../constants/constants";
describe('ApiInterceptor', () => {
let interceptor:ApiInterceptor;
let executionContext:ExecutionContext;
beforeEach(() => {
interceptor = new ApiInterceptor();
executionContext = createMock<ExecutionContext>();
});
it('apiInterceptor가 정의되어야 함.', () => {
expect(interceptor).toBeDefined();
});
describe('intercept', () => {
const mockNextFn = jest.fn();
const mockCompleteFn = jest.fn();
it('NotFoundException을 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new NetworkError()))
}
});
const result = resultObservable.subscribe({
next() {
mockNextFn();
},
error(error) {
expect(error.status).toBe(404);
expect(error.response.error).toBe('Not Found');
expect(error.response.message).toBe(TX_MESSAGES.NETWORK_UNSTABLE);
},
complete() {
mockCompleteFn();
}
});
expect(mockNextFn).toHaveBeenCalledTimes(0);
expect(mockCompleteFn).toHaveBeenCalledTimes(0);
});
it('BadRequestException을 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new ClientError()))
}
});
const result = resultObservable.subscribe({
next() {
mockNextFn();
},
error(error) {
expect(error.status).toBe(400);
expect(error.response.error).toBe('Bad Request');
expect(error.response.message).toBe(GET_ERROR_MESSAGES.CLIENT);
},
complete() {
mockCompleteFn();
}
});
expect(mockNextFn).toHaveBeenCalledTimes(0);
expect(mockCompleteFn).toHaveBeenCalledTimes(0);
});
it('InternalServerErrorException을 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new ServerError()))
}
});
const result = resultObservable.subscribe({
next() {
mockNextFn();
},
error(error) {
expect(error.status).toBe(500);
expect(error.response.error).toBe('Internal Server Error');
expect(error.response.message).toBe(GET_ERROR_MESSAGES.SERVER);
},
complete() {
mockCompleteFn();
}
});
expect(mockNextFn).toHaveBeenCalledTimes(0);
expect(mockCompleteFn).toHaveBeenCalledTimes(0);
});
it('조건에 해당하지 않는 Error는 기본 메시지를 던져야 함.', () => {
const resultObservable = interceptor.intercept(executionContext, {
handle:() => {
return (throwError(() => new Error()))
}
});
const result = resultObservable.subscribe({
next() {
mockNextFn();
},
error(error) {
expect(error.status).toBe(400);
expect(error.message).toBe(DEFAULT_ERROR_MESSAGE);
},
complete() {
mockCompleteFn();
}
});
expect(mockNextFn).toHaveBeenCalledTimes(0);
expect(mockCompleteFn).toHaveBeenCalledTimes(0);
})
});
});
그리고,
npm run test:cov
명령어를 통해, 테스트를 작성한 api.interceptor.ts
파일의 테스트가 커버리지가
깔끔하게 100%로 나오는 부분까지 확인할 수 있었습니다!
사실은, 제가 혼자 구글링하며 임의로 작성한 내용이다 보니, 개념이 명확하지 못한 부분이 있을 수 있고, 좋지 않은 방식으로 작성된 내용일 수 있을 것 같습니다.
부족하거나 잘못된 부분이 있다면, 댓글로 의견 주실 수 있으시면 감사하겠습니다 :D
앞으로도 계속 배워 나가고자 합니다 :)