하루루카
루카의 개발 일지
하루루카
전체 방문자
오늘
어제
  • 분류 전체보기 (59)
    • React (10)
    • vue.js (5)
    • javascript (6)
    • 자격증 (5)
    • 기타 (5)
    • 코딩테스트 (11)
    • 프론트 CS (15)
    • NodeJs (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • react
  • 기술면접
  • CI/CD
  • jest
  • codedeploy
  • socket
  • 코딩테스트
  • AWS
  • GithubActions
  • 자바스크립트
  • 백준
  • express
  • 11655
  • vuex
  • node
  • nodejs
  • vuetify
  • nextjs
  • 프론트엔드
  • VUE

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
하루루카

루카의 개발 일지

React

React 에서 Jest 를 활용하여 TDD 배우기 - 2

2023. 7. 29. 11:48

저번에는 Jest를 통해서 함수를 테스트하는 방법을 알아보았는데

이번에는 리액트 컴포넌트를 테스트하는 방법을 알아보도록 하겠습니다

 

 

리액트 컴포넌트 테스트

jest 같은 경우는 리액트 컴포넌트를 테스트하기에는 다소 불편한 부분이 많습니다

그래서 리액트 컴포넌트를 테스트할 때는 testing-library를 사용하여 테스트를 진행합니다

이 라이브러리는 컴포넌트의 구현 상세보다는 실제 동작 방식에 집중하며 이로 인해 사용자가 애플리케이션을 사용하는 방식과 가장 유사하게 테스틀 진행할 수 있습니다

 

라이브러리 설치

yarn add -D @testing-library/react @testing-library/jest-dom

 

그러면 우선 간단한 예제로 테스팅 라이브러리가 어떻게 동작하는지 알아보도록 하겠습니다

 

화면에 Hello About이라는 텍스트가 있는지에 대한 테스트 코드입니다

 

about.test.tsx

import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import About from "../about";

test("renders about", () => {
  render(<About />);
  expect(screen.getByText(/Hello About/i)).toBeInTheDocument();
});

 

about.tsx

import React from "react";

function About() {
  return <p>Hello About</p>;
}

export default About;

 

예제를 보면 아주 간단한 예제이며 단순히 Hello About이라는 텍스트를 표시해 주는 컴포넌트를 테스트하는 코드입니다

우선 jest 사용할 때와 마찬가지로 test 함수를 사용해서 test단위를 지정합니다

 

이후 render 함수를 사용해 테스트를 진행하고 싶은 컴포넌트를 렌더링 합니다

이제 중요한 부분은 아래 코드입니다

expect(screen.getByText(/Hello About/i)).toBeInTheDocument();

 

이전에 jest에서는 expect의 함수 나 클래스를 넣어 테스트를 진행하였는데 이번에는 컴포넌트를 테스트하는 거 기 때문에

컴포넌트 안에 있는 Text가 현재 도큐먼트에 있는지 테스트를 진행합니다

 

아직 이해가 잘 안 될 수도 있으니 주요 함수와 주요 Matchers 들을 정리해 보았습니다

 

주요 함수

  • render : 이 함수는 react 컴포넌트를 렌더링 합니다. 이 함수를 통해 원하는 컴포넌트를 렌더링 한 후에 그 결과를 쿼리 하거나 이벤트를 발생시킬 수 있습니다.
  • screen : 이 객체는 렌더링 된 컴포넌트를 쿼리 하는 데 사용이 되며 여러 가지 쿼리 메서드가 있습니다
    • getBy* : 이 메서드는 주요 실렉터와 일치하는 DOM 요소를 찾으며 만약 일치하는 요소가 없다면 오류를 발생시킵니다
    • queryBy* : getBy* 와 비슷하지만 일치하는 요소가 없을 때 오류를 발생시키는 게 아닌 null을 반환합니다
    • findBy* : 비동기적 요소를 찾으며 주어진 셀렉터와 일치하는 요소가 나타날 때까지 기다립니다
  • fireEvent : DOM 이벤트를 발생시킵니다. 이 함수를 사용하여 버튼 클릭, 텍스트 입력 등과 같은 이벤트를 시뮬레이션할 수 있습니다

주요 Matchers

  • toBeInTheDocument : 선택한 요소가 문서(돔)에 존재하는지 확인합니다
  • toBeVisible : 선택한 요소가 사용자에게 보이는지 확인합니다
  • toHaveTextContent : 선택한 요소가 주어진 텍스트를 포함하고 있는지 확인합니다
  • toHaveValue : input 또는 textarea 요소가 주어진 값을 가지고 있는지 확인합니다
  • toBeDisabled, toBeEnabled : 선택한 요소가 비활성화되어있는지 또는 활성화되었는지 확인합니다

 

그러면 한번 Todo list 컴포넌트를 TDD를 통해 만들어보도록 하겠습니다

 

Todo List 컴포넌트 만들기

우선 요구사항은 아래와 같습니다

요구사항

  1. 사용자는 할 일을 입력할 수 있는 텍스트 필드가 있어야 합니다.
  2. 사용자는 입력한 할 일을 추가할 수 있는 '추가' 버튼이 있어야 합니다.
  3. 사용자는 추가한 할 일을 목록에서 볼 수 있어야 합니다.
  4. 사용자는 할 일 목록의 각 항목 옆에 있는 '완료' 버튼을 클릭하여 해당 항목을 완료 상태로 변경할 수 있어야 합니다.
  5. 사용자는 완료된 할 일 항목 옆에 있는 '삭제' 버튼을 클릭하여 해당 항목을 목록에서 삭제할 수 있어야 합니다.

우선 이 요구사항을 토대로 테스트 케이스를 작성해 보도록 하겠습니다

 

테스트 케이스

  1. '추가' 버튼을 클릭하면 텍스트 필드의 내용이 할 일 목록에 추가되는지 테스트합니다.
  2. '추가' 버튼을 클릭했을 때 텍스트 필드가 비어 있으면 할 일이 목록에 추가되지 않는지 테스트합니다.
  3. '완료' 버튼을 클릭하면 해당할 일 항목이 완료 상태로 표시되는지 테스트합니다.
  4. '삭제' 버튼을 클릭하면 해당할 일 항목이 목록에서 삭제되는지 테스트합니다.

작성한 테스트 케이스 기준으로 테스트 코드를 작성해보독 하겠습니다

Red

우선 테스트를 실패하는 테스트 코드를 작성해 보도록 하겠습니다

import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import Todo from "../todo/todo";

test("renders element test", () => {
  // todo 컴포넌트 렌더링
  render(<Todo />);

  //placheholdertext 를 사용하여 add To do placehdoler가 있는 input를 가져온다
  const inputEl = screen.getByPlaceholderText("add To do");
  //가져온 input이 돔에 있는지 체크
  expect(inputEl).toBeInTheDocument();

  //추가 버튼이 있는지 체크 한다
  const addButton = screen.getByText("추가");
  expect(addButton).toBeTruthy();

  //fireEvent 를 이용하여 input 엘리먼트에 value를 변경해준다
  fireEvent.change(inputEl, { target: { value: "" } });

  //그리고 추가 버튼에 매팅되어있는 onClick 이벤트를 실행한다
  fireEvent.click(addButton);

  //만약 value가 빈값이면 todo가 추가되면 안되기때문에 todo-item의 갯수를 확인한다
  const todoItems = screen.queryAllByTestId("todo-item");
  expect(todoItems.length).toBe(0);

  //다시 input의 value를 수정하고 추가버튼을 클릭한다
  fireEvent.change(inputEl, { target: { value: "todo1" } });
  fireEvent.click(addButton);

  //돔에 방금 추가한 todo1이 있는지 확인하다
  const todo1Text = screen.getByText("todo1");
  expect(todo1Text).toBeInTheDocument();

  //돔에 todo가 추가되고 완료 버튼도 같이 추가가 되었는지 확인한다
  const submitBtn = screen.queryAllByTestId("todo-submit");
  expect(submitBtn.length).toBe(1);

  //돔에 todo가 추가되고 삭제 버튼도 같이 추가가 되었는지 확인한다
  const deleteBtn = screen.queryAllByTestId("todo-delete");
  expect(deleteBtn.length).toBe(1);

  //완료 버튼을 클릭하여 todo가 완료상태로 변경되는지 체크한다
  fireEvent.click(submitBtn[0]);
  const success = screen.queryAllByTestId("todo-success");
  expect(success.length).toBe(1);

  //삭제 버튼을 클릭하여 todo가 삭제가 되었는지 체크한다
  fireEvent.click(deleteBtn[0]);
  const todoItemsDe = screen.queryAllByTestId("todo-item");
  expect(todoItemsDe.length).toBe(0);
});

각 단계는 아래와 같습니다

  1. 우선 getByPlaceholderText로 input 엘리먼트가 현재 돔에 있는지 확인합니다
  2. 그리고 Todo 추가 버튼이 있는지 체크합니다
  3. 이후 fireEvent.change을 이용해서 현재 input 엘리먼트에 value를 변경해 줍니다 이때 값은 빈값을 넣습니다
  4. 그리고 추가 버튼을 클릭합니다. 현재 value가 빈값이기 때문에 todo-item이 추가가 안되었는지 확인합니다
  5. 확인이 되었으면 다시 input 엘리먼트에 value를 변경합니다 이제는 실제 값을 넣습니다
  6. 실제 값이 들어갔기 때문에 이버에는 todo-item이 추가가 되었는지 확인합니다
  7. 이후 todo-item이 추가가 되고 완료 버튼, 삭제 버튼도 같이 추가가 되었는지 확인합니다
  8. 그리고 완료 버튼을 클릭하여 현재 todo에 상태에 완료로 변경되었는지 확인합니다
  9. 마지막으로 삭제 버튼을 클릭하여 todo가 삭제가 되었는지 확인합니다

 

Green

이제 작성된 테스트 코드 기반으로 이 테스트를 통과하는 컴포넌트를 만들어보겠습니디ㅏ

import { useState } from "react";

interface TodoItem {
  value: string;
  isDone: boolean;
}

function Todo() {
  const [value, setValue] = useState("");
  const [todo, setTodo] = useState<TodoItem[]>([]);

  const handleAddTodo = () => {
    if (value === "") {
      return;
    }
    setTodo([
      ...todo,
      {
        value: value,
        isDone: false,
      },
    ]);
    setValue("");
  };

  const handleDoneTodo = (idx: number) => {
    setTodo((prevTodo) =>
      prevTodo.map((todo: TodoItem, index: number) =>
        idx === index ? { ...todo, isDone: true } : todo
      )
    );
  };

  const handleRemoveTodo = (idx: number) => {
    setTodo((prevTodo) =>
      prevTodo.filter((todo: TodoItem, index: number) => index !== idx)
    );
  };

  return (
    <>
      <input
        type="text"
        value={value}
        placeholder="add To do"
        onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
          setValue(e.target.value)
        }
      />
      <button onClick={handleAddTodo}>추가</button>
      <ul>
        {todo.map((todo: TodoItem, idx: number) => (
          <li key={idx} data-testid="todo-item">
            {todo.value}
            {todo.isDone ? <p data-testid="todo-success">완료</p> : null}
            <button
              data-testid="todo-submit"
              onClick={() => handleDoneTodo(idx)}
            >
              완료
            </button>
            <button
              data-testid="todo-delete"
              onClick={() => handleRemoveTodo(idx)}
            >
              삭제
            </button>
          </li>
        ))}
      </ul>
    </>
  );
}
export default Todo;

작성한 테스트 코드 기반으로 컴포넌를 개발하였습니다

이제 테스트를 진행하면 아래와 같이 테스트가 성공했다고 표시가 됩니다

 

 

Refactor

이제 리팩토링 단계입니다 리팩토링 단계에서는 작성한 컴포넌트를 리팩터링 할 수도 있고

작성한 테스트 코드를 리팩토링 할 수도 있습니다

 

지금은 Todo 컴포넌트는 큰 문제가 보이지 않지만 Todo 테스트 코드는 많은 문제가 보여서 한번 개선해 보도록 하겠습니다

개선 포인트는 아래와 같습니다

  1. 테스트 케이스를 더 작은 단위로 분리해야 될 거 같습니다. 테스트 케이스를 분리하면 각 케이스별 기능이 잘 동작하는지 명확하게 확인이 가능하고 디버깅이 쉽습니다
  2. 테스트 케이스는 실행순서에 의존해선 안된다는 원칙이 있습니다 각 테스트 케이스는 독립적으로 실행이 되어야 돼서 테스트 케이스 실행순서에 따라 결과가 달라지면 안 됩니다. 따라서 각 테스트가 독립적으로 실행되도록 개선해야 될 거 같습니다 여기서 중점은 각 테스트 케이스별 필요한 상태를 설정해주어야 하는데 이러한 작업을 테스트 케이스 setup이라고 합니다 jest에서는 보통 beforeEach를 통해 setup을 할 수 있는데 이번에는 beforeEach 없이 한번 setup을 해보겠습니다

개선된 테스트 코드

import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import Todo from "../todo/todo";

const setup = () => {
  render(<Todo />);
  //placheholdertext 를 사용하여 add To do placehdoler가 있는 input를 가져온다
  const inputEl = screen.getByPlaceholderText("add To do");

  //추가 버튼을 가져온다
  const addButton = screen.getByText("추가");
  return { inputEl, addButton };
};

test("renders to input element to document", () => {
  const { inputEl } = setup();

  //input element 가 돔에 있는지 체크
  expect(inputEl).toBeInTheDocument();
});

test("renders to add Button element to document", () => {
  const { addButton } = setup();

  //추가 버튼이 있는지 체크 한다
  expect(addButton).toBeTruthy();
});

test("submit to todo Add Button empty value input element", () => {
  const { inputEl, addButton } = setup();

  //fireEvent 를 이용하여 input 엘리먼트에 value를 변경해준다
  fireEvent.change(inputEl, { target: { value: "" } });

  //그리고 추가 버튼에 매팅되어있는 onClick 이벤트를 실행한다
  fireEvent.click(addButton);

  //만약 value가 빈값이면 todo가 추가되면 안되기때문에 todo-item의 갯수를 확인한다
  const todoItems = screen.queryAllByTestId("todo-item");
  expect(todoItems.length).toBe(0);
});

test("submit to todo Add button no empty value input element", () => {
  const { inputEl, addButton } = setup();

  //다시 input의 value를 수정하고 추가버튼을 클릭한다
  fireEvent.change(inputEl, { target: { value: "todo1" } });
  fireEvent.click(addButton);

  //돔에 방금 추가한 todo1이 있는지 확인하다
  const todo1Text = screen.getByText("todo1");
  expect(todo1Text).toBeInTheDocument();
});

test("renders to success button in document", () => {
  const { inputEl, addButton } = setup();
  fireEvent.change(inputEl, { target: { value: "todo1" } });
  fireEvent.click(addButton);

  //돔에 todo가 추가되고 완료 버튼도 같이 추가가 되었는지 확인한다
  const submitBtn = screen.queryAllByTestId("todo-submit");
  expect(submitBtn.length).toBe(1);
});

test("renders to delete button in document", () => {
  const { inputEl, addButton } = setup();
  fireEvent.change(inputEl, { target: { value: "todo1" } });
  fireEvent.click(addButton);

  //돔에 todo가 추가되고 삭제 버튼도 같이 추가가 되었는지 확인한다
  const deleteBtn = screen.queryAllByTestId("todo-delete");
  expect(deleteBtn.length).toBe(1);
});

test("click to todo success button", () => {
  const { inputEl, addButton } = setup();
  fireEvent.change(inputEl, { target: { value: "todo1" } });
  fireEvent.click(addButton);

  const submitBtn = screen.queryAllByTestId("todo-submit");
  fireEvent.click(submitBtn[0]);
  const success = screen.queryAllByTestId("todo-success");
  expect(success.length).toBe(1);
});

test("click to todo delete button", () => {
  const { inputEl, addButton } = setup();
  fireEvent.change(inputEl, { target: { value: "todo1" } });
  fireEvent.click(addButton);

  const deleteBtn = screen.queryAllByTestId("todo-delete");
  fireEvent.click(deleteBtn[0]);
  const todoItemsDe = screen.queryAllByTestId("todo-item");
  expect(todoItemsDe.length).toBe(0);
});

setup 함수를 별도로 만들어서 Todo 컴포넌트 렌더링, 그리고 필요한 각 케이스별 필요한 버튼 및 input 엘리먼트를 가져오는 부분을 통합하였습니다

 

그리고 각 테스트 케이스별 테스트를 독립적으로 분리하여 테스트 케이스 간 영향이 없도록 개선해 보았습니다

 

마치며

jest만 이용해서 함수를 테스트했을 때랑은 생각해야 되는 부분이 많이 달라지고 많아진 거 같습니다

아무래도 실제 사용자가 앱을 사용하듯이 테스트를 진행하는 거라서 테스트 케이스도 더 구체적으로 생각을 해야 되는 거 같습니다

오늘은 간단한 Todo 앱을 작성해 보았는데 다음에는 비동기 작업, 상태 관리 등이 포함된 더 복잡한 컴포넌트 테스트에 대해 알아보도록 하겠습니다

'React' 카테고리의 다른 글

Nextjs 14 공개  (0) 2023.11.04
React 에서 Jest 를 활용하여 TDD 배우기 - 1  (0) 2023.07.22
React CI/CD 구축 해보기 3(EC2 + Github Actions + CodeDeploy)  (0) 2023.03.26
React CI/CD 구축 해보기 2(EC2 + Github Actions + CodeDeploy)  (0) 2023.03.22
React CI/CD 구축 해보기 1(EC2+Github Actions + CodeDeploy)  (0) 2023.03.19
    'React' 카테고리의 다른 글
    • Nextjs 14 공개
    • React 에서 Jest 를 활용하여 TDD 배우기 - 1
    • React CI/CD 구축 해보기 3(EC2 + Github Actions + CodeDeploy)
    • React CI/CD 구축 해보기 2(EC2 + Github Actions + CodeDeploy)
    하루루카
    하루루카

    티스토리툴바