[NestJS] Interceptors 단위 테스트(Unit Testing)

toto9602·2022년 8월 27일
0

NestJS 공부하기

목록 보기
1/3

최근 NestJS를 처음 공부하면서 연습삼아 테스트 코드를 짜 보던 중,
interceptor에 대한 단위 테스트를 해 보고 싶었던 적이 있었습니다.

사실 controller단에서 테스트를 해도 성능 테스트는 잘 되었을 것 같지만, 왠지 test coverage에 파란 불이 들어오는 걸 보고 싶었달까요...ㅎㅎ

그렇게 관련 자료를 좀 찾아보다가, 생각보다 intercept 자체를 테스트하는 경우는 많지 않은 듯하여 기록삼아 남겨 두는 글입니다.

사실 굳이 필요한 부분인지 아직 잘 모르겠지만..ㅎㅎ 개인적으로 재미있었던 부분이라..^^

아래에서 언급할 코드들은, 포스팅의 순서와 동일한 순서로 작성되지는 않았으며, 기록과 설명의 편의를 위해 임의로 구분된 순서임을 밝힙니다 :)

0. 연습 서버에서 Interceptor의 역할

0-1. 관련된 service

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();
        }
    }
}

0-2. 관련된 custom Errors

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 관련 에러가 발생하면,
errorHandlerServicehandleAxios가 적절한 Custom Error를 반환하고,
interceptor가 이를 intercept하여 Custom Error에 맞는 적절한 Http Exception을 클라이언트에 반환하는 형태로 코드를 작성하였습니다!

**UseInterceptors를 활용하여 controller 단에 Interceptor를 붙이는 부분은 해당 포스팅과 크게 관련이 없어 생략하도록 하겠습니다 :)

참고 : Nest JS 공식 문서 - Interceptors


1. 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);
           })
       )
   }
}

발생한 error를 잡아, 해당 error의 type에 따라 서로 다른 Http Exception을 반환해 주는 간단한 Interceptor입니다!

해당 Interceptor 작성 과정에서도 역시 Nest JS 공식문서를 참고하였습니다 :D


2. 테스트 코드 작성

2-1. createMock 사용하기

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-jestcreateMock 함수를 사용하여
ExecutionContext를 모킹하였다는 점을 제외하면, NestJS에서 자동으로 생성해 주는 테스트 파일의 형태와 동일한 것으로 기억합니다.


@golevelup/ts-jestcreateMockNestJS의 공식 문서에서 HINT로 잠시 언급되기도 하는데,

npm.js의 패키지 설명에 따르면, 모든 sub property를 jest.fn()으로 모킹한 mock Object를 생성해 준다고 합니다!

재밌게도 패키지 설명에서 createMock 사용의 예시로, 이번에 사용할 ExecutionContext의 모킹을 소개해 주고 있는데,

저도 그 설명을 따라서 createMock을 사용해 ExecutionContext를 모킹하여 사용하였습니다 :)


모킹한 ExecutionContext#1. [Interceptor 작성] 부분에서 확인하실 수 있듯,
intercept 함수를 테스트 실행할 때 첫 번째 인자로 넣어 줄 예정입니다!

2-2. 테스트 케이스 정의 && handle method 인자로 넣어주기


이제 #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()))
               }
           });
   });
});

ApiInterceptorintercept 함수를 테스트하고자 하는 것이므로,
intercept라는 describe 문을 새로 작성하고, 그 내부에 4가지 에러 반환 경우 해당하는 테스트를 작성하였습니다!

각 테스트의 내부에서, 본격적으로 intercept의 테스트를 위해 interceptor.intercept()를 호출하는데,

첫 번째 인자로는 조금 전에 모킹한 ExecutionContext를, 두 번째 인자로 들어가야 할 CallHandler 부분에는, 모킹한 handle method가 포함된 객체를 넣어 주었습니다.


NestJS 공식 문서 및 본 포스팅의 #1. [Interceptor 작성] 부분에서 확인하실 수 있듯,

  1. NestInterceptor는 두 번째 인자로 handle method를 implement하는 CallHandler 타입을 받고,
  2. Observable 타입의 값을 반환합니다.

따라서, 저도 이에 맞추어
CallHandlerhandle method를 원하는 기능으로 구현하여, 의도된 테스트를 실행해 볼 수 있도록, intercept의 두 번째 인자로 "제가 전제하는 기능을 실행하는 handle method가 포함된 객체"로 CallHandler 타입을 대체해 보는 방식으로 테스트 코드를 작성하였습니다!


그 방식으로 작성된 `intercept`의 두 번째 인자는 모두 동일하게 아래와 같은 형태로 작성하였습니다.
{
                handle:() => {
                    return (throwError(() => new ClientError()))
                }
            }

위의 코드에서 throwError
RxJS 문서에 따르면, subscribe가 호출될 때마다 Error를 생성하는 Observable을 생성한다고 합니다!

NestJS 공식 문서에 따르면 CallHandler의 handle method는 Observable을 반환하므로, 이를 모킹할 때에도 반환값 타입을 동일하게 맞춰 주기 위해 위와 같은 구문으로 throwError를 사용했습니다 :)

P.S RxJSObservable에 대한 자세한 설명은 공식 문서를 참고하시면 좋을 듯합니다!

참고 : RxJS 문서

2-3. subscribe와, expect를 이용한 테스트

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를 활용한 테스트


2-3-1. describe 내부에 mockNextFn, mockCompleteFn 작성

        const mockNextFn = jest.fn();
        const mockCompleteFn = jest.fn();

describe의 바로 아래에 위의 두 줄의 코드를 추가하였습니다.
해당 코드들은 다음 2-3-3. expect를 활용한 테스트 부분에서 사용할 예정입니다!

2-3-2. subscribe 호출과 next, error, complete 작성

P.S. 하나의 예시만을 언급하며 정리하였습니다!

            const result = resultObservable.subscribe({
                next() {
                },
                error(error) {
                },
                complete() {
                }
            });

사실 subscribe라는 개념에 대해서는 저도 잘 모르겠지만...ㅠㅠ
문서의 설명에 따르면, 간단히 "Observable의 실행" 정도로 볼 수 있는 것 같습니다.

  • next()는 실행 과정에서 호출되고
  • error()는 중간에 에러가 발생한 경우 호출되며, 이 경우 complete()는 호출되지 않습니다.
  • complete()는 말 그대로, Observable의 실행이 종료되었을 때 호출되는

느낌인 것으로 이해하였습니다!

2-3-3. 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에서 정의한 mockNextFnmockCompleteFn을 각각
next()complete()의 실행부에 넣어,
expect().toHaveBeenCalledTimes(0)을 통해 next()complete()가 테스트 과정에서 실행되지 않음을 확인하였습니다 :)

3. 완성

위의 과정을 거쳐 완성된 코드는 다음과 같습니다 :)

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

앞으로도 계속 배워 나가고자 합니다 :)

profile
주니어 백엔드 개발자입니다! 조용한 시간에 읽고 쓰는 것을 좋아합니다 :)

0개의 댓글