JS, TS

testing-library, vitest로 리액트 Unit test 작성하며 리팩토링하기

Mitchell 2023. 10. 20. 01:25

늘어가는 기능과 화면에 비례해서 버그 또한 늘어나기 마련입니다. 그런데 그 버그를 수정하면 또 다른 곳이 터져버리는 불상사가 눈앞에 펼쳐지게 됩니다. 사람은 실수를 하기 마련이고 그것을 줄이기 위해서는 더 많은 시간을 쏟아야 합니다. 이러한 이유 때문에 테스트코드는 결국 필요하게 됩니다.

 

따라서 이번 포스팅에서는 Frontend 영역에서의 Unit test에 대해서 작성하고, 테스트를 작성하면서 기존 코드의 문제점을 분석하고 리팩토링 하겠습니다. 예시로는 LoginForm 컴포넌트에 대해서 작성합니다.


들어가기 전에

선수지식에 대해서는 반드시 알고 가셔야 이해하실 수 있습니다. 이 글에서 사용되는 기술들에 대해서 전부 알 필요는 없습니다만 참고용으로 적어두었습니다.

 

✅ 선수지식

  • React,
  • Vitest(또는 Jest), React Testing Library

🟡 이 글에서 사용되는 기술들

  • Vite
  • React, React Router, React Query
  • Vitest, React Testing Library

 

테스팅을 위한 기본셋팅

1. 테스트를 위한 패키지 다운로드하기

npm install -D vitest
npm install -D jsdom
npm install -D @testing-library/react
npm install -D @testing-library/jest-dom

 

2. 폴더 및 파일구조

// 테스트 폴더
__test__
    - mocks.ts
    - setup.ts
    - utils.tsx
    
// 컴포넌트 폴더
components
    - login
    	- LoginForm.tsx
        - LoginForm.test.tsx

"__test__" 폴더 안에는 테스트를 위한 mocks, utils, setup 등을 가지고 있으며 실제 테스트를 하는 코드들은 "components" 내부에 테스트를 해야하는 곳과 가깝게 배치합니다.

 

3. setup.ts 파일 작성하기

import '@testing-library/jest-dom'

setup파일에 import 해두면 모든 테스트 파일안에서 Dom 테스트를 위한 추가적인 jest의 matcher를 사용할 수 있습니다. (여기에서는 vitest)

 

4. vite.config.ts에 test config 추가하기

/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

export default defineConfig({
    plugins: [react()],
    test: {
        setupFiles: ['./src/__tests__/setup.ts'],
        globals: true,
        environment: 'jsdom',
    },
})

테스트 케이스 구성하기

본격적으로 테스트를 시작하면서 바로 머리속에 생각이 하나 떠올랐을 겁니다. "무엇을 어디까지 테스트를 해야하는걸까?"

정답이라는 것은 존재하지 않지만, React Testing Library 공식문서의 가이드 원칙에 따르면 생각의 범위를 좁혀갈 수 있습니다.

테스트가 소프트웨어가 사용되는 방식과 닮을 수록, 테스트는 더 많은 확신을 준다.

풀어서 말하면, 사용자가 앱에서 보고 만지고 하는 행위들 자체를 테스트 해야한다는 것입니다.

 

LoginForm.tsx 파일이 있는 같은 폴더에 LoginForm.test.tsx 파일을 생성하겠습니다. 그리고 위 기본 원칙을 고려하여 LoginForm의 테스트 케이스를 구성해보겠습니다. 

describe('<LoginForm/>', () => {
    it('아이디인풋, 비밀번호인풋, 로그인버튼이 보여야한다.', () => {

    })

    it('인풋에 값이 입력되면 화면에 보여야한다.', () => {

    })

    it('인풋 중 값이 하나라도 없으면 버튼은 클릭 할 수 없다.', () => {

    })

    it('버튼을 클릭하면 로그인을 시도한다.', () => {

    })

    it('로그인을 시도하는 중에 버튼은 프로그레스 바가 나타난다', () => {

    })
})

* 마지막 테스트 케이스에서는 내부적인 기획에 따라 버튼에 프로그레스가 나타나야 하는 상황입니다. 일반적으로는 다르게 로딩이 처리 될 수 있습니다.


테스트 코드 작성하기

각 테스트는 LoginForm을 렌더링한 후 각 요소가 있는지, 사용자 이벤트에 반응을 하는지 등을 테스트 하게 될 것입니다.

 

1. LoginForm 렌더링하고 자주 사용하는 함수와 요소를 리턴하는 공통함수를 만듭니다.

 

 

2. 각 케이스별로 테스트 코드를 작성합니다.

 


3. 작성된 테스트를 실행한다.

실패한 테스트 케이스

우리는 LoginForm.tsx의 코드가 어떻게 생겨먹었는지 전혀 알지 못하는채로 테스트케이스를 작성했습니다. 결과는 당연히 FAIL이 날 수밖에 없겠죠. 이제부터 테스트를 PASS 할 수 있도록 리팩토링을 진행하겠습니다.


테스트를 PASS 하도록 LoginForm 수정하기

위 사진을 보면 "useNavigate() may be used only..."라는 에러가 발생하며 테스트가 실패하였습니다. 해당 에러는 React Router와 관련되어 있으며 LoginForm 렌더링 자체에서 실패했음을 알 수 있습니다. 이제 작성되어 있던 LoginForm.tsx 코드를 먼저 살펴보겠습니다.

 

에러가 발생했던 useNavigate는 login 시도의 성공 후 페이지를 "/"로 이동시키기 위해 사용된 React-Router의 Hook 입니다. 그런데 LoginForm에 한정에서 생각했을 때에는 로그인 성공 후 페이지 이동이나, 실패 후 에러 토스트메시지를 띄우는 행위들은 LoginForm의 역할 이상의 것이라고 볼 수 있습니다. 물론 테스트케이스에서도 그런 행위에 대한 테스트는 구성하지 않았었습니다.

 

따라서 LoginForm 안에서 로그인 실행 이후에 벌어지는 일들에 대해서는 관심사를 분리하도록 리팩토링 하겠습니다. 먼저 성공과 실패에 대한 useMutation의 side effect를 Props로 넘겨받는 방법으로 고치겠습니다.

 

위 코드로 다시 한번 테스트를 실행하겠습니다.

이번에는 "No QueryClient set, ..."이라는 React-Query 관련 에러가 발생해서 렌더링에 실패합니다. testing-library의 render 함수를 QueryClientProvider로 wrapping 하지 않아서 발생하는 에러입니다. 그러나 다음의 이유로 저희는 useMutation을 LoginForm 내부에서 분리시키는 것으로 추가 리팩토링을 하겠습니다. 

  1. useMutation의 mutationFn인 login은 서버와 통신하는 API 함수로 해당 기능의 테스트는 LoginForm의 관심사가 아닙니다.
  2. useLogin이라는 Custom Hook으로 분리하면 login 기능의 재사용성을 높일 수 있습니다.

최종적으로 useMutation으로 받던 isLoading과 mutate(login API 함수)를 Props로 전달받도록 리팩토링 하겠습니다.

 

리팩토링을 완료 후에 테스트를 다시 실행해보면 드디어 성공하는 결과를 얻을 수 있습니다.


TDD로 시작하지는 않았지만 TDD와 비슷한 방식으로 테스트 코드를 작성해보았습니다. 기존에 존재했던 코드는 무시한 상태로 테스트를 먼저 작성하였고 그 과정에서 PASS 하는 테스트결과를 얻기위해 관심사 분리, 재사용성에 대해 고려하며 자연스럽게 리팩토링이 되었습니다.

 

다음 포스팅에서는 LoginForm을 리팩토링 하면서 분리되었던 로직들에 대한 통합테스트를 작성할 예정입니다.

 

useLogin Custom Hook에서는 msw(mock-service-worker)로 로그인 API 실행 모킹 후 성공, 실패, 지연에 대한 테스트를 해볼 것이고, LoginPage에서는 유저액션에 따라 페이지 전체의 흐름이 어떻게 되는지 테스트하겠습니다.