avatar
개발 브ㄹ로그
Vitest + MSW 로 유연한 단위 / 통합 테스트코드 작성해보기
변경이 잦은 프론트엔드에서 어떻게 건강한 테스트코드를 작성할 수 있을까?
프론트엔드리액트vitestmsw
May 25
·
24 min read

개요

테스트 평소에 어떻게 하세요?!
저는 일일히 손으로 합니다..

일을 하다보면 자동화가 절실해지는 상황이 생기는데
요즘 그러한 상황을 너무 자주 느끼고 있다.🥲

직전에 진행했던 스프린트에서 발생한 버그를 수정하다가 다른 부분에서 버그가 발생한걸 모르고 지나치는 경우등이 존재했는데,
왜 요즘 공고에 테스트 자동화 경험을 요구하는지 너무 이해가 되더라..

⚒️ 테스팅 라이브러리 선택

잘 알려진 단위테스트 라이브러리로는 jest / vitest 가 존재한다.

번들러로 vite 를 적용했을 때 별다른 설정 없이 개발자 경험이 쾌적해서 좋아서 vite 를 기본 번들러로 채택 후 vitest 를 테스팅 라이브러리로 채택해서 진행했다.
추가로 vitest 같은 경우는 ESM 을 지원해서 쾌적하고 빠른 테스트가 가능했다. 💪🏻

각각의 테스팅 라이브러리가 가져다주는 이점이 다르긴 하지만,
기존 환경을 고려하여 채택을 하는것도 나쁘지 않다.

👷🏻‍♂️ Vitest 기본 환경 설정

vitest 는 이름부터 vite + test 인만큼 비트와 호환성이 좋다.
vite 의 설정을 그대로 가져와서 중복 설정 없이 mergeConfig 함수를 사용해 vite 와 vitest 설정을 병합하여 사용이 가능하다.

export default mergeConfig(viteConfig, defineConfig({
  test: {
    testMatch: ['**/*.test.js'],
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.js'],
  },
}));
  • e2e 테스트에서 다른 라이브러리를 사용한다면 testMatch 설정을 통해서 테스트를 분리할 수 있다.

  • 단위테스트나 통합테스트에서 React Testing Library 를 통해서 렌더링이 포함된 테스트 코드를 작성하거나 window 객체를 사용하는 환경이라면 jsdom 을 기본 환경으로 사용해서 테스트코드를 작성하면 된다.😁

module.exports = {
  transform: {
    '^.+\\.[jt]sx?$': ['babel-jest'],
    '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2|svg)$': 'jest-transform-stub',
  },
  moduleDirectories: ['node_modules'],
  moduleNameMapper: {
    '^.+.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2|svg)$': 'jest-transform-stub',
  },
  transformIgnorePatterns: [
    '/node_modules/(?!swiper)',
  ],
  testEnvironment: 'jsdom',
  testTimeout: 10000,
};

참고로 jest 는 기본적으로 CJS 문법을 사용하기 때문에 그에 따른 설정을 적용해줘야 한다.

이에 비해 vitest 는 마치 웹팩에 비해서 별 설정을 해주지 않아도 웹팩보다 좋은 성능을 보여주는 vite 와 비슷하게 보이기도.. 🤔

👷🏻‍♂️ Vitest setup 파일 적용

모든 테스트 혹은 각각의 테스트를 시작하기 이전에 어떠한 동작이 호출되어야 한다면
일일히 각각의 테스트케이스 내부에 해당 코드를 작성하게 되면 중복코드가 되어버린다.

물론 동일하게 작동되어야 하는 동작이지만 따로따로 작성해둔 탓에 모든 코드를 일일히 수정해야 하는 경우가 발생할 수도..😞

beforeEach, beforeAll, afterEach, afterAll 을 통해서 각각의 행동을 정의 후 실행 할 수 있다.

import { vi } from 'vitest';

beforeAll(() => {
  global.window = Object.create(window);
  const { location } = window;
  delete global.window.location;
  global.window.location = { ...location };
  ...
});

afterEach(() => {
  vi.restoreAllMocks();
});

🤔 변경에 유연한 단위 테스트 하는법

아무래도 일일히 모든 함수나 훅을 테스트해서 커버리지를 올리는건 들어가는 리소스 대비 큰 효과를 얻지 못하는 것 같다.

하지만 개발을 하다보면 정말 많은 곳에서 사용되는 공통 함수나 커스텀훅이 존재할거고,
라우팅을 자주 할 일이 생길때 훅을 선언해서 모듈화를 통해 공통 동작을 정의할 때가 있을 수도 있다.

예를 들면 react-router-dom 의 navigate 나 history 를 사용한 커스텀 훅을 테스트하거나 하는 경우가 존재한다.

글쓴이가 정의해둔 커스텀훅중에 쿼리스트링과 라우팅을 더 간편하게 사용하도록 선언한 커스텀 훅이 있는데
생각보다 쿼리파라미터를 관리하는 방법들이 다양하고 애매해서 리팩토링을 하거나 기능을 추가할 상황들이 자주 발생했다.

단위 테스트를 정의하기 딱 좋은 상황이 아닌가..? 🤩

일단 다른 코드들과 마찬가지로 랩핑을 통해서 변경에 유연한 테스팅 함수를 작성해주자.

const routerWrapper = (search) => ({ children }) => (
  <MemoryRouter initialEntries={[`/?${search}`]}>{children}</MemoryRouter>
);

/**
 * renderHookWithRouter - 쿼리파라미터, 라우팅 등을 테스팅하기위한 렌더 함수
 * @param hook
 * @param search - 초기 쿼리스트링
 */
export const renderHookWithRouter = (hook, search) => {
  const wrapper = routerWrapper(search);
  
  return renderHook(() => {
    const location = useLocation();
    const history = useHistory();
    const hookResult = hook();
    return { ...hookResult, location, history };
  }, { wrapper });
};

이런식으로 랩핑해서 사용하면 useHistory 를 더이상 지원하지 않는 상황이나 라우팅 테스트를 위한 라우터 프로퍼티가 변경된 상황에서도 수정이 쉬워진다.

import { act } from '@testing-library/react-hooks';

describe('useRouteWithQueryString', () => {
  🙅🏻‍♂️ 과정에 의존하는 테스트코드는 작성하지 않는다.
  it('1. handleRoute 를 실행하면 키와 밸류를 포함하여 라우팅한다.', () => {
    const { result } = renderHookWithRouter(() => useRouteWithQuery({
    queryKey: { key: 'new', value: 'value' } }),
    'type=discount&subtype=discount');
    
    act(() => {
      result.current.handleRoute();
    });

    expect(result.current.location.search).toBe('?new=value');
  });

  🙆🏻 가정과 결과에만 집중한 코드를 작성하자.
  it('1. handleRoute 를 실행하면 키와 밸류를 포함하여 라우팅한다.', () => {
    const { result } = renderHookWithRouter(() => useRouteWithQuery(createQueryEntry['new','value']),
    'type=discount&subtype=discount');
    
    act(() => {
      result.current.handleRoute();
    });

    expect(result.current.location.search).toBe('?new=value');
  });

});

🙋🏻‍♂️ 메소드 설명

  • describe 를 통해 해당 테스트케이스의 서브젝트를 명시해준다.

  • it 이나 test 를 통해 해당 테스트 케이스의 동작을 명시해준다.

  • act 로 특정 상태 업데이트가 동기적으로 완료되도록 보장하고, 이를 통해 테스트에서 예상한 대로 컴포넌트가 렌더링되는지 테스팅을 진행할 수 있다.

🙋🏻‍♂️테스트 범위

  • 만약 첫번째처럼 코드를 작성하면 테스트를 수행하는 커스텀 훅의 매개인자의 타입이 변경될 경우에 모든 테스트코드를 변경해줘야한다.

    • 아래처럼 팩토리 메소드를 선언하여 하나의 주제를 갖는 테스트에서 큰 변경점이 일어난다면 최상위에서 테스트에 필요한 매개인자, 반환값을 정의해줄 수 있도록 하자.

  • A 라는 값이 들어가면 B라는 결과가 나온다는 순수함수 정의에 기반하여 작성한다면 건강한 테스트코드를 작성할 수 있다.

이렇게 테스트 코드를 작성한다면 리팩토링으로 매개인자가 변경되거나 하는 상황에서도 단순히 하나의 코드를 수정하여 유연하게 테스트가 가능해진다.🤔

🤔변경에 유연한 통합 테스트 하는법

보통 테스트 코드를 작성할거란 확신을 갖고 프로젝트를 진행하지 않는 이상
보통은 테스트를 실행하기 까다로운 환경에서 테스트코드를 붙일 수 밖에 없는 환경이 될 수 밖에 없다.
테스트 코드를 더 간단하게 작성하기 위해서 프로젝트 전체 구조를 변경하거나 하는건 너무 큰 비용이 소모된다.. 흠 🤔

따라서 여러 상황을 통합해서 생각해보면 서버 데이터에 의존하는 컴포넌트를 테스트하기 위해선 API모킹을 통한 통합 테스팅이 가장 깔끔한 방법이 아닐까 생각했다.

이미 특정 로직과 강결합된 컴포넌트를 컨테이너 -> 프레젠터 패턴으로 변경하거나 의존성 주입 패턴으로 변경할 순 없으니까.. 🥲

일단 기본적으로 특정 store 를 사용하여 provider 를 통한 주입이 필요하거나
경로에 따라서 출력되는 컨테이너가 달라야하는 환경에서 테스트가 진행되기 때문에
마찬가지로 변경에 유연할 수 있도록 렌더 함수를 따로 선언해주자.

export function renderWithRouterAndProvider(
  ui, url, reduxStore,
  {
    ...renderOptions
  } = {},
) {

  function Wrapper({ children }) {
    return (
          <Provider store={reduxStore}>
            <MemoryRouter initialEntries={[url]}>
              {children}
            </MemoryRouter>
          </Provider>
    );
  }

  return { reduxStore, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}

이렇게 따로 선언해두면 테스트별로 각각 다른 store 를 사용해서 병렬로 진행되는 테스트에서 독립된 스토어로 테스트가 가능해진다.

beforeAll(() => {
  server.listen({ onUnhandledRequest: 'bypass' });
});

afterEach(() => {
  server.resetHandlers();
});

afterAll(() => {
  server.close();
});

그리고 mocking 을 위해 setup 파일에 msw 관련 설정도 추가해주자.

MSW 설정

여담으로 jest 를 사용하지 않은 이유중에 하나가
jest 는 msw 1버전까지 지원해서 최신 버전을 사용하지 못한다.
번들러를 교체한 이유중에 의존성 패키지의 레거시 버전으로 인한 생산성 감소 문제도 존재했고..🙂
이러한 상황에서 아무래도 커뮤니티가 크고 지원이 잘 되는 라이브러리를 선택하는것도 큰 도움이 될 수도..? 🤔

API가 정상적으로 호출되거나 실패하는 케이스까지 테스트할 땐 API를 모킹해서 테스트를 진행하는 방법도 존재한다.
(일단 통합 테스트 정의가 외부 라이브러리까지의 호환성 테스트 등을 포함한 단위를 통합테스트로 분류하기 때문에..)

⚒️ MSW는 기본적으로 서비스워커를 통해서 인터셉터처럼 HTTP 요청을 가로채서 원하는 응답을 반환하도록 할 수 있고, 선언적으로 API를 사용할 수 있기 때문에 세세한 설정 없이 간편하게 모킹을 할 수 있다.

일단 서비스별로 서버에서 반환하는 응답 객체가 다른 경우가 존재할텐데 그러한 상황을 해결해줄 팩토리 메소드를 선언해주자.

const createMockResponse = (data, status = '200') => ({
  data,
  status,
});

그리고 이를 알차게 호출해줄 mocking 함수를 선언해주자.

import { http, HttpResponse } from 'msw';

/**
 * createMockHandler - msw 용 핸들러 생성 함수
 * @param url - 요청 URL
 * @param data - 응답 데이터
 * @param status - 응답 상태 코드
 *
 * @example
 * server.use(createMockHandler({...}));
 */
export const createMockGetHandler = ({ url, data, status = 200 }) => {
  const statusCode = status.toString();
  
  return http.get(url, () => HttpResponse.json(createMockResponse(data, statusCode)));
};

이런식으로 랩핑을 해서 사용해주면 패키지 자체 API가 변경되어도 해당 함수 하나만 변경해서 사용하면 큰 변경점 없이 모킹을 할 수 있다.

추가로 테스트를 진행할 프로젝트에서 초기 진입시 로그인 이후 불러오는 API가 정해져있다면
단순히 axios 로 fetch api를 정의해서 호출하듯이 모킹을 해두고 기본 핸들러로 지정해주면 된다.

여기에 추가로 각각의 실패케이스까지 대응하기 위해서 매개인자로 응답객체나 상태코드를 넘겨줄 수 있도록 해주면 더 선언적으로 사용이 가능하다 👀

// account.js
const mockFetchAccountInfo = (data, code) => createMockHandler({ url: API_WITH_ACCOUNT_NAME, data: data || MOCK_FETCH_ACCOUNT_INFO_DATA, status: code || 200 });

const mockFetchAccountMessages = (data, code) => createMockHandler({ url: `/accounts/${TEST_ACCOUNT_NAME}/messages`, data: data || MOCK_FETCH_ACCOUNT_MESSAGES_DATA, status: code || 200 });

//handlers.js
const handlers = [
  ...mockInitAccountHandlers,
];

export default handlers;

//server.js
import handlers from './handlers';

const server = setupServer(...handlers);

export default server;

이렇게 사용하면 혹시 모를 특정 API가 deprecated 되거나 사용법이 달라지는 경우에 더욱 유연하게 대처가 가능해진다. 💪🏻

API 호출을 포함한 컴포넌트 렌더링 테스트

이전에 구현했던 컴포넌트중에, 서버 데이터 중에 특정 프로퍼티에 따라서 문구가 다르게 출력되는 컴포넌트가 존재했었는데,
그 컴포넌트를 테스트코드 없이 테스트를 진행하면서 일일히 백엔드 개발자분께 데이터 변경을 요청해서 테스트를 진행했던 적이 있다;;😅

이러한 상황이 딱 통합테스트를 진행하기 좋은 상황 아닐까..? 한번 작성해보자 🙇🏻‍♂️

describe('SendOut.js', () => {
let store;

beforeEach(() => {
  store = createMockStore();
});

🙅🏻‍♂️ 변경 될 가능성이 존재하는 테스트코드는 최대한 지양한다.
it('1. 첫 화면에 진입하면 로그인한 유저의 닉네임이 출력된다.', async () => {
  renderWithRouterAndProvider(<Routes />, ROUTE_PATHS.MAIN, store);
  expect(screen.queryByText('로그인')).not.toBeInTheDocument();
  expect(await screen.findByText('테스트')).toBeInTheDocument();
 }
);

🙆🏻 너무 변경되지 않을 요소들만 상수로 선언하여 관리하는 것도 방법이다.
it('2. 정보를 가져오는 API 호출에 실패하면 에러 컴포넌트가 출력된다.', async () => {
  server.use(mockAccountHandlers.mockFetchAccountInfo({}, 401));
  renderWithRouterAndProvider(<Routes />, ROUTE_PATHS.MAIN, store);
  await screen.findByText(TEST_CONSTS_ERROR);}
 );
});

🙋🏻‍♂️ 메소드 설명

  • getBy~ 는 보통 동기적으로 해당 요소를 가져올 수 있는 환경일 때 사용할 수 있다.

    • getBy~('로그인') 이런 식으로 특정 문구가 정확히 일치해야지만 가져올수도 있고,

    • getBy~(/로그인/) 이런 식으로 해당 문구를 포함한 요소를 가져올 수도 있다. (하지만 여러 요소가 존재한다면 테스트는 실패한다😅)

  • findBy~ 는 비동기 작업을 통해서 해당 요소가 출력되어야 할 때 사용할 수 있다.

  • queryBy~ 를 사용해서 해당 요소가 존재하지 않을 때 테스트를 중단하지 않고 null을 갖도록 해서 유연하게 핸들링할수도 있다.

🙋🏻‍♂️테스트 범위

  • 통합테스트는 UI와 API호출 및 외부 패키지의 동작까지를 포함하여 테스트가 수행되기 때문에 오래 사용할 수 있는 코드를 작성하려면 너무 당연한 키워드와 문구, 반환값 등은 상수로 관리해줄 필요가 있다.

    • 문구에 의존하지 않는 테스트를 진행하려면 data-testid 어트리뷰트를 사용해주는것도 한 방법이지만 테스트를 위해서 기존 요소에 속성을 하나 더 추가하는것도 리소스 낭비가 아닐까? 하는 생각이 있기도.. 👀

  • 마찬가지로 너무 잦은 변경을 갖는 요소들을 테스트에 포함시키는 것도 비효율적인 테스트가 아닐까? 생각한다.

특정 페이지에 진입 > 페이지를 변경하는 특정 트리거를 실행하면서 API를 호출 > 호출된 API의 데이터에 따라서 다른 화면을 출력
이러한 흐름이라면, 데이터에 따라 차트를 출력하거나 분석 기능을 제공하는 상황에서 유연한 테스트가 가능할것이다.

👀 라우팅을 포함하여 테스트 진행하기

  function Wrapper({ children }) {
    return (
          <Provider store={reduxStore}>
            <MemoryRouter initialEntries={[url]}>
              {children}
            </MemoryRouter>
          </Provider>
    );
  }

이 wrapper 를 보면 알겠지만 기본적으로 테스트를 위한 라우터로는 MemoryRouter 를 사용할 수 있는데,

🙅🏻‍♂️ Router 내부에 들어가는 하위 컴포넌트에 BrowserRouter 등을 포함해서 전달해주면 안된다. (정상적으로 경로를 인식하지 못한다. history 를 두개를 갖고 있는것과 마찬가지가 된다.)

따라서 v5 라면 Switch 까지만 랩핑된 컴포넌트를 매개 인자로 전달해주거나 v6 라면 Routes 로 랩핑된 컴포넌트를 전달해주면 된다.

  • BrowserRouter: 이 라우터는 HTML5의 history API (pushState, replaceState, popstate 이벤트)를 사용하여 UI를 현재 URL과 동기화합니다. 이 라우터는 동적인 웹 애플리케이션에서 주로 사용되며, 서버에서 모든 요청을 처리할 수 있도록 설정되어야 합니다.

  • MemoryRouter: 이 라우터는 메모리에 history를 유지합니다. 이는 주로 테스트나 non-browser 환경 (예: React Native)에서 사용됩니다. MemoryRouter는 URL을 변경하지 않기 때문에, 사용자가 브라우저의 뒤로 가기, 앞으로 가기 버튼을 클릭하거나 URL을 직접 변경하더라도 애플리케이션의 UI가 변경되지 않습니다. (하지만 history 에 접근해서 url이 변경되면 리렌더링됨)

마치며 🙂

사실 테스트의 스콥을 더 넓게 가져가서 작성하기 귀찮은 통합테스트에 해당되는 개념들을 E2E 테스트로 가져갈 수도 있다.
하지만 그렇게 진행한다면 속도가 느려지거나, 잦은 API호출로 인한 서버에 부하가 일어날 수도 있다는 생각이 들기도 했고..

  • 통합 테스트의 스콥을 재정의한다.

  • E2E 테스트의 각각의 케이스에서 최적의 효율을 뽑을 수 있는 범위로 테스트코드를 작성한다.

  • 모킹에 의존하지 않는 프로젝트 구조를 설계한다.

여러 방법이 있지만.. 사실 QA팀이 존재하는 회사라면 테스트코드 작성 자체가 리소스 낭비일수도 있고.
사실 테스트 코드를 작성해보기 전에 가졌던 환상에 비해 잃는게 더 많은것 같기도 하다.

특히 FE는 어딜가던 변경사항이 많기 때문에 재사용이 가능한 범위가 한정적인 경우도 존재한다고 생각했고..
최적의 방법이 뭐가 있을까? 🤔 이번 작업으로 인해 생각보다 고민을 많이 해보게 되었다.







스타트업 멋쟁이토마토 프엔 개발자 브린스입니다