Logo

React Testing Library 사용법

React Testing Library(RTL)라는 테스팅 라이브러리가 React 개발자들의 입소문을 타고 점점 인기가 올라가고 있습니다. 이번 포스팅에서는 최근 React의 테스팅 패러다임을 바꾸고 있는 React Testing Library에 대해서 다뤄보도록 하겠습니다.

React Testing Library 소개

React Testing Library는 Behavior Driven Test(행위 주도 테스트) 방법론이 대두 되면서 함께 주목 받기 시작한 테스팅 라이브러리 입니다. 행위 주도 테스트는 기존에 관행이던 Implementation Driven Test(구현 주도 테스트)의 단점을 보완하기 위한 방법론인데요.

Implementation Driven Test에서는 주로 애플리케이션이 어떻게 작동하는지에 대해서 초점을 두어 테스트를 작성합니다.

<h2 class="title">제목</h2>

예를 들어, 위와 같은 UI를 테스트한다고 했을 때, <h2>라는 태그가 쓰였고, title 이라는 클래스가 사용되었는지 여부를 테스트합니다.

반면에, Behavior Driven Test에서는 사용자가 애플리케이션을 이용하는 관점에서 사용자의 실제 경험 위주로 테스트를 작성합니다. 사용자가 위 UI를 사용하면서 과연 <h2> 태그를 사용하고, title 이라는 클래스가 사용되었는지 관심이나 있을까요? 사용자 입장에서는 단지 브라우저 화면에 제목 이라는 텍스트가 보일 뿐입니다. 따라서, 사용자에게 어떤 컨텐츠가 현재 보이고, 사용자가 어떤 이벤트를 발생시켰을 때, 그에 따라 화면에 변화가 일어나는지를 테스트합니다.

자 여기서, 만약에 추후 어떤 이유로 인해서 <h2> 태그가 <h3> 태그로 바뀌었을 때는 어떤 일이 날까요? Implementation Driven Test 방법론으로 작성된 테스트는 깨지지만, Behavior Driven Test 방밥론으로 작성된 테스트는 깨지지 않을 것입니다. 애플리케이션 입장에서 봤을 때는 구현의 디테일이 바뀐 것이지만, 사용자 입장에서는 제목 이라는 텍스트가 화면있다는 사실은 변함이 없기 때문입니다.

Enzyme vs. React Testing Library

RTL이 등장하기 전에는 Airbnb에서 만든 Enzyme이라는 테스팅 라이브러리가 많이 사용되었습니다. 이 두 라이브러리를 간단히 비교를 해보면 Enzyme은 위에서 설명드린 Implementation Driven Test 방법론을 따르는 테스트를 작성하기에 적합합니다. 왜냐하면 Enzyme을 쓸 때는 실제 브라우저 DOM이 아닌, React가 만들어내는 가상 DOM을 기준으로 테스트를 작성하기 때문입니다. 따라서 테스트 대상 React 컴포넌트에 어떤 prop이 넘어가고, 현재 state이 어떻게 되는지에 대해서 검증을 수행합니다.

반면에, React Testing Library는 Behavior Driven Test 방밥론을 따르는 테스트를 작성하는데 적합합니다. 왜냐하면 React Testing Library는 jsdom이나 happy-dom 라이브러를 통해 실제 브라우저 DOM을 기준으로 테스트를 작성하게 됩니다. 따라서 어떤 React 컴포넌트를 사용하는지는 의미가 없으며, 결국 사용자 브라우저에서 랜더링하는 실제 HTML 마크업의 모습이 어떤지에 대해서 테스트하기 용이합니다.

개인적으로 두 가지 테스팅 라이브러리 다 써보았는데, 둘 다 잘하는 영역이 있기 때문에 하나가 다른 것보다 항상 났다라고는 말하기 어려운 것 같습니다. 단지, 위에서 설명드린 것 처럼 테스트 패러다임 자체가 Behavior Driven Test 쪽으로 흘러가는 추세이기 때문에, 앞으로 점점 신규 프로젝트에서는 Enzyme이라는 보다는 React Testing Library가 많이 선택될 확률이 높을 것입니다.

React Testing Library 설치

create-react-app으로 생성된 프로젝트는 Jest와 테스팅 라이브러리가 이미 설치되어 있으니, 이 섹션을 건너뛰셔도 됩니다.

먼저, 자신의 React 프로젝트에 Jest나 Vitest와 같은 테스팅 프레임워크가 설치되어 있어야 합니다. 예전에는 Jest가 많이 사용되었으나 요즘에는 Vitest로 많이 넘어가고 있는 추세입니다.

Jest를 설치하는 방법에 대해서는 이 글을 참고하시고, Vitest를 설치하는 방법은 이 글을 참조 바랍니다.

다음, React Testing Library를 설치합니다. 패키지 이름은 @testing-library/react 입니다. 간혹 아직 업데이트가 안 된 튜토리얼의 경우, 패키지 이름이 react-testing-library 안내되고 있지만, 반드시 @testing-library/react를 설치해야합니다. (패키지 이름이 바꿘 사연은 React Testing Library의 저자인 Kent C. Dodds의 트윗를 참고바랍니다.)

$ npm add -D @testing-library/react

마지막으로 Jest의 DOM에 특화된 matcher 애드온인 jest-dom을 설치합니다.

$ npm add -D @testing-library/jest-dom

@testing-library/jest-dom에서 제공하는 custom matcher 목록은 아래 링크를 참조바랍니다.

React Testing Library 설정

React Testing Library를 사용하려면 두 가지 설정이 필요합니다. 첫 째는 각 테스트가 DOM에 렌더링해놓은 내용들을 테스트가 끝날 때 다음 테스트를 위해서 지워주는 것이고, 두 번째는 jest-dom가 제공하는 matcher를 Jest 테스트 러너에게 인식시키는 것입니다.

아래 두 줄의 코드를 자신의 React 프로젝트의 테스팅 설정 파일에 추가해주면 됩니다. 예를 들어 create-react-app으로 생성된 프로젝트라면 src/setupTests.js 파일에 추가해주면 됩니다.

src/setupTests.js
import "@testing-library/react/cleanup-after-each";
import "@testing-library/jest-dom/extend-expect";

Vitest를 사용하는 프로젝트에서는 다음과 같이 설정합니다.

src/setupTests.js
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(cleanup);

React Testing Library 주요 API

React Testing Library는 매우 심플하지만 강력한 API를 가지고 있는데요. 기본적으로 @testing-library/react 패키지에서 render() 함수와 screen 객체를 불러와서 사용하며, 유저와 상호작용을 테스트할 때는 선택적으로 fireEvent 객체도 불러와야 합니다.

크게 DOM에 컴포넌트를 랜더링 해주는 render() 함수와, DOM에서 특정 영역을 선택하기 위한 다양한 쿼리 함수를 제공하는 screen 객체, 그리고 특정 이벤트를 발생시켜주는 fireEvent 객체로 이루어집니다.

render() 함수는 인자로 넘어온 JSX를 DOM에 랜더링해줍니다. screen 객체를 통해서는 DOM에서 특정 영역을 선택하기 위한 다양한 쿼리 함수를 호출할 수 있습니다.

import { render, screen fireEvent } from "@testing-library/react";

render(<YourComponent />);

const button = screen.getByText(/click me/i);
fireEvent.click(button);

fireEvent 객체는 쿼리 함수로 선택된 영역을 대상으로 특정 사용자 이벤트를 발생시키기 위해서 사용하는데, 아래 나오는 예제를 통해 어떻게 사용하는지 좀 더 구체적으로 살펴보겠습니다.

정적 컴포넌트 테스팅

자, 이제 본격적으로 React Testing Library를 사용해서 테스트를 작성해보도록 하겠습니다. 아래 코드는 매우 심플한 페이지를 찾을 수 없음 페이지입니다. 내부 상태가 없고 단순히 고정된 텍스트와 이미지로만 구성되어 있습니다.

NotFound.js
function NotFound({ path }) {
  return (
    <>
      <h2>Page Not Found</h2>
      <p>해당 페이지({path})를 찾을 수 없습니다.</p>
      <img
        alt="404"
        src="https://media.giphy.com/media/14uQ3cOFteDaU/giphy.gif"
      />
    </>
  );
}

먼저 헤더가 랜더링되고 있는지를 검증하는 테스트를 작성해보겠습니다.

일단 테스트 파일에 React를 임포트 후에, @testing-library/react 모듈로 부터 render 함수와 screen 객체를 불러옵니다. 그리고 테스트 대상인 <NotFound /> 컴포넌트도 임포트합니다.

NotFound.test.js
import { render, screen } from "@testing-library/react";
import { NotFound } from "./NotFound";

describe("<NotFound />", () => {
  it("renders header", () => {
    render(<NotFound path="/abc" />);
    const heading = screen.getByRole("heading", {
      name: "Page Not Found",
    });
    expect(heading).toBeInTheDocument();
  });
});

그 다음, render() 함수로 <NotFound /> 컴포넌트를 HTML 문서에 랜더링합니다. 그리고, screen 객체의 getByRole() 함수로 "Page Not Found"라는 텍스트를 담고 있는 <h2/> 요소를 얻습니다. 마지막으로 jest-domtoBeInTheDocument() matcher 함수를 이용해서 해당 <h2/> 요소가 HTML 문서 상에 존재하는 검증합니다.

동일한 방식으로 본문이 제대로 랜더링되고 있는지 검증해볼 수 있습니다. getByText() 쿼리 함수는 문자열 뿐만 아니라 정규식도 인자로 받을 수 있습니다. jest-domtoHaveTextContent() matcher 함수를 이용해서 <p/> 엘리먼트 속의 텍스트가 예상과 일치하는지 검증합니다.

it("renders paragraph", () => {
  render(<NotFound path="/abc" />);
  const paragraph = screen.getByText(/^해당 페이지/);
  expect(paragraph).toHaveTextContent("해당 페이지(/abc)를 찾을 수 없습니다.");
});

이미지 같은 경우, <img/> 태그는 내부에 텍스트가 없기 때문에, 역할(role)로 선택하는 getByRole() 함수나 alt 속성값으로 선택하는 getAltText() 함수를 사용합니다. jest-domtoHaveAttribute() matcher 함수를 이용해서 <img/> 엘리먼트의 src 속성값이 정확한지 검증합니다.

it("renders image", () => {
  render(<NotFound path="/abc" />);
  const image = screen.getByRole("img", { name: /404/i });
  // const image = screen.getByAltText("404");
  expect(image).toHaveAttribute(
    "src",
    "https://media.giphy.com/media/14uQ3cOFteDaU/giphy.gif"
  );
});

Testing Library는 getByXxx() 외에도 queryByXxx()findByXxx() 등 매우 다양한 종류의 쿼리 함수를 제공하고 있습니다. 자세한 내용은 아래 문서를 참고바랍니다.

동적 컴포넌트 테스팅

이번에는 내부 상태에 따라 UI에 변화가 생길 수 있는 좀 더 복잡한 컴포넌트에 대한 테스트를 작성해보겠습니다. 아래 코드는 이메일과 비밀번호 입력란과 버튼으로 구성된 간단한 로그인 폼입니다.

import React from "react";

function LoginForm({ onSubmit }) {
  const [email, setEmail] = React.useState("");
  const [password, setPassword] = React.useState("");

  return (
    <>
      <h2>Login</h2>
      <form onSubmit={() => onSubmit()}>
        <label>
          이메일
          <input
            type="email"
            placeholder="user@test.com"
            value={email}
            onChange={({ target: { value } }) => setEmail(value)}
          />
        </label>
        <label>
          비밀번호
          <input
            type="password"
            value={password}
            onChange={({ target: { value } }) => setPassword(value)}
          />
        </label>
        <button disabled={!email || !password}>로그인</button>
      </form>
    </>
  );
}

먼저 비활성 되어 있던 로그인 버튼이 이메일과 비밀번호가 입력된 후에 활성화되는지에 대한 테스트를 작성해보겠습니다.

import React from "react";
import { render, fireEvent } from "@testing-library/react";
import LoginForm from "./LoginForm";

describe("<LoginForm />", () => {
  it("enables button when both email and password are entered", () => {
    render(<LoginForm onSubmit={() => null} />);

    const button = screen.getByRole("button", {
      name: /로그인/i,
    });
    expect(button).toBeDisabled();

    const email = screen.getByRole("textbox", {
      name: /이메일/i,
    });
    const password = screen.getByLabelText(/비밀번호/i);

    fireEvent.change(email, { target: { value: "user@test.com" } });
    fireEvent.change(password, { target: { value: "Test1234" } });

    expect(button).toBeEnabled();
  });
});

로그인 버튼과 이메일 입력란은 getByRole() 쿼리 함수를 통해 선택하고, 비밀번호 입력란은 getByLabelText() 쿼리 함수로 선택하였습니다. type 속성이 password이 설정된 <input> 요소에는 아무 역할(role)이 주어지지 않기 때문입니다.

그리고 jest-domtoBeDisabled()toBeEnabled() matcher 함수를 통해서 로그인 버튼의 활성화 여부를 이벤트 발생 전후로 검증합니다. 두 개의 입력칸에 change 이벤트를 발생시키기 위해서 fireEvent.change() 함수를 사용하였습니다.

마지막으로 로그인 버튼을 클릭하였을 때, prop으로 넘긴 onSubmit라는 함수가 호출되는지 여부를 검증합니다.

it("submits form when button is clicked", () => {
  const obSubmit = jest.fn();
  render(<LoginForm onSubmit={obSubmit} />);

  const email = screen.getByRole("textbox", {
    name: /이메일/i,
  });
  const password = screen.getByLabelText(/비밀번호/i);

  fireEvent.change(email, { target: { value: "user@test.com" } });
  fireEvent.change(password, { target: { value: "Test1234" } });

  const button = screen.getByRole("button", {
    name: /로그인/i,
  });
  fireEvent.click(button);

  expect(obSubmit).toHaveBeenCalledTimes(1);
  expect(obSubmit).toHaveBeenCalledWith();
});

이번에는 로그인 버튼에 click 이벤트를 발생시키기 위해서 fireEvent.click() 함수를 사용하였습니다.

jest.fn()이 생소한 분들은 Jest Mock에 대한 포스팅를 참고바랍니다.

전체 코드

본 포스팅에서 Jest 기준으로 작성한 코드는 아래에서 직접 확인하고 실행해보실 수 있습니다.

Vitest를 사용하고 타입스크립트로 작성한 코드도 아래 올려두었으니 참고 바랍니다.

마치면서

지금까지 React Testing Library를 이용해서 React 컴포넌트에 대한 테스트 코드를 어떻게 작성하는지 전반적으로 살펴보았습니다. 후속 포스팅을 통해서 Testing Library에서 제공하는 다양한 종류의 쿼리 함수와 jest-dom이 제공하는 다양한 매처 함수에 대해서 좀 더 알아보도록 하겠습니다.

Testing Library에 연관된 포스팅은 Testing Library 태그를 통해서 쉽게 만나보세요!