Test

[Jest] React Testing Library 사용하기

jalalja 2024. 12. 17. 18:07

React Testing Library란?

더보기

- 사용자 중심 테스트 지향

  • RTL은 컴포넌트의 구현 방식보다는 UI 요소를 렌더링하고, 사용자가 상호작용하는 방식에 중점을 둬서 테스트를 진행한다.
  • 즉, 버튼 클릭, 텍스트 입력, 요소의 표시 여부 등을 테스트할 때 사용한다.

 

- DOM 상호작용 테스트

  • RTL은 querySelector와 같은 DOM 메서드를 사용하여 컴포넌트 내의 요소를 선택하고 상호작용할 수 있게 해준다.
  • 예를 들어, getByText, getByRole, getByLabelText 등 다양한 쿼리 메서드를 제공하여, 실제 화면에서 표시되는 텍스트나 역할 기반으로 요소를 찾을 수 있다.

 

- 어울리는 기술 스택

  • RTL은 React 컴포넌트의 사용자 상호작용을 테스트하는 도구이고, Jest는 테스트를 실행하고 결과를 처리하는 역할을 한다.
  • 주로 두가지 기술이 같이 쓰여진다.

React Testing Library 설치

더보기

- RTL을 사용하기 위해 설치해야하는 모듈

  • Vite로 생성한 프로젝트일 경우 @types/react와 @types/react-dom은 설치가 되어있어서 별도로 해주지 않아도 된다.
yarn add -D @testing-library/react @testing-library/dom @testing-library/jest-dom @types/react @types/react-dom

Query 종류

더보기

- Query 란?

  • 렌더링된 DOM에서 요소를 찾는 데 사용되는 도구들을 의미하는것으로, HTML 요소를 검색하고 선택하기 위해 사용되는 다양한 함수들이다.

 

- Query 유형 (DOM 요소를 찾는 방법)

  • getBy
    • 요소를 찾을 수 있으면 반환한다.
    • 요소를 찾을 수 없거나, 1개를 초과하는 요소가 있을 경우 에러를 발생시킨다.
    • 비동기 작업을 지원하지 않는다.
    • 주로 요소가 항상 DOM에 존재할 때 사용한다.
  • queryBy
    • 요소를 찾을 수 있으면 반환한다.
    • 요소를 찾을 수 없으면 null을 반환한다.
    • 1개를 초과하는 요소가 있을 경우 에러를 발생시킨다.
    • 비동기 작업을 지원하지 않는다.
    • 주로 요소가 존재하지 않거나, 에러를 던지지 않는 방식으로 검사가 필요할 때 사용한다.
  • findBy
    • 요소를 찾을 수 있으면 반환한다.
    • 요소를 찾을 수 없거나, 1개를 초과하는 요소가 있을 경우 에러를 발생시킨다.
    • 요소가 즉시 존재하지 않더라도 retry를 통해 나타날 때까지 기다린다.
    • 비동기 작업을 지원한다.
    • 주로 비동기적으로 렌더링되는 요소를 테스트하거나, 요소가 나중에 DOM에 추가될 것을 예상하는 경우 사용한다.
  • getAllBy
    • 요소를 찾을 수 있으면 배열 형태로 반환한다.
    • 요소를 찾을 수 없으면 에러를 발생시킨다.
    • getBy와 동일하지만, 여러개의 요소를 선택할 때 사용한다.
  • queryAllBy
    • 요소를 찾을 수 있으면 배열 형태로 반환한다.
    • 요소를 찾을 수 없으면 빈 배열을 반환한다.
    • queryBy와 동일하지만, 여러개의 요소를 선택할 때 사용한다.
  • findAllBy
    • 요소를 찾을 수 있으면 배열 형태로 반환한다.
    • 요소를 찾을 수 없으면 에러를 발생시킨다.
    • findBy와 동일하지만, 여러개의 요소를 선택할 때 사용한다. 

 

- 대표적인 Query 종류

  • 전체 Query에 해당
    • screen.getByRole()과 같은 방식으로 사용하면 container: HTMLElement 부분을 별도로 작성해주지 않아도 된다.
  • ByRole (가장 권장되는 방법)
    • 접근성 속성을 기반으로 DOM 요소를 찾는 가장 권장되는 RTL 쿼리이다.
    • ex) <button>Submit</button> (role: button)
getByRole(
  // If you're using `screen`, then skip the container argument:
  container: HTMLElement,
  role: string,
  options?: {
    hidden?: boolean = false,
    name?: TextMatch,
    description?: TextMatch,
    selected?: boolean,
    busy?: boolean,
    checked?: boolean,
    pressed?: boolean,
    suggest?: boolean,
    current?: boolean | string,
    expanded?: boolean,
    queryFallbacks?: boolean,
    level?: number,
    value?: {
      min?: number,
      max?: number,
      now?: number,
      text?: TextMatch,
    }
  }): HTMLElement
  • ByLabelText
    • 요소의 label과 연결된 텍스트(label과 input 관계)로 요소를 찾는다.
    • ex) <label for="name">Name</label><input id="name" />
getByLabelText(
  // If you're using `screen`, then skip the container argument:
  container: HTMLElement,
  text: TextMatch,
  options?: {
    selector?: string = '*',
    exact?: boolean = true,
    normalizer?: NormalizerFn,
  }): HTMLElement
  • ByPlaceholderText
    • 요소의 placeholder 속성을 기준으로 요소를 찾는다.
    • ex) <input placeholder="Enter your name" />
getByPlaceholderText(
  // If you're using `screen`, then skip the container argument:
  container: HTMLElement,
  text: TextMatch,
  options?: {
    exact?: boolean = true,
    normalizer?: NormalizerFn,
  }): HTMLElement
  • ByText
    • 요소의 텍스트 콘텐츠(보이는 텍스트)를 기준으로 요소를 찾는다.
    • ex) <button>Submit</button>
getByText(
  // If you're using `screen`, then skip the container argument:
  container: HTMLElement,
  text: TextMatch,
  options?: {
    selector?: string = '*',
    exact?: boolean = true,
    ignore?: string|boolean = 'script, style',
    normalizer?: NormalizerFn,
  }): HTMLElement
  • ByDisplayValue
    • <input>이나 <textarea>의 value 속성 값을 기준으로 요소를 찾는다.
    • ex) <input value="Daniel" />
getByDisplayValue(
  // If you're using `screen`, then skip the container argument:
  container: HTMLElement,
  value: TextMatch,
  options?: {
    exact?: boolean = true,
    normalizer?: NormalizerFn,
  }): HTMLElement
  • ByAltText
    • 이미지의 alt 속성을 기준으로 요소를 찾는다.
    • ex) <img alt="profile picture" />
getByAltText(
  // If you're using `screen`, then skip the container argument:
  container: HTMLElement,
  text: TextMatch,
  options?: {
    exact?: boolean = true,
    normalizer?: NormalizerFn,
  }): HTMLElement
  • ByTitle
    • 요소의 title 속성을 기준으로 요소를 찾는다.
    • ex) <div title="tooltip" />
getByTitle(
  // If you're using `screen`, then skip the container argument:
  container: HTMLElement,
  title: TextMatch,
  options?: {
    exact?: boolean = true,
    normalizer?: NormalizerFn,
  }): HTMLElement
  • ByTestId
    • 요소의 data-testid 속성을 기준으로 요소를 찾는다.
    • ex) <div data-testid="custom-element" />
getByTestId(
  // If you're using `screen`, then skip the container argument:
  container: HTMLElement,
  text: TextMatch,
  options?: {
    exact?: boolean = true,
    normalizer?: NormalizerFn,
  }): HTMLElement

 

 

- CheatSheet

  • 전반적인 테스트 방법이 적혀있다. (공식문서를 다 보기 힘든 경우 참고하기 좋다)
 

Cheatsheet | Testing Library

Get the printable cheat sheet

testing-library.com


RTL을 쓰기 전의 코드 바꿔보기 (Query 사용)

더보기

- App.tsx

import { FormEvent, useState } from "react";
import "./App.css";

function App() {
  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const [loginError, setLoginError] = useState<boolean>(false);

  const onSubmit = (e: FormEvent) => {
    e.preventDefault();

    if (!email || !password) {
      setLoginError(true);
      return;
    }
  };

  return (
    <>
      <header>
        <h1>HEADER</h1>
      </header>

      {loginError && <p id="errorMessage">ERROR</p>}

      <main>
        <form onSubmit={onSubmit}>
          <div>
            <h2>email</h2>
            <input
              type="text"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </div>
          <div>
            <h2>password</h2>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
          </div>
          <button id="submitButton" type="submit">
            Form 제출
          </button>
        </form>
      </main>

      <footer>
        <h1>FOOTER</h1>
      </footer>
    </>
  );
}

export default App;

 

- RTL을 사용하지 않은 테스트 코드

import { test } from "@jest/globals";
import { act } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

test("로그인 화면이 잘 보인다.", async () => {
  // 테스트를 위한 #root와 동일한 요소 생성
  const container = document.createElement("div");

  // 해당 요소를 body 하단에 삽입
  document.body.appendChild(container);

  // container 안에 App 컴포넌트 렌더링
  await act(() => {
    createRoot(container).render(<App />);
  });
  
  // header안의 h1 태그가 가지고 있는 텍스트가 "HEADER"인지 테스트
  expect(container.querySelector("header h1")?.textContent).toBe("HEADER");
});

test("이메일, 비밀번호 미입력시 에러메시지가 나타난다.", async () => {
  // 테스트를 위한 #root와 동일한 요소 생성
  const container = document.createElement("div");

  // 해당 요소를 body 하단에 삽입
  document.body.appendChild(container);

  // container 안에 App 컴포넌트 렌더링
  await act(() => {
    createRoot(container).render(<App />);
  });

  // form을 제출할 버튼 가져오기
  const submitButton = container.querySelector("#submitButton");

  // 에러 메시지 가져오기
  let errorMessage = container.querySelector("#errorMessage");

  // form을 제출하기 전에는 에러 메시지가 없기때문에 undefined여야 한다.
  expect(errorMessage?.textContent).toBeUndefined();

  // form 제출 버튼 클릭 이벤트
  await act(() => {
    submitButton?.dispatchEvent(new MouseEvent("click"));
  });

  // form을 제출한 다음에 변한 에러 메시지를 가져온다.
  errorMessage = container.querySelector("#errorMessage");

  // 에러 메시지는 ERROR여야 한다.
  expect(errorMessage?.textContent).toBe("ERROR");
});

 

- RTL을 사용한 코드

  • 코드를 간단하고 가독성있게 작성할 수 있다.
import { test } from "@jest/globals";
import { fireEvent, render, screen } from "@testing-library/react";
import App from "../App";
import "@testing-library/jest-dom";

test("로그인 화면이 잘 보인다.", () => {
  render(<App />);
  expect(screen.getByText("HEADER")).toBeInTheDocument();
});

test("이메일, 비밀번호 입력 없이 로그인 버튼을 클릭하면 에러메시지가 표시된다", async () => {
  render(<App />);
  fireEvent.click(screen.getByRole("button"));
  expect(screen.getByText("ERROR")).toBeInTheDocument();
});

RenderHook 사용

  • React에서 사용하는 Custom Hook을 테스트하기 위해 사용한다.
더보기

- Custom Hook 이란?

  • Custom Hook은 React에서 재사용 가능한 기능을 만들기 위한 함수이다.
  • 간단히 말해서, 여러 컴포넌트에서 자주 사용하는 로직을 하나의 함수로 묶어서 재사용할 수 있도록 만들어 주는 도구이다.

 

- App.tsx 코드

import { FormEvent } from "react";
import "./App.css";
import useLogin from "./hooks/useLogin";

function App() {
  const { values, error, handleChange, validate } = useLogin({
    email: "",
    password: "",
  });

  const onSubmit = (e: FormEvent) => {
    e.preventDefault();

    if (!validate()) {
      return;
    }
  };

  return (
    <>
      <header>
        <h1>HEADER</h1>
      </header>

      {error && <p id="errorMessage">ERROR</p>}

      <main>
        <form onSubmit={onSubmit}>
          <div>
            <h2>email</h2>
            <input
              type="text"
              name="email"
              value={values.email}
              onChange={handleChange}
            />
          </div>
          <div>
            <h2>password</h2>
            <input
              type="password"
              name="password"
              value={values.password}
              onChange={handleChange}
            />
          </div>
          <button id="submitButton" type="submit">
            Form 제출
          </button>
        </form>
      </main>

      <footer>
        <h1>FOOTER</h1>
      </footer>
    </>
  );
}

export default App;

 

- 간단한 Custom Hook 코드

import { useState } from "react";

function useLogin(initialValues: { [key: string]: string }) {
  const [values, setValues] = useState(initialValues);
  const [error, setError] = useState(false);

  // 사용자가 이메일과, 비밀번호를 입력하면 상태를 바꿔주는 함수
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setValues((prev) => ({ ...prev, [name]: value }));
  };

  // 이메일과 비밀번호가 비어있는지 확인하는 함수
  const validate = () => {
    const hasEmptyFields = Object.values(values).some((value) => !value);
    setError(hasEmptyFields);
    return hasEmptyFields;
  };

  return { values, error, handleChange, validate };
}

export default useLogin;

 

- 테스트 코드

import { renderHook, act } from "@testing-library/react";
import useLogin from "./useLogin";

describe("로그인 커스텀훅 테스트", () => {
  test("초기값이 정상적으로 잘 저장이 되었는지 확인한다.", () => {
    const { result } = renderHook(() => useLogin({ email: "", password: "" }));

    // 이메일과 비밀번호는 빈값이고, 에러는 false여야한다.
    expect(result.current.values.email).toBe("");
    expect(result.current.values.password).toBe("");
    expect(result.current.error).toBe(false);
  });

  test("사용자가 아이디 또는 비밀번호를 입력하면 값이 업데이트된다.", () => {
    const { result } = renderHook(() => useLogin({ email: "", password: "" }));

    // 이메일에 값을 넣어준다.
    act(() => {
      result.current.handleChange({
        target: { name: "email", value: "ths_eksldpf@naver.com" },
      } as React.ChangeEvent<HTMLInputElement>);
    });

    // 넣어준 값이 동일한지 확인한다.
    expect(result.current.values.email).toBe("ths_eksldpf@naver.com");
  });

  test("아이디 또는 비밀번호가 빈 값일 경우 에러가 true로 바뀐다.", () => {
    const { result } = renderHook(() => useLogin({ email: "", password: "" }));

    // 이메일만 값을 넣어주고 비밀번호는 별도의 값을 넣어주지 않는다.
    act(() => {
      result.current.handleChange({
        target: { name: "email", value: "ths_eksldpf@naver.com" },
      } as React.ChangeEvent<HTMLInputElement>);
    });

    // validate 함수를 호출해서 error 상태를 업데이트 시켜준다.
    act(() => {
      result.current.validate();
    });

    // 비밀번호가 빈 값이기 때문에 에러 상태는 true 여야 한다.
    expect(result.current.error).toBe(true);
  });
});