본문 바로가기
FE/모던 리액트 Deep Dive

8. 좋은 리액트 코드 작성을 위한 환경 구축하기 - 테스트(2)

by Toddler_AD 2025. 9. 17.

8.2.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...를 사용하면 된다.
    • fingBy... : getBy...와 거의 유사하나 한 가지 큰 차이점은 Promise를 반환한다는 것이다. 즉, 비동기로 찾는다는 것을 의미하며, 기본값으로 1000ms의 타임아웃을 가지고 있다. 마찬가지로 두 개 이상이면 에러를 발생시키지만 복수 개를 찾고 싶다면 findAllBy...를 사용하면 된다. 이러한 특징 때문에 findBy는 비동기 액션 이후에 요소를 찾을 때 사용한다.
    • queryBy... : 인수의 조건에 맞는 요소를 반환하는 대신, 찾지 못한다면 null을 반환한다. getBy...와 findBy...는 찾지 못하면 에러를 발생시키기 때문에 찾지 못해도 에러를 발생시키지 않고 싶다면 queryBy...를 사용하면 된다. 마찬가지로 복수 개를 찾았을 때는 에러를 발생시키며, 복수 개를 찾고 싶다면 queryAllBy...를 사용하면 된다.
  • 그리고 컴포넌트를 테스트하는 파일은 App.tsx, App.test.tsx의 경우와 마찬가지로 같은 디렉터리상에 위치하는 것이 일반적이다. 이름 규칙인 *.test.{t|s}sx만 준수한다면 디렉터리 내부에서 명확하게 구별되고, 대부분의 프레임워크가 이러한 이름으로 된 파일은 번들링에서 제외하므로 유용하게 사용할 수 있다.
  • 테스트를 위해 사용할 수 있는 기본적인 메서드에 대해 알아봤으니 이제 본격적으로 컴포넌트를 테스트하는 방법을 살펴보자.

정적 컴포넌트

  • 정적 컴포넌트, 즉 별도의 상태가 존재하지 않아 항상 같은 결과를 반환하는 컴포넌트를 테스트하는 방법은 크게 어렵지 않다. 테스트를 원하는 컴포넌트를 렌더링한 다음, 테스트를 원하는 요소를 찾아 원하는 테스트를 수행하면 된다. 먼저 다음과 같은 컴포넌트가 있다고 가정해 보자.
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://yceffort.kr" />
        </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://yceffort.kr')
  })

  it('블로그는 같은 창에서 열려야 한다.', () => {
    const blogLink = screen.getByText('블로그')
    expect(blogLink).not.toHaveAttribute('target')
  })
})
  • 몇가지 새로운 jest 메서드가 보인다. 하나씩 살펴보자.
    • 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="${youtId}"])와 동일한 역할을 한다.
  • 요약하자면 각 테스트를 수행하기 전에 StaticComponent를 렌더링하고, describe로 연관된 테스트를 묶어서 it으로 it 함수 내부에 정의된 테스트를 수행하는 테스트 파일이라고 정의할 수 있다.
  • 여기서는 toHaveAttribyte, toBeVisible 등 다양한 메서드를 사용해 테스트를 수행했다. 메서드 이름만 봐도 한눈에 무슨 역할을 하는지 확인할 수 있는 것이 Jest를 비롯한 테스팅 프레임워크의 특징이다. Jest에서 사용 가능한 각종 어설션 메서드는 문서에서 확인할 수 있다.

동적 컴포넌트

  • 아무런 상태값이 없는 완전히 순수한 무상태(stateless) 컴포넌트는 테스트하기가 매우 간편하다. 하지만 상태값이 있는 컴포넌트, 예를 들어 useState를 사용해 상태값을 관리하는 컴포넌트는 어떨까? 사용자의 액션에 따라 state 값이 변경된다면? 이러한 변경에 따라 컴포넌트가 다르게 렌더링돼야 한다면? 일반적으로 테스트해야 하는 컴포넌트는 정적인 경우보다 이렇게 동적인 경우가 훨씬 더 많을 것이다. 이번에는 동적인 컴포넌트를 테스트 하는 방법을 알아보자.

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

  • 리액트로 작성하는 컴포넌트 중 가장 흔히 볼 수 있는 것은 사용자의 입력을 useState로 받아서 처리하는 컴포넌트일 것이다. 리액트 테스팅 라이브러리에서 사용자의 입력을 흉내 내고, 또 state의 변화에 따른 컴포넌트의 변화를 테스트하는 방법을 알아보자.
  • 먼저 예제 컴포넌트를 작성한다.
// src/conponents/InputComponents/index.tsx

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>
    </>
  );

};
  • InputComponent는 사용자의 키보드 타이핑 입력을 받는 input과 이를 alert로 띄우는 button으로 구성된 간단한 컴포넌트다. input은 최대 20자까지, 한글 입력만 가능하도록 제한돼 있다. 한글 입력을 막는 기능은 onChange에서 정규식을 활용해 강제로 value를 replace하는 방식으로 작성돼 있다. 그리고 버튼은 글자가 없으면 disabled 되도록 처리돼 있고, 클릭 시 alert 창을 띄운다.
  • 이 컴포넌트를 테스트하기 위해 다음과 같이 테스트 코드를 작성했다.
// src/conponents/InputComponents/index.test.tsx

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-libraby/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과 mockImplementation에 대해 알아야 한다.
    • jest.spyOn : Jest가 제공하는 spyOn은 어떠한 특정 메서드를 오염시키지 않고 실행이 됐는지, 또 어떤 인수로 실행됐는지 등 실행과 관련된 정보만 얻고 싶을 때 사용한다. 여기서는 (window, 'alert')라는 인수와 함께 사용됐는데, 이는 window 객체의 메서드 alert를 구현하지 않고 해당 메서드가 실행됐는지만 관찰하겠다는 뜻이다. 다음 예제를 보자.
const calc = {
  add: (a, b) => a + b,
};

const spyFn = jest.spyOn(calc, 'add')

const result = calc.add(1, 2)

expect(spyFn).toBeCalledTimes(1)
expect(spyFn).toBeCalledWith(1, 2)
expect(spyFn).toBe(3)
  • 위 코드에서는
    • jest.spyOn으로 calc 객체의 add 메서드를 관찰하는 것을 볼 수 있다. spyOn으로 관찰한 덕분에 한번 호출됐는지(toBeCalledTimes(1)), 원하는 인수와 함께 호출됐는지(toBeCalledWith(1, 2))를 확인할 수 있다. 그리고 spyOn으로 관찰은 했지만 calc.add의 작동 자체에는 영향을 미치지 않은 것을 확인할 수 있다.
    • 이처럼 jest.spyOn은 특정 개체의 메서드를 오염시키지 않고 단순히 관찰하는 용도로 사용할 수 있다.
    • mockImplementation : 해당 메서드에 대한 모킹(mocking) 구현을 도와준다. 현재 Jest를 실행하는 Node.js 환경에서는 window.alert가 존재하지 않으므로 해당 메서드를 모의 함수(mock)로 구현해야 하는데, 이것이 바로 mockImplementation의 역할이다. 비록 모의 함수로 구현된 함수이지만 함수가 실행됐는지 등의 정보는 확인할 수 있도록 도와준다.
    • 즉, 여기서는 Node.js가 존재하지 않는 window.alert를 테스트하기 위해 jest.spyOn을 사용해 window.alert를 관찰하게끔 하고, mockImplementation을 통해 window.alert가 실행됐는지 등의 정보를 확인할 수 있도록 처리한 것이다. 이렇게 먼저 Node.js 환경에서 실행될 수 없는 window.alert를 처리해 주면 실제 alert가 발생할 때 해당 모의 함수가 실행되어 함수가 몇 번 실행됐는지, 어던 인수와 함께 실행됐는지 관찰할 수 있다.
  • 지금까지 사용자 액션이 있는 동적인 컴포넌트를 테스트하는 방법을 살펴봤다. Jest에서 사용자 작동을 흉내 내는 메서드는 type 외에도 클릭(click), 더블클릭(dblclick), 클리어(clear) 등 다양하며, 이러한 메서드를 활용하면 웬만한 사용자 작동을 재현할 수 있다. 정적인 컴포넌트에 비해 테스트 코드가 복잡하지만 액션이 수행된 이후에 DOM에 기댓값이 반영됐는지 확인하는 방법은 동일하다.

비동기 이벤트가 발생하는 컴포넌트

  • 한 발짝 더 나아가서 비동기 이벤트 특히 자주 사용되는 fetch가 실행되는 컴포넌트를 예로 들어보자.
// src/conponents/FetchComponents/index.tsx

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>
  )
}
  • 이 코드는 버튼을 클릭하면 /todos/:id로 fetch 요청을 보내 데이터를 불러오는 컴포넌트다. 데이터를 불러오는 데 성공하면 응답값 중 하나를 노출하지만, 실패하면 에러 문구를 노출하는 컴포넌트로, 일반적인 애플리케이션에서 자주 볼 수 있는 패턴 중 하나다.
  • 테스트 코드를 본격적으로 작성하기에 앞서, 먼저 눈여겨봐야 할 것은 바로 fetch다. 이 fetch는 어떻게 테스트 할 수 있을까? 가장 먼저 떠오르는 방법은 앞서 언급한 jest.spyOn 등을 활용해서 fetch를 모킹하는 것이다.
  • 다음과 같이 fetch를 모킹했다고 가정해 보자.
jest.spyOn(window, 'fetch').mockImplementation(
  jest.fn(() =>
    Promise.resolve({
      ok: true,
      status: 200,
      json: () => Promise.resolve(MOCK_TODO_RESPONSE),
    }),
  ) as jest.Mock 
  // 실제로 정확하게 fetch를 모킹하려면 많은 메서드를 구현해야 하지만 
  // 여기서는 간단하게 json만 구현하고 어설션으로 간단하게 처리했다.
)
  • 코드를 살펴보면 window에 있는 fetch를 테스트에 필요한 부분만 아주 간단하게 모킹한 것을 볼 수 있다. 그러나 위 케이스는 모든 시나리오를 해결할 수 없다. 서버 응답에서 오류가 발생한 경우는 어떻게 테스트할 수 있을까? ok, status, json의 모든 값을 바꿔서 다시 모킹해야 한다.
  • 이러한 방식은 테스트를 수행할 때마다 모든 경우를 새롭게 모킹해야 하므로 테스트 코드가 길고 복잡해진다. 또한 fetch가 할 수 있는 다양한 일(headers를 설정하거나, text()로 파싱하거나, status의 값을 다르게 보는 등)을 일일이 모킹해야 하므로 테스트 코드가 길어지고 유지보수도 어렵다.
  • 이러한 문제를 해결하기 위해 등장한 것이 MSW(Mock Service Worker)다. MSW는 Node.js나 브라우저에서 모두 사용할 수 있는 모킹 라이브러리로, 브라우저에서는 서비스 워커를 활용해 실제 네트워크 요청을 가로채는 방식으로 모킹을 구현한다. 그리고, Node.js나 브라우저에서는 fetch 요청을 하는 것과 동일하게 네트워크 요청을 수행하는, 이 요청을 중간에 MSW가 감지하고 미리 준비한 모킹 데이터를 제공하는 방식이다.
  • 이러한 방식은 fetch의 모든 기능을 그대로 사용하면서도 응답에 대해서만 모킹할 수 있으므로 fetch를 모킹하는 것이 훨씬 수월해진다. 비단 테스트 코드 뿐만 아니라 create-react-app, Next.js 등 다양한 환경에서도 사용 가능하므로 모킹에 대해 고민하고 있다면 사용해 보는 것을 추천한다.
  • MSW를 활용해 fetch 응답을 모킹한 테스트 코드를 다음과 같이 작성했다.
// src/conponents/FetchComponents/index.test.tsx

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())
beforeAll(() => 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.ues를 사용해 기존 setupServer의 내용을 새롭게 덮어 쓴다. 여기서는 /todos/:id 라우팅을 모든 경우에 503이 오도록 작성했다. 서버 설정이 끝난 이후에는 앞선 테스트와 동일하게 findBy를 활용해 에러 문구가 정상적으로 노출됐는지 확인한다.
  • server.use를 활용한 서버 기본 작동을 덮어쓰는 작업은 'it('버튼을 클릭하고 서버 요청에서 에러가 발생하면 에러 문구를 노출한다.', async () => {... 에서만 유효해야 한다. 다른 테스트 시에는 원래대로 서버 작동이 다시 변경되어야 하므로 afterEach에서 resetHandlers를 제거해도 테스트 결과가 달라지지 않을 것이다. 그러나 테스트 케이스가 가장 마지막에 수행되지 않고, resetHandlers를 수행하지 않는다면 다른 테스트 케이스에서도 503 에러를 받게 되므로 주의해야 한다.
  • 지금까지 fetch를 이용한 비동기 컴포넌트를 테스트 하는 방법을 알아봤다. 여기서 중요한것은 MSW를 사용한 fetch 응답 모킹과 findBy를 활용해 비동기 요청이 끝난 뒤에 제대로 된 렌더링이 일어났는지 기다린 후에 확인하는 것이다. 이 두가지만 염두에 둔다면 비동기 컴포넌트 테스트 또한 크게 다를 것이 없다.