JWT Mock 관심사분리..

minseok baek·2024년 8월 5일

프로젝트

목록 보기
9/20

JWT토큰 관리의 테스트

프로젝트 초기에 인증와 인가에 대해 문제로 많은 고민을 쏟아 특히나 토큰 관리에 대한 애착이 있었다. 신경쓴 만큼 이부분 만큼은 꼭 테스트코드를 작성하고 싶다는 생각이 들었다.

문제

axios.interceptor를 활용하고 있어 해당 부분에 대한 테스트 코드를 가장 먼저 고려해야 하는 상황이였지만, 이상하게도axios.interceptor자체는 테스트 코드를 작성안하는게 일반적인지 국내에는 자료가 없었다. 스택 오버 플로우를 검토해본 결과 다행히도 바로 적용할 수 있는 코드는 아니였지만, 최근에 가장 머리아프게 만들었던 mock방식에 대한 단서는 존재하였다.

물론 해당 방식으로 적용해봤지만 바로 mock이 처리되진 않았다. 하지만 핵심은 interceptors 자체를 목킹을 해야한다는 소중한 단서를 얻었다.

본격적인 테스트코드 작성을 위해 해당 로직에 대한 시나리오에 대해 생각해봤는데 가장 근본적인 문제를 발견할 수 있었다.

복잡한 관심사

토큰을 처리하는 로직을 함수별로 잘 분류하였지만, 모든 내용이 axios.intercepotr에 관련되어 있었고, 결국 토큰처리라는 공통주제가 있었다. 따라서 코드를 모듈화하여 분할하는 것이 오히려 구조 파악에 풀편할 것이라고 생각이들어 분류하지 않았다. 하지만 이로 인해 테스트 코드 시나리오가 복잡해지고, mock처리도 불편하다는것을 깨달았다.

관심사 분리 및 개선

  1. axios 생성 및 오류 판별
  2. jwt 오류 검증
  3. 그외 오류 판별
  4. request 처리

이러한 분리를 통해, 테스트 코드의 복잡성을 줄이고, 각 관심사에 대해 독립적으로 테스트를 작성할 수 있게 되었다.

Mock 처리

다시 본론으로 돌아와 가장 머리 아팠던 부분이 axios.intercepotrs를 목킹하는 것이였다. 스택 오버플로우의 경우 interceptors를 바로 목킹하여 결과를 반환해주었다. 하지만 해당 방식으로 목킹을 적용하면 엄청난 타입오류와 목킹에 관련된 오류가 뿜어졌다.

이전에 목킹을 하면서 상당히 머리아픔을 느꼈는데 가장 단순하면서 확실한 방법은 해당 객체를 직접 콘솔로그 찍으면 하나하나 추적하면서 그 과정을 반영하여 결과를 반영하는 것이였다.

정말 머리 아팠던 오류이지만 알고보니 원인은 너무 단순했다.
axios를 인터셉터 하기 이전에 create()가 선행이 되어야하는 것이였다. 즉 create()를 목킹 해줘야 인터셉트도 목킹의 결과를 반영할 수 있다는 것이였다.

모킹 코드

jest.mock('axios', () => ({
create: () => ({
interceptors: {
request: { eject: jest.fn(), use: jest.fn() },
response: { eject: jest.fn(), use: jest.fn() },
},
request: jest.fn(),
get: jest.fn(),
}),
}));

import Cookies from 'js-cookie';

import { ERROR_MESSAGES } from '@/src/shared/const';

import { API } from './client';
import { tokenReissue } from './tokenReissue';

jest.mock('js-cookie', () => ({
  get: jest.fn(),
  set: jest.fn(),
}));

jest.mock('./tokenReissue', () => ({
  tokenReissue: jest.fn(),
}));

jest.mock('axios', () => ({
  create: () => ({
    interceptors: {
      request: { eject: jest.fn(), use: jest.fn() },
      response: { eject: jest.fn(), use: jest.fn() },
    },
    request: jest.fn(),
    get: jest.fn(),
  }),
}));

const mockedTokenReissue = tokenReissue as jest.Mock;

describe('API Interceptors', () => {
  const context = describe;

  context('요청 인터셉트', () => {
    it('액세스 토큰이 쿠키에 있으면 Authorization 헤더를 추가해야 합니다', () => {
      const token = { key: 'test_token' };
      (Cookies.get as jest.Mock).mockReturnValue(token);

      const config = { headers: {} };

      const requestInterceptor = API.interceptors.request.use as jest.Mock;
      const requestHandler = requestInterceptor.mock.calls[0][0];
      const result = requestHandler(config);

      expect(result.headers.Authorization).toBe(token);
    });

    it('액세스 토큰이 쿠키가 없으면 Authorization 헤더를 추가하지 않는다.', () => {
      (Cookies.get as jest.Mock).mockReturnValueOnce(undefined);

      const config = { headers: {} };

      const requestInterceptor = API.interceptors.request.use as jest.Mock;
      const requestHandler = requestInterceptor.mock.calls[0][0];
      const result = requestHandler(config);

      expect(result.headers).not.toHaveProperty('Authorization');
    });
  });

  context('응답 인터셉트', () => {
    it('401 에러를 처리하고 토큰 재발급을 시행한다.', async () => {
      const error = {
        response: {
          status: 401,
          data: { message: ERROR_MESSAGES.JWT_EXPIRED },
          config: {},
        },
        headers: {},
      };

      mockedTokenReissue.mockResolvedValue({ data: { access: 'new_token' } });
      (Cookies.get as jest.Mock).mockReturnValueOnce({ key: 'new_token' });

      const requestInterceptor = API.interceptors.response.use as jest.Mock;
      const responseHandler = requestInterceptor.mock.calls[0][1];
      const result = responseHandler(error);
      await expect(result).rejects.toEqual(error);
      expect(mockedTokenReissue).toHaveBeenCalled();
    });
  });
});

profile
성장은 점진적 과부하, 매주 회고를 목표로 시작했지만 그때 그때 컨셉이 달라요. 시행착오를 통해 저만의 방식을 찾아가는중입니다.

0개의 댓글