리액트 테스팅 - React Testing Library

With. Jest

·

17 min read

📎 자바스크립트 테스트의 기초


기본적으로 자바스크립트에서 테스트 코드는 어떻게 작성하는지에 대해 알아보자. 만약 인수 두 개의 합을 더하는 함수를 만들었다고 가정해 보자.

function sum(a,b) {
  return a + b 
}

이 함수에 대한 테스트 코드를 작성한다면 어떻게 작성해야 할까? 테스트 코드를 작성하기에 앞서 테스트 코드가 무엇인지 상기하자. 테스트 코드란 내가 작성한 코드가 내가 코드를 작성했던 당시의 의도와 목적에 맞는지 확인하는 코드를 의미한다. 그런 의미에서 sum 함수에 대해서는 다음과 같은 테스트 코드를 작성해 볼 수 있을 것이다.

// 테스트 1
// 함수를 실행했을 때의 실제 결과
let actual = sum(1, 2)
// 함수를 실행했을 때 기대하는 결과
let expected = 3

if(expected !== actual) {
 throw new Error(`${expected} is not equal to ${actual}`)
}

// 테스트 2
actual = sum(2, 2)
expected = 4

if(expected !== actual) {
 throw new Error(`${expected} is not equal to ${actual}`)
}

테스트 코드를 작성하는 방식, 테스트 코드에 사용되는 인수 등 코드의 세세한 부분에는 조금씩 차이가 있겠지만 기본적인 테스트 코드를 작성하는 방식은 다음과 같은 과정을 거친다는 점에서 비슷할 것이다.

  1. 테스트할 함수나 모듈을 선정한다.

  2. 함수나 모듈이 반환하길 기대하는 값을 적는다.

  3. 함수나 모듈의 실제 반환 값을 적는다.

  4. 3번의 기대에 따라 2번의 결과가 일치하는지 확인한다.

  5. 기대하는 결과를 반환한다면 테스트는 성공이며, 만약 기대와 다른 결과를 반환하면 에러를 던진다.

이를 위해 가장 먼저 필요한 것이 “작성한 코드가 예상대로 작동한다면 성공했다는 메시지가 출력되고, 실패하면 에러를 던진다” 라는 작동을 대신해주는 라이브러리다.

이처럼 테스트 결과를 확인할 수 있도록 도와주는 라이브러리를 어설션(assertion) 라이브러리라고 한다. 이러한 어설션 라이브러리에는 Node.js가 제공하는 assert 외에도 should.js, expect.js, chai 등 다양하다.

이러한 테스트의 기승전결을 완성해 주는 것이 바로 테스팅 프레임워크다. 테스팅 프레임워크들은 어설션을 기반으로 테스트를 수행하며, 여기에 추가로 테스트 코드 작성자에게 도움이 될 만한 정보를 알려주는 역할도 함께 수행한다. 자바스크립트에서 유명한 테스팅 프레임워크로는 Jest, Mocha, Karma 등이 있다. 그리고 리액트 진영에서는 리액트와 마찬가지로 메타에서 작성한 오픈소스 라이브러리인 Jest가 널리 쓰이고 있다. Jest의 경우 자체적으로 제작한 expect 패키지를 사용해 어설션을 수행한다.

이제 앞의 테스트 코드를 Jest로 완전히 새롭게 작성해보자. 이번에는 테스트 코드와 실제 코드를 별도로 분리했다

function sum(a, b) {
  return a + b
}

module.exports = {
  sum,
}

그리고 이 코드에 대한 테스트 코드를 다음과 같이 작성할 수 있다. 아래 함수는 의도적으로 틀리도록 만든 것이다.

const { sum } = require('./math')

test('두 인수가 덧셈이 되어야 한다.', () => {
  expect(sum(1, 2)).toBe(3)
})

test('두 인수가 덧셈이 되어야 한다.', () => {
  expect(sum(2, 2)).toBe(3) // 에러
})

🚀 그리고 테스트 코드를 실행하면 다음과 같은 결과를 얻을 수 있다.

이렇게 어설션 라이브러리를 내장한 테스트 프레임워크를 사용하면 테스트 코드를 작성하는 것뿐만 아니라 테스트에 대한 결과와 관련 정보를 일목요연하게 확인할 수 있다 💩

📎 리액트 컴포넌트 테스트 코드 작성하기


자바스크립트에서 이뤄지는 테스트 코드에 대해 어느 정도 살펴봤으니 이제 본격적으로 리액트 컴포넌트를 테스트하는 방법을 알아보자. 기본적으로 리액트에서 컴포넌트 테스트는 다음과 같은 순서로 진행된다.

  1. 컴포넌트를 렌더링한다.

  2. 필요하다면 컴포넌트에서 특정 액션을 수행한다.

  3. 컴포넌트 렌더링과 2번의 액션을 통해 기대하는 결과와 실제 결과를 비교한다.

이제 테스트 코드를 작성해 보자.

프로젝트 생성

테스트 코드를 작성하기에 앞서, 먼저 create-react-app으로 예제 프로젝트를 생성한다. create-react-app에는 이미 react-testing-library가 포함돼 있으므로 별도로 설치할 필요가 없다.

npx create-react-app react-test —template typescript

이렇게 생성된 프로젝트를 살펴보면 App.test.tsx 파일이 생성돼 있는 것을 확인할 수 있다.

import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});

App.test.tsx가 테스트하는 App 컴포넌트는 다음과 같이 구성돼 있다.

import React from 'react';
import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="<https://reactjs.org>"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

코드 내용을 종합하면 App.test.tsx가 App.tsx에서 테스트하는 내용은 다음과 같이 요약할 수 있다.

  1. <App />을 렌더링한다.

  2. 렌더링하는 컴포넌트 내부에서 “learn react” 라는 문자열을 가진 DOM 요소를 찾는다.

  3. expect(linkElement).toBeInTheDocument() 라는 어설션을 활용해 2번에서 찾은 요소가 document 내부에 있는지 확인한다.

위와 같이 리액트 컴포넌트에서 테스트하는 일반적인 시나리오는 특정한 무언가를 지닌 HTML 요소가 있는지 여부다. 이를 확인하는 방법은 크게 3가지가 있다.

  • getBy… : 인수의 조건에 맞는 요소를 반환하며, 해당 요소가 없거나 두 개 이상이면 에러를 발생시킨다. 복수 개를 찾고 싶다면 getAllBy… 를 사용하면 된다.

  • findBy… : getBy… 와 유사하나 한 가지 큰 차이점은 Promise를 반환한다는 것이다. 즉, 비동기로 찾는다는 것을 의미하며, 기본값으로 1000ms의 타임아웃을 가지고 있다. 마찬가지로 두 개 이상이면 에러를 발생시키지만 복수 개를 찾고 싶다면 findAllBy… 를 사용하면 된다. 이러한 특징 때문에 findBy는 비동기 액션 이후에 요소를 찾을 때 사용한다.

  • queryBy… : 인수의 조건에 맞는 요소를 반환하는 대신, 찾지 못한다면 null을 반환한다. getBy…와 findBy…는 찾지 못하면 에러를 발생시키기 때문에 찾지 못해도 에러를 발생시키지 않고 싶다면 queryBy… 를 사용하면 된다. 마찬가지로 복수 개를 찾았을 때는 에러를 발생시키며, 복수 개를 찾고 싶다면 queryAllBy…를 사용하면 된다.

그리고 컴포넌트를 테스트하는 파일은 App.tsx, App.test.tsx의 경우와 마찬가지로 같은 디렉터리상에 위치하는 것이 일반적이다. 이름 규칙인 *.test.{t¦s}jsx만 준수한다면 디렉터리 내부에서 명확하게 구별되고, 대부분의 프레임워크가 이러한 이름으로 된 파일은 번들링에서 제외하므로 유용하게 사용할 수 있다.

테스트를 위해 사용할 수 있는 기본적인 메서드에 대해 알아봤으니 이제 본격적으로 리액트에서 컴포넌트를 테스트하는 방법을 살펴보자.

정적 컴포넌트

정적 컴포넌트, 즉 별도의 상태가 존재하지 않아 항상 같은 결과를 반환하는 컴포넌트를 테스트하는 방법은 크게 어렵지 않다. 테스트를 원하는 컴포넌트를 렌더링한 다음, 테스트를 원하는 요소를 찾아 원하는 테스트를 수행하면 된다. 다음과 같은 컴포넌트가 있다고 가정해 보자.

import { memo } from 'react'

const AnchorTagComponent = memo(function AnchorTagComponent({
  name,
  href,
  targetBlank,
}: {
  name: string
  href: string
  targetBlank?: boolean
}) {
  return (
    <a
      href={href}
      target={targetBlank ? '_blank' : undefined}
      rel="noopener noreferrer"
    >
      {name}
    </a>
  )
})

export default function StaticComponent() {
  return (
    <>
      <h1>Static Component</h1>
      <div>유용한 링크</div>

      <ul data-testid="ul" style={{ listStyleType: 'square' }}>
        <li>
          <AnchorTagComponent
            targetBlank
            name="리액트"
            href="<https://reactjs.org>"
          />
        </li>
        <li>
          <AnchorTagComponent
            targetBlank
            name="네이버"
            href="<https://www.naver.com>"
          />
        </li>
        <li>
          <AnchorTagComponent name="블로그" href="<https://hashnode.com/@seokjin>" />
        </li>
      </ul>
    </>
  )
}

이 컴포넌트에 링크가 제대로 있는지 확인한다면 다음과 같이 테스트 코드를 작성해 볼 수 있다.

import { render, screen } from '@testing-library/react'

import StaticComponent from './index'

beforeEach(() => {
  render(<StaticComponent />)
})

describe('링크 확인', () => {
  it('링크가 3개 존재한다.', () => {
    const ul = screen.getByTestId('ul')
    expect(ul.children.length).toBe(3)
  })

  it('링크 목록의 스타일이 square다.', () => {
    const ul = screen.getByTestId('ul')
    expect(ul).toHaveStyle('list-style-type: square;')
  })
})

describe('리액트 링크 테스트', () => {
  it('리액트 링크가 존재한다.', () => {
    const reactLink = screen.getByText('리액트')
    expect(reactLink).toBeVisible()
  })

  it('리액트 링크가 올바른 주소로 존재한다.', () => {
    const reactLink = screen.getByText('리액트')

    expect(reactLink.tagName).toEqual('A')
    expect(reactLink).toHaveAttribute('href', '<https://reactjs.org>')
  })
})

describe('네이버 링크 테스트', () => {
  it('네이버 링크가 존재한다.', () => {
    const naverLink = screen.getByText('네이버')
    expect(naverLink).toBeVisible()
  })

  it('네이버 링크가 올바른 주소로 존재한다.', () => {
    const naverLink = screen.getByText('네이버')

    expect(naverLink.tagName).toEqual('A')
    expect(naverLink).toHaveAttribute('href', '<https://www.naver.com>')
  })
})

describe('블로그 링크 테스트', () => {
  it('블로그 링크가 존재한다.', () => {
    const blogLink = screen.getByText('블로그')
    expect(blogLink).toBeVisible()
  })

  it('블로그 링크가 올바른 주소로 존재한다.', () => {
    const blogLink = screen.getByText('블로그')

    expect(blogLink.tagName).toEqual('A')
    expect(blogLink).toHaveAttribute('href', '<https://hashnode.com/@seokjin>')
  })

  it('블로그는 같은 창에서 열려야 한다.', () => {
    const blogLink = screen.getByText('블로그')
    expect(blogLink).not.toHaveAttribute('target')
  })
})
  • beforeEach : 각 테스트(it)를 수행하기 전에 실행하는 함수다. 여기서는 각 테스트를 실행하기에 앞서 Static Component를 렌더링한다.

  • describe : 비슷한 속성을 가진 테스트를 하나의 그룹으로 묶는 역할을 한다. 정의에서도 알 수 있듯, 이 describe는 꼭 필요한 메서드는 아니다. 그러나 테스트 코드가 많아지고 관리가 어려워진다면 describe로 묶어서 관리하는 것이 편리하다. describe 내부에 describe를 또 사용할 수 있다.

  • it : test와 완전히 동일하며, test의 축약어(alias)다. it이라는 축약어를 제공하는 이유는 테스트 코드를 좀 더 사람이 읽기 쉽게 하기 위해서다. describe … it (something)과 같은 형태로 작성해 두면 테스트 코드가 한결 더 문어체 같이 표현되어 읽기 쉬워진다.

  • testId : testId는 리액트 테스팅 라이브러리의 예약어로, get 등의 선택자로 어렵거나 곤란한 요소를 선택하기 위해 사용할 수 있다. HTML의 DOM 요소에 testId 데이터셋을 선언해 두면 이후 테스트 시에 getByTestId, findByTestId 등으로 선택할 수 있다. 웹에서 사용하는 querySelector([data-testid=”${yourId}”]) 와 동일한 역할을 한다.

요약하자면 각 테스트를 수행하기 전에 StaticComponent를 렌더링하고, describe로 연관된 테스트를 묶어서 it으로 it 함수 내부에 정의된 테스트를 수행하는 테스트 파일이라고 정의할 수 있다.

동적 컴포넌트

아무런 상태값이 없는 완전히 순수한 무상태(stateless) 컴포넌트는 테스트하기가 매우 간편하다. 하지만 상태값이 있는 컴포넌트, 예를 들어 useState를 사용해 상태값을 관리하는 컴포넌트는 어떨까? 사용자의 액션에 따라 state 값이 변경된다면? 이러한 변경에 따라 컴포넌트가 다르게 렌더링돼야 한다면?

사용자가 useState를 통해 입력을 변경하는 컴포넌트

import { useState } from 'react'

export function InputComponent() {
  const [text, setText] = useState('')

  function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
    const rawValue = event.target.value
    const value = rawValue.replace(/[^A-Za-z0-9]/gi, '')
    setText(value)
  }

  function handleButtonClick() {
    alert(text)
  }

  return (
    <>
      <label htmlFor="input">아이디를 입력하세요.</label>
      <input
        aria-label="input"
        id="input"
        value={text}
        onChange={handleInputChange}
        maxLength={20}
      />
      <button onClick={handleButtonClick} disabled={text.length === 0}>
        제출하기
      </button>
    </>
  )
}

이 컴포넌트를 테스트하기 위해 다음과 같이 테스트 코드를 작성했다.

import { fireEvent, render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import { InputComponent } from '.'

describe('InputComponent 테스트', () => {
  const setup = () => {
    const screen = render(<InputComponent />)
    const input = screen.getByLabelText('input') as HTMLInputElement
    const button = screen.getByText(/제출하기/i) as HTMLButtonElement
    return {
      input,
      button,
      ...screen,
    }
  }

  it('input의 초기값은 빈 문자열이다.', () => {
    const { input } = setup()
    expect(input.value).toEqual('')
  })

  it('input의 최대길이가 20자로 설정되어 있다.', () => {
    const { input } = setup()
    expect(input).toHaveAttribute('maxlength', '20')
  })

  it('영문과 숫자만 입력된다.', () => {
    const { input } = setup()
    const inputValue = '안녕하세요123'
    userEvent.type(input, inputValue)
    expect(input.value).toEqual('123')
  })

  it('아이디를 입력하지 않으면 버튼이 활성화 되지 않는다.', () => {
    const { button } = setup()
    expect(button).toBeDisabled()
  })

  it('아이디를 입력하면 버튼이 활성화 된다.', () => {
    const { button, input } = setup()

    const inputValue = 'helloworld'
    userEvent.type(input, inputValue)

    expect(input.value).toEqual(inputValue)
    expect(button).toBeEnabled()
  })

  it('버튼을 클릭하면 alert가 해당 아이디로 뜬다.', () => {
    const alertMock = jest
      .spyOn(window, 'alert')
      .mockImplementation((_: string) => undefined)

    const { button, input } = setup()
    const inputValue = 'helloworld'

    userEvent.type(input, inputValue)
    fireEvent.click(button)

    expect(alertMock).toHaveBeenCalledTimes(1)
    expect(alertMock).toHaveBeenCalledWith(inputValue)
  })
})

이 테스트 코드에서 사용된 메서드들에 하나씩 살펴보자.

  • setup 함수 : setup 함수는 내부에서 컴포넌트를 렌더링하고, 또 테스트에 필요한 button과 input을 반환한다. 이 파일에서 수행하는 모든 테스트는 렌더링과 button, input을 필요로 하므로 이를 하나의 함수로 묶어 두었다.

  • userEvent.type : userEvent.type은 사용자가 타이핑하는 것을 흉내 내는 메서드다. userEvent.type을 사용하면 사용자가 키보드로 타이핑하는 것과 동일한 작동을 만들 수 있다. userEvent는 @testing-library-react에서 제공하는 fireEvent와 차이가 있다. 기본적으로 userEvent는 fireEvent의 여러 이벤트를 순차적으로 실행해 좀 더 자세하게 사용자의 동작을 흉내 낸다. 예를 들어, userEvent.click을 수행하면 내부적으로 다음과 같은 fireEvent가 실행된다.

    • fireEvent.mouseOver

    • fireEvent.mouseMove

    • fireEvent.mouseDown

    • fireEvent.mouseUp

    • fireEvent.click

userEvent.click은 사용자가 마우스를 움직이고, 요소에 올리고, 마우스를 클릭하는 등의 모든 작동을 수행한다. 따라서 userEvent는 사용자의 작동을 여러 fireEvent를 통해 좀 더 자세하게 흉내 내는 모듈이라고 볼 수 있다.

maxLength는 사용자가 하나씩 입력하는 경우에만 막히고, 코드로 한 번에 입력하는 경우에는 작동하지 않는다. fireEvent.type으로는 이 maxLength 작동을 확인할 수 없으므로 userEvent.type을 사용해야 한다.

요약하자면, 대부분의 이벤트를 테스트할 때는 fireEvent로 충분하고 훨씬 더 빠르다. 단, 특별히 사용자의 이벤트를 흉내 내야 할 때만 userEvent를 사용하면 된다.

  • jest.spyOn(window, ‘alert’).mockImplementation() :

    • jest.spyOn : Jest가 제공하는 spyOn은 어떠한 특정 메서드를 오염시키지 않고 실행이 됐는지, 또 어떤 인수로 실행됐는지 등 실행과 관련된 정보만 얻고 싶을 때 사용한다. 여기서는 (window, ‘alert’)라는 인수와 함께 사용됐는데, 이는 window 객체의 메서드 alert를 구현하지 않고 해당 메서드가 실행됐는지만 관찰하겠다는 뜻이다.

    • mockImplementation : 해당 메서드에 대한 모킹(mocking) 구현을 도와준다. 현재 Jest를 실행하는 Node.js 환경에서는 window.alert가 존재하지 않으므로 해당 메서드를 모의 함수로(mock)로 구현해야 하는데, 이것이 바로 mockImplementation의 역할이다. 비록 모의 함수로 구현된 함수이지만 함수가 실행됐는지 등의 정보는 확인할 수 있도록 도와준다.

비동기 이벤트 컴포넌트

마지막으로 비동기 이벤트 특히 자주 사용되는 fetch가 실행되는 컴포넌트를 예로 들어보자.

import { MouseEvent, useState } from 'react'

interface TodoResponse {
  userId: number
  id: number
  title: string
  completed: false
}

export function FetchComponent() {
  const [data, setData] = useState<TodoResponse | null>(null)
  const [error, setError] = useState<number | null>(null)

  async function handleButtonClick(e: MouseEvent<HTMLButtonElement>) {
    const id = e.currentTarget.dataset.id

    const response = await fetch(`/todos/${id}`)

    if (response.ok) {
      const result: TodoResponse = await response.json()
      setData(result)
    } else {
      setError(response.status)
    }
  }

  return (
    <div>
      <p>{data === null ? '불러온 데이터가 없습니다.' : data.title}</p>

      {error && <p style={{ backgroundColor: 'red' }}>에러가 발생했습니다</p>}

      <ul>
        {Array.from({ length: 10 }).map((_, index) => {
          const id = index + 1
          return (
            <button key={id} data-id={id} onClick={handleButtonClick}>
              {`${id}번`}
            </button>
          )
        })}
      </ul>
    </div>
  )
}

이 코드는 데이터를 불러오는 데 성공하면 응답값 중 하나를 노출하지만, 실패하면 에러 문구를 노출하는 컴포넌트로, 일반적인 애플리케이션에서 자주 볼 수 있는 패턴이다.

테스트 코드를 작성하기 앞서, 눈여겨봐야 할 것은 바로 fetch다. 이 fetch는 어떻게 테스트할 수 있을까? 가장 먼저 떠오르는 방법은 앞서 언급한 jest.spyOn 등을 활용해서 fetch를 모킹하는 것이다.

예를 들어, 간단하게 실제로 정확하게 fetch를 모킹하려면 많은 메서드를 구현해야 하지만 여기서는 간단하게 json만 구현하고 어설션으로 간단하게 처리했다.

jest.spyOn(window, 'fetch').mockImplementation(
    jest.fn(() => 
        Promise.resolve({
            ok: true,
            status: 200,
            json: () => Promise.resolve(MOCK_TODO_RESPONSE),
        }),
    ) as jest.Mock
}

코드를 살펴보면 window에 있는 fetch를 테스트에 필요한 부분만 아주 간단하게 모킹한 것을 볼 수 있다. 그러나 위 케이스는 모든 시나리오를 해결할 수 없다. 서버 응답에서 오류가 발생한 경우는 어떻게 테스트할 수 있을까? ok, status, json의 모든 값을 바꿔서 다시 모킹해야 한다. 이러한 방식은 테스트를 수행할 때마다 모든 경우를 새롭게 모킹해야 하므로 테스트 코드가 길고 복잡해진다. 또한 fetch가 할 수 있는 다양한 일(headers를 설정하거나, text()로 파싱하거나, status의 값을 다르게 보는 등)을 일일이 모킹해야 하므로 테스트 코드가 길어지고 유지보수도 어렵다.

MSW(Mock Service Worker)

이러한 문제를 해결하기 위해 등장한 것이 MSW(Mock Service Worker)다. MSW는 Node.js나 브라우저에서 모두 사용할 수 있는 모킹 라이브러리로, 브라우저에서는 서비스 워커를 활용해 실제 네트워크 요청을 가로채는 방식으로 모킹을 구현한다. 그리고 Node.js 환경에서는 https나 XMLHttpRequest의 요청을 가로채는 방식으로 작동한다. 즉, Node.js나 브라우저에서는 fetch 요청을 하는 것과 동일하게 네트워크 요청을 수행하고, 이 요청을 중간에 MSW가 감지하고 미리 준비한 모킹 데이터를 제공하는 방식이다. 이러한 방식은 fetch의 모든 기능을 그대로 사용하면서도 응답에 대해서만 모킹할 수 있으므로 fetch를 모킹하는 것이 훨씬 수월해진다. 비단 테스트 코드뿐만 아니라 create-react-app, Next.js 등 다양한 환경에서도 사용 가능하므로 모킹에 대해 고민하고 있다면 사용해 보는 것을 추천한다.

MSW를 활용해 fetch 응답을 모킹한 테스트 코드를 다음과 같이 작성했다.

import { fireEvent, render, screen } from '@testing-library/react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'

import { FetchComponent } from '.'

const MOCK_TODO_RESPONSE = {
  userId: 1,
  id: 1,
  title: 'delectus aut autem',
  completed: false,
}

const server = setupServer(
  rest.get('/todos/:id', (req, res, ctx) => {
    const todoId = req.params.id

    if (Number(todoId)) {
      return res(ctx.json({ ...MOCK_TODO_RESPONSE, id: Number(todoId) }))
    } else {
      return res(ctx.status(404))
    }
  }),
)

beforeAll(() => server.listen())
// afterEach(() => server.resetHandlers());
afterAll(() => server.close())

beforeEach(() => {
  render(<FetchComponent />)
})

describe('FetchComponent 테스트', () => {
  it('데이터를 불러오기 전에는 기본 문구가 뜬다.', async () => {
    const nowLoading = screen.getByText(/불러온 데이터가 없습니다./)
    expect(nowLoading).toBeInTheDocument()
  })

  it('버튼을 클릭하면 데이터를 불러온다.', async () => {
    const button = screen.getByRole('button', { name: /1번/ })
    fireEvent.click(button)

    const data = await screen.findByText(MOCK_TODO_RESPONSE.title)
    expect(data).toBeInTheDocument()
  })

  it('버튼을 클릭하고 서버요청에서 에러가 발생하면 에러문구를 노출한다.', async () => {
    server.use(
      rest.get('/todos/:id', (req, res, ctx) => {
        return res(ctx.status(503))
      }),
    )

    const button = screen.getByRole('button', { name: /1번/ })
    fireEvent.click(button)

    const error = await screen.findByText(/에러가 발생했습니다/)
    expect(error).toBeInTheDocument()
  })
})
const server = setupServer(
  rest.get('/todos/:id', (req, res, ctx) => {
    const todoId = req.params.id

    if (Number(todoId)) {
      return res(ctx.json({ ...MOCK_TODO_RESPONSE, id: Number(todoId) }))
    } else {
      return res(ctx.status(404))
    }
  }),
)

이 코드에서는 MSW를 활용해 fetch 응답을 모킹했다. setupServer는 MSW에서 제공하는 메서드로, 이름 그대로 서버를 만드는 역할을 한다. 그리고 이 함수 내부에서 Express나 Koa와 비슷하게 라우트를 선언할 수 있다. 그리고 이 라우트 내부에서 서버 코드를 작성하는 것과 동일하게 코드를 작성하고, 대신 응답하는 데이터만 미리 준비해 둔 모킹 데이터를 반환하면 된다. 테스트 코드에서는 라우트 /todos/:id의 요청만 가로채서 todoId가 숫자인지 확인한 다음, 숫자일 때만 MOCK_TODO_RESPONSE와 id를 반환하고, 숫자가 아니라면 404를 반환하도록 코드를 구성했다.

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers());
afterAll(() => server.close())

테스트 코드를 시작하기 전에는 서버를 기동하고, 테스트 코드 실행이 종료되면 서버를 종료시킨다. 한 가지 눈에 띄는 것은 afterEach에 있는 server.resetHandlers()다. 이 코드는 앞에서 선언한 setupServer의 기본 설정으로 되돌리는 역할을 한다. 일반적인 경우라면 필요 없지만, 뒤이이서 작성할 ‘서버에서 실패가 발생하는 경우’를 테스트할 때는 res를 임의로 ctx.status(503)과 같은 형태로 변경할 것이다. 그러나 이를 리셋하지 않으면 계속해서 실패하는 코드로 남아있을 것이므로 테스트 실행마다 resetHandlers를 통해 setupServer로 초기화했던 초기값을 유지하는 것이다.

그다음부터는 describe를 시작으로 테스트하고 싶은 내용을 테스트 코드로 작성했다.

it('버튼을 클릭하면 데이터를 불러온다.', async () => {
    const button = screen.getByRole('button', { name: /1번/ })
    fireEvent.click(button)

    const data = await screen.findByText(MOCK_TODO_RESPONSE.title)
    expect(data).toBeInTheDocument()
})

여기서부터 본격적으로 비동기 이벤트, 버튼을 클릭해 fetch가 발생하는 시나리오를 테스트한다. 버튼을 클릭하는 것까지는 동일하지만 이후 fetch 응답이 온 뒤에서야 비로소 찾고자 하는 값을 렌더링할 것이다. 원하는 값을 동기 방식으로 즉시 찾는 get 메서드 대신, 요소가 렌더링될 때까지 일정 시간 동안 기다리는 find 메서드를 사용해 요소를 검색한다.

it('버튼을 클릭하고 서버요청에서 에러가 발생하면 에러문구를 노출한다.', async () => {
    server.use(
      rest.get('/todos/:id', (req, res, ctx) => {
        return res(ctx.status(503))
      }),
    )

    const button = screen.getByRole('button', { name: /1번/ })
    fireEvent.click(button)

    const error = await screen.findByText(/에러가 발생했습니다/)
    expect(error).toBeInTheDocument()
})

앞서 setupServer에서는 정상적인 응답만 모킹했기 때문에 에러가 발생하는 경우를 테스트하기 어렵다. 서버 응답이 실패하는 경우를 테스트하기 위해 server.use를 사용해 기존 setupServer의 내용을 새롭게 덮어쓴다. 여기서는 /todos/:id 라우팅을 모든 경우에 503이 오도록 작성했다. 서버 설정이 끝난 이후에는 앞선 테스트와 동일하게 findBy를 활용해 에러 문구가 정상적으로 노출됐는지 확인한다.

server.use를 활용한 서버 기본 작동을 덮어쓰는 작업은 ‘it(’버튼을 클릭하고 서버 요청에서 에러가 발생하면 에러 문구를 노출한다.’, async () ⇒ {…에서만 유요해야 한다. 다른 테스트 시에는 원래대로 서버 작동이 다시 변경되어야 하므로 afterEach에서 resetHandlers를 실행한다. 이렇게 하면 it 내부의 server.use 구문이 종료된 이후에는 503 에러 라우팅은 사라지고 다시 정상적인 응답만 받을 수 있게 된다.

물론 앞서 살펴본 테스트가 가장 마지막에 수행하는 테스트이므로 resetHandlers를 제거해도 테스트 결과가 달라지지 않을 것이다. 그러나 테스트 케이스가 가장 마지막에 수행되지 않고, resetHandlers를 수행하지 않는다면 다른 테스트 케이스에서도 503 에러를 받게 되므로 주의해야 한다.


💩 실제 프로젝트에서 구현한 테스트 코드

다음은 기술과제에서 구현한 테스트 코드로 Context API를 활용한 유저 회원가입 및 로그인, 로그아웃 기능을 테스트 했다.

import React from "react";
import { render, fireEvent, waitFor } from "@testing-library/react";
import {
  UserContext,
  UserContextType,
  UserProvider,
  useUser
} from "../../context/UserContext";
import { User } from "../../types/user";

const mockUser: User = {
  email: "inflab@test.com",
  password: "test12@",
  birthday: "1998-09-09",
  job: "pm",
  interests: ["data"]
};

describe("UserContext", () => {
  const mockAddUser = jest.fn();
  const mockUpdateUserList = jest.fn();
  const mockLogin = jest.fn();
  const mockLogout = jest.fn();

  const userContextValue: UserContextType = {
    addUser: mockAddUser,
    users: [],
    user: mockUser,
    updateUserList: mockUpdateUserList,
    login: mockLogin,
    logout: mockLogout
  };

  beforeEach(() => {
    localStorage.clear();
  });

  it("회원가입 로직", () => {
    const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
      <UserProvider>
        <UserContext.Provider value={userContextValue}>
          {children}
        </UserContext.Provider>
      </UserProvider>
    );

    const { getByText } = render(<TestComponent />, { wrapper });
    fireEvent.click(getByText("Add User"));
    expect(mockAddUser).toHaveBeenCalledWith(mockUser);
  });

  it("로그인 성공 및 localStorage 추가", async () => {
    let currentUser = {
      email: "inflab@test.com",
      password: "test12@",
      birthday: "1998-09-09",
      job: "pm",
      interests: ["data"]
    };
    localStorage.removeItem("loggedInUser");

    const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
      <UserProvider>
        <UserContext.Provider value={userContextValue}>
          {children}
        </UserContext.Provider>
      </UserProvider>
    );

    const { getByText } = render(<TestComponent />, { wrapper });

    fireEvent.click(getByText("Login"));
    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith(currentUser);
    });
  });

  it("로그아웃 성공 및 localStorage 초기화", async () => {
    localStorage.setItem("loggedInUser", JSON.stringify(mockUser));
    const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
      <UserProvider>
        <UserContext.Provider value={userContextValue}>
          {children}
        </UserContext.Provider>
      </UserProvider>
    );

    const { getByText } = render(<TestComponent />, { wrapper });

    fireEvent.click(getByText("Logout"));
    await waitFor(() => {
      expect(mockLogout).toHaveBeenCalledWith();
    });
  });
});

function TestComponent() {
  const { addUser, login, logout } = useUser();
  return (
    <div>
      <div onClick={() => addUser(mockUser)}>Add User</div>
      <div onClick={() => login(mockUser)}>Login</div>
      <div onClick={() => logout()}>Logout</div>
    </div>
  );
}

현재 각각의 테스트에서 wrapper 를 통해서 <TestComponent /> 를 렌더링을 해주고 있는데 처음에는 각각의 테스트에서 코드가 중복으로 사용되고 있었고 해당 코드를 beforeEach 를 통해서 중복을 제거할 수 있겠지만, 그럴경우 테스트를 위해 userContextValue 를 전역 변수인 공유 상태로 둠으로써 테스트의 수정이 어려워지고 테스트간의 영향을 주는 코드가 되었습니다.

또한, 이 테스트는 이전보다 가독성이 떨어졌습니다.

테스트를 추가하거나 수정할때마다 항상 beforeEach 메소드를 파악해야 되고, 그래서 개별 테스트 코드안을 보고는 이제 코드 전체를 파악할 수 없게 되었습니다.

import React from "react";
import { render, fireEvent, waitFor } from "@testing-library/react";
import {
  UserContext,
  UserContextType,
  UserProvider,
  useUser
} from "../../context/UserContext";
import { User } from "../../types/user";

const mockUser: User = {
  email: "inflab@test.com",
  password: "test12@",
  birthday: "1998-09-09",
  job: "pm",
  interests: ["data"]
};

describe("UserContext", () => {
  const mockAddUser = jest.fn();
  const mockUpdateUserList = jest.fn();
  const mockLogin = jest.fn();
  const mockLogout = jest.fn();

  const userContextValue: UserContextType = {
    addUser: mockAddUser,
    users: [],
    user: mockUser,
    updateUserList: mockUpdateUserList,
    login: mockLogin,
    logout: mockLogout
  };

  beforeEach(() => {
    localStorage.clear();
  });

  const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
    <UserProvider>
      <UserContext.Provider value={userContextValue}>
        {children}
      </UserContext.Provider>
    </UserProvider>
  );

  it("회원가입 로직", () => {
    const { getByText } = render(<TestComponent />, { wrapper });
    fireEvent.click(getByText("Add User"));
    expect(mockAddUser).toHaveBeenCalledWith(mockUser);
  });

  it("로그인 성공 및 localStorage 추가", async () => {
    let currentUser = {
      email: "inflab@test.com",
      password: "test12@",
      birthday: "1998-09-09",
      job: "pm",
      interests: ["data"]
    };
    localStorage.removeItem("loggedInUser");

    const { getByText } = render(<TestComponent />, { wrapper });

    fireEvent.click(getByText("Login"));
    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith(currentUser);
    });
  });

  it("로그아웃 성공 및 localStorage 초기화", async () => {
    localStorage.setItem("loggedInUser", JSON.stringify(mockUser));
    const { getByText } = render(<TestComponent />, { wrapper });

    fireEvent.click(getByText("Logout"));
    await waitFor(() => {
      expect(mockLogout).toHaveBeenCalledWith();
    });
  });
});

function TestComponent() {
  const { addUser, login, logout } = useUser();
  return (
    <div>
      <div onClick={() => addUser(mockUser)}>Add User</div>
      <div onClick={() => login(mockUser)}>Login</div>
      <div onClick={() => logout()}>Logout</div>
    </div>
  );
}

아직은 테스트하고 있는 내용들이 복잡하지 않고 단순한 유저의 회원가입, 로그인, 로그아웃 로직 뿐이지만 회원의 상태가 늘어나거나 접속 제한 혹은 관리자계정으로 로그인 할 경우 등등의 여러 상황에 한해서는 오히려 더 코드가 불필요하게 늘어나게 될 것입니다. 이러한 문제를 해결하기 위해 테스트 코드의 리팩토링은 중복을 줄이는 것이 아니라 더 서술적이고 의미있는 방향으로 이루어져야 합니다.

테스트 코드를 잘 작성하는 방법은 여러가지가 있지만 그 중에서 내가 참고했던 기술블로그을 하단에 첨부하였다 😀

아직 테스트 코드를 실제로 작성해본 경험도 부족하고 테스트 케이스들 또한 정말 간단한 렌더링이 이루어졌는가, 올바르게 함수가 호출 되었는가, 버튼을 중복,중첩 클릭하게 되었을 때 등의 단순한 상황에 대해서만 구현해봤다. 하지만 코드구현이 전부가 아니고 현장에서는 우리가 작성한 코드가 실제로도 다양한 예외사항에 대해서도 정상적으로 대처가 가능한 지 그러한 부분들이 중요하다고 판단되고 우연찮게 이번 기술과제에서 필요한 기술이라서 급하게 공부하고 적용해봤지만 좋은 경험이 되었고, 이 또한 나의 스킬로 발전시켜 나가려고 한다 🚀

[ 참고 : 올리브영 테크블로그 - https://oliveyoung.tech/blog/2024-01-23/msw-frontend/ ]

[ 참고 : 모던 리액트 Deep Dive - 김용찬 ]

[ 참고 : 인프런 테크블로그 - https://tech.inflab.com/20230404-test-code/ ]

[ 참고 : 우아한 기술블로그 - https://techblog.woowahan.com/8942/ ]