성장을 위한 기록

React Testing Library를 이용한 TodoList (feat. testing-library/react) 본문

React

React Testing Library를 이용한 TodoList (feat. testing-library/react)

B_Tae 2023. 8. 23. 23:56

이 글은 벨로퍼트와 함께하는 리액트 테스팅을 참고하여 작성했습니다.

혹시 Jest나 React Testing Library가 처음이라면 이전 포스트를 한번 읽고 보시는 것도 이해에 도움이 됩니다. 물론 보지 않으셔도 이해할 수 있습니다.

React Testing Library를 이용하여 Todo List 만들기

들어가기 앞서 기반이 되는 벨로퍼트와 함께하는 리액트 테스팅에서는 yarn add react-testing-library jest-dom @types/jest를 사용하고 있습니다. 현재는 라이브러리 이름이 변경되어 react-testing-library가 아닌 @testing-library/react를 사용하고 있습니다.

제가 이 글에서 사용한 라이브러리는 @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, @types/jest입니다. 이는 CRA로 프로젝트를 생성할 경우 같이 설치되기 때문에 버전에 문제가 없다면 별로도 설치할 필요는 없습니다.

 

ps. 추가로 알게된 것은 jest-dom@testing-library/jest-dom은 다른 라이브러리입니다. 다만 @testing-library/jest-dom이 jest-dom을 포함하는 라이브러리기 때문에 별도에 설치 없이 하나의 라이브러리로 사용이 가능합니다.

 

React 프로젝트 생성

앞서 언급한듯 create-react-app을 이용하여 프로젝트를 생성하는 경우 별도에 라이브러리 설치가 필요 없습니다. 버전만 확인해주세요.

프로젝트 생성과 CRA없이 생성하는 방법에 대해서는 생략하도록 하겠습니다.

 

Form 컴포넌트, 테스트코드 작성

제가 구현할 Form 컴포넌트는 2개 기능이 필요합니다. 아래 기능이 들어간 컴포넌트를 작성해보겠습니다.

  • input에 사용자가 입력할 경우 상태가 변경되어야 한다.
  • “저장” 버튼을 눌렀을 때 함수가 실행되며 input에 값이 비워져야한다.

 

컴포넌트 작성

import React, { useCallback, useState } from "react";

const TodoForm = ({ addItem }: { addItem: (text: string) => void }) => {
  const [value, setValue] = useState<string>("");

  const onChangeText = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  }, []);

  const onSubmitTodo = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    addItem(value);
    setValue("");
  };

  return (
    <form onSubmit={onSubmitTodo}>
      <input
        placeholder="할 일을 입력하세요"
        value={value}
        onChange={onChangeText}
      />
      <button type="submit">저장</button>
    </form>
  );
};

export default TodoForm;

코드는 간단하니 설명은 생략하도록 하겠습니다.

 

테스트 코드 작성

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

describe("<TodoForm />", () => {
  /* 
  각 test에 매번 렌더와 input, button 태그를 가져오는 
  중복코드를 하나로 줄이기 위해 setup함수 작성
  */
  const setup = (props?: { addItem: (item: string) => void }) => {
    render(<TodoForm {...props} />);
    const input = screen.getByPlaceholderText("할 일을 입력하세요");
    const button = screen.getByText("저장");
    return {
      input,
      button,
    };
  };

  test("check input, button", () => {
    const { input, button } = setup();

    // 해당 값이 truthy 한 값인지 확인
    expect(input).toBeTruthy();
    expect(button).toBeTruthy();
  });

  test("changes input value", () => {
    const { input } = setup();
    fireEvent.change(input, {
      target: {
        value: "hello world",
      },
    });
    // input값이 변경되는지 확인
    expect(input).toHaveAttribute("value", "hello world");
  });

  test("add item and clear input value", () => {
    // mock 함수
    const addItem = jest.fn();
    const { input, button } = setup({ addItem });
    fireEvent.change(input, {
      target: {
        value: "hello world",
      },
    });
    fireEvent.click(button);
    // 함수 전달값 확인
    expect(addItem).toBeCalledWith("hello world+");
    // input clear 확인
    expect(input).toHaveAttribute("value", "");
  });
});

위 코드는 총 3개에 test코드를 describe로 감싸고 있는 구조입니다.

당연히 모든 test 코드를 하나로 묶어 작성할 수 있고, 필요없는 테스트는 생략할 수 있습니다. 목표 테스트 커버리지에 따라 적절하게 작성해주시면 될것 같습니다.

getByPlaceholderText, getByText등에 매서드를 이용해 렌더된 DOM을 조작할 수 있습니다. 이 밖에 role, id, name 등을 통해서 DOM을 조작할 수 있습니다.

 

jest.fn()은 jest에서 제공하는 mock함수 선언 방법입니다. 여기서는 컴포넌트에 props로 전달되는 addItem를 대신하고 있습니다. props로 전달되는 함수를 작성하지 않았기 때문에 테스트에서는 함수에 실행 결과를 알 수 없습니다. 이때 mock함수를 사용하여 해당 함수가 호출이 되는지 받는 파라미터가 무엇인지 확인 할 수 있습니다. 컴포넌트 코드를 보면 onSubmitTodo함수 내에 addItem(value)함수가 있습니다. toBeCalledWith매서드를 통해 해당 함수가 받는 파라미터에 값을 받아 적절한 값을 받아오는지 확인 할 수 있습니다. ( 결과는 아래에서 확인 가능합니다. )

 

그 밖에 매서드는 이름에서 의미를 파악할 수 있을 것 같아 추가 설명은 생략하도록 하겠습니다. 이후 기회가 된다면 매서드를 정리하는 글을 작성해보겠습니다.

 

아래 테스트 결과에서는 expect(addItem).toBeCalledWith(hello world+");부분에서 실패하도록 “+”를 추가하여 작성했습니다.

  • 테스트 결과

기대값은 “hello world”였지만, 실제 받은 결과는 “hello world+”임으로 실패한 결과를 받았습니다. "+"를 제거하고 테스트 했을 때 통과되면 잘 작성하신거에요.

 

Item 컴포넌트, 테스트코드 작성

제가 구현할 Item 컴포넌트는 3개 기능이 필요합니다. 아래 기능이 들어간 컴포넌트를 작성해보겠습니다.

  • 삭제 버튼을 눌렀을 때 이벤트 설정
  • text에 클릭 이벤트 설정
  • 상태에 따른 Style 변경

 

컴포넌트 작성

import React from "react";

interface ITodo {
  id: number;
  text: string;
  done: boolean;
}

interface IProps {
  todo: ITodo;
  onToggle: (id: number) => void;
  onRemove: (id: number) => void;
}

const TodoItem = ({ todo, onToggle, onRemove }: IProps) => {
  const { id, text, done } = todo;

  return (
    <li>
      <span
        style={{
          textDecoration: done ? "line-through" : "none",
        }}
        onClick={() => onToggle(id)}
      >
        {text}
      </span>
      <button onClick={() => onRemove(id)}>삭제</button>
    </li>
  );
};

export default TodoItem;

마찬가지로 코드에 대한 설명은 하지 않도록 하겠습니다.

 

테스트 코드 작성

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

describe("<TodoItem />", () => {
  const sampleTodo = {
    id: 1,
    text: "hello world",
    done: false,
  };
  const sampleEvent = {
    onToggle: (id: number) => {},
    onRemove: (id: number) => {},
  };

  const setup = (props?: {
    todo?: { id: number; text: string; done: boolean };
    onToggle?: (id: number) => void;
    onRemove?: (id: number) => void;
  }) => {
    const initialProps = { todo: sampleTodo, ...sampleEvent };
    render(<TodoItem {...initialProps} {...props} />);
    const todo = props?.todo || initialProps.todo;
    const span = screen.getByText(todo.text);
    const button = screen.getByText("삭제");
    return {
      span,
      button,
    };
  };

  test("check span, button", () => {
    const { span, button } = setup();
    expect(span).toBeTruthy();
    expect(button).toBeTruthy();
  });

  it("show line-through", () => {
    const { span } = setup({ todo: { ...sampleTodo, done: true } });
    expect(span).toHaveStyle("text-decoration: line-through;");
  });

  it("not show line-through", () => {
    const { span } = setup({ todo: { ...sampleTodo, done: false } });
    // not을 통해 반대 경우 테스트 통과
    expect(span).not.toHaveStyle("text-decoration: line-through;");
  });

  it("onToggle event", () => {
    const onToggle = jest.fn();
    const { span } = setup({ onToggle });
    fireEvent.click(span);
    expect(onToggle).toBeCalledWith(sampleTodo.id);
  });

  it("onRemove event", () => {
    const onRemove = jest.fn();
    const { button } = setup({ onRemove });
    fireEvent.click(button);
    expect(onRemove).toBeCalledWith(sampleTodo.id);
  });
});

<TudoForm />테스트와 유사한 구조를 가지고 있습니다. 처음 작성된 테스트 관련 코드 부분에 대해서만 설명하고 넘어가도록 하겠습니다.

 

expect(span).toHaveStyle("text-decoration: line-through;");는 이름에서 알 수 있듯 span태그에 style을 검사하는 코드입니다. 문제는 inline-style에 대해 검사한다는 점입니다. 따라서 styled-compoent와 외부 CSS에서 적용한 Style에 경우 해당 테스트를 통과할 수 없습니다. (마지막에 styled-component와 CSS에서 지정한 스타일에 대한 테스트 방법을 정리하도록 하겠습니다.)


.not이 있습니다. 이는 실패를 성공, 성공을 실패로 바꿔줄 수 있습니다. 해당 style을 가지고 있다면 실패, 가지고 있지 않다면 성공을 반환합니다. style에 국한되지 않고 다양하게 사용할 수 있습니다.

 

아래 이미지는 테스트 결과입니다. 테스트를 실패하기 위해 style을 변경했습니다.

  • 테스트 결과

List 컴포넌트, 테스트코드 작성

제가 구현할 List 컴포넌트는 기능이 있지는 않습니다. 2가지만 확인하겠습니다.

  • todo배열에 모든 text가 렌더링이 되는지
  • 렌더링된 Item에 이벤트가 잘 동작하는지 (<TodoItem/>에서 테스트를 했기 때문에 불필요할 수도?)

 

컴포넌트 작성

import React from "react";
import TodoItem from "./TodoItem";

interface ITodo {
  id: number;
  text: string;
  done: boolean;
}

interface IProps {
  todos: ITodo[];
  onToggle: (id: number) => void;
  onRemove: (id: number) => void;
}

const TodoList = ({ todos, onToggle, onRemove }: IProps) => {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          todo={todo}
          key={todo.id}
          onToggle={onToggle}
          onRemove={onRemove}
        />
      ))}
    </ul>
  );
};

export default TodoList;

 

테스트 코드 작성

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

describe("<TodoList />", () => {
  const sampleTodos = [
    {
      id: 1,
      text: "hello world",
      done: true,
    },
    {
      id: 2,
      text: "bye world",
      done: false,
    },
  ];
  const sampleEvent = {
    onToggle: (id: number) => {},
    onRemove: (id: number) => {},
  };

  it("check all todos text", () => {
    render(<TodoList todos={sampleTodos} {...sampleEvent} />);
    sampleTodos.forEach((todo) => {
      screen.getByText(todo.text);
    });
  });

  it("onToggle and onRemove event", () => {
    const onToggle = jest.fn();
    const onRemove = jest.fn();
    render(
      <TodoList todos={sampleTodos} onToggle={onToggle} onRemove={onRemove} />
    );

    fireEvent.click(screen.getByText(sampleTodos[0].text));
    expect(onToggle).toBeCalledWith(sampleTodos[0].id);

    fireEvent.click(screen.getAllByText("삭제")[0]); 
    expect(onRemove).toBeCalledWith(sampleTodos[0].id);
  });
});

<TodoItem />테스트와 다르게 onToggle, onRemove이벤트 테스트를 하나의 테스트로 묶었습니다.

상황에 맞게 테스트를 병합/분리 하시면 됩니다.

 

테스트 코드는 이전과 유사하여 빠르게 넘어가도록 하겠습니다.

 

Todo 컴포넌트, 테스트코드 작성

지금까지 구현한 컴포넌트들을 합쳐 하나의 페이지를 만들도록 하겠습니다.

 

컴포넌트 작성

import React, { useState, useCallback, useRef } from "react";
import TodoList from "./TodoList";
import TodoForm from "./TodoForm";

const Todo = () => {
  const [todos, setTodos] = useState([
    {
      id: 1,
      text: "hello world",
      done: true,
    },
    {
      id: 2,
      text: "bye world",
      done: false,
    },
  ]);

  const addItem = (text: string) => {
    setTodos((todos) =>
      todos.concat({
        id: todos.length + 1,
        text,
        done: false,
      })
    );
  };

  const onToggle = (id: number) => {
    setTodos((todos) =>
      todos.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      )
    );
  };

  const onRemove = (id: number) => {
    setTodos((todos) => todos.filter((todo) => todo.id !== id));
  };

  return (
    <>
      <TodoForm addItem={addItem} />
      <TodoList todos={todos} onToggle={onToggle} onRemove={onRemove} />
    </>
  );
};

export default Todo;

// TodoList 코드 수정 **data-testid="TodoList" 추가**
    <ul data-testid="TodoList">
      {todos.map((todo) => (
        <TodoItem
          todo={todo}
          key={todo.id}
          onToggle={onToggle}
          onRemove={onRemove}
        />
      ))}
    </ul>

기본값 2개와 props로 내려줄 이벤트 함수를 작성했습니다. 그리고 <TodoList />data-testid를 추가하여 코드를 수정했습니다. data-testid는 가 렌더링 되는지 확인하기 위해 추가했습니다. 이 방법을 선택할 때 알아야하는 점이 있습니다. 저는 data-testid 사용에 2가지 생각이 들었습니다. 우선 부정적인 측면으로는 테스트 코드가 아닌 사용되는 컴포넌트를 수정해야한다는 점입니다. 테스트 코드를 위해 컴포넌트를 수정하는 것은 바람직하지 않다고 생각됩니다. 이유는 컴포넌트, 테스트코드 중 하나라도 수정이 들어간다면 다른 하나도 확인이 필요하기 때문입니다. 일을 두번하는 느낌일 것 같아요. 하지만 태그로 찾는 방식은 추천하지 않습니다. 한 화면에 태그가 2개 이상일 경우 특정 짓기 어려운 단점이 있습니다. 지금은 추가할 내용이 없는 todoList이지만 실제 프로젝트에서는 어떤 컨텐츠가 추가될지 모르는 상황이기 때문에 태그를 직접 찾는건 좋지 않다고 생각합니다.

 

이렇게 둘다 단점이면 뭐가 좋을까요? 저는 찾지 못했습니다. 위 방법밖에 없다면 data-testid를 사용하겠습니다. 다른 좋은 방법이 있다면 공유 부탁드립니다 !!

 

테스트 코드 작성

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

describe("<Todo />", () => {
  it("renders TodoForm and TodoList", () => {
    render(<Todo />);
    screen.getByText("저장"); // TodoForm 존재유무 확인
    screen.getByTestId("TodoList"); // TodoList 존재유무 확인
  });

  it("renders defaults todos", () => {
    render(<Todo />);
    screen.getByText("hello world");
    screen.getByText("bye world");
  });

  it("creates new todo", () => {
    render(<Todo />);
    // 새 항목 추가
    fireEvent.change(screen.getByPlaceholderText("할 일을 입력하세요"), {
      target: {
        value: "새 항목 추가하기",
      },
    });
    fireEvent.click(screen.getByText("저장"));
    // 해당 항목이 보여져야합니다.
    screen.getByText("새 항목 추가하기");
  });

  it("toggles todo", () => {
    render(<Todo />);
    const todoText = screen.getByText("hello world");
    expect(todoText).toHaveStyle("text-decoration: line-through;");
    fireEvent.click(todoText);
    expect(todoText).not.toHaveStyle("text-decoration: line-through;");
    fireEvent.click(todoText);
    expect(todoText).toHaveStyle("text-decoration: line-through;");
  });

  it("removes todo", () => {
    render(<Todo />);
    const todoText = screen.getByText("hello world");
    const removeButton = todoText.nextSibling;
    fireEvent.click(removeButton);
    expect(todoText).not.toBeInTheDocument(); // 페이지에서 사라졌음을 의미함
  });
});

자식 컴포넌트에서 작성한 테스트 케이스와 비슷한게 많습니다. 아직 현업 테스트코드를 보지 못했기 때문에 자식, 부모 컴포넌트에 같은 기능을 각각 테스트해야하는지는 의문입니다. 이 부분에 대해서도 의견을 남겨주시면 감사하겠습니다.

 

이제 코드를 살펴보겠습니다. screen.getByTestId("TodoList");이 부분은 새롭게 등장한 부분입니다. 앞서 얘기 했듯 TodoList를 감싸는 태그에 렌더링을 확인하기위해 컴포넌트에 data-testid를 추가했는데, 이 data-testid로 요소를 찾는 매서드 입니다.

다른 하나는const removeButton = todoText.nextSibling;입니다. nextSibling은 todoText다음 요소를 가르킵니다. <TodoItem />을 보면 text다음 요소는 삭제 버튼임으로 removeButton은 삭제 element가 할당되게 됩니다.

 

번외

styled-components, CSS로 작성된 Style 테스트코드 작성하는 방법

  • styled-component 테스트코드 적용하기
      yarn add --dev jest-styled-components
      //__tests__ > TodoItem.test.tsx
    
      ...
      // 테스트 코드에 import 추가
      import "jest-styled-components";
      ...
    
      // 스타일 검사 코드 수정 
      //이전 expect(span).toHaveStyle("text-decoration: line-through;");
      expect(span).toHaveStyleRule("text-decoration", "line-through"); 
      ...
    위와 같이 수정할 경우 styled-component로 작성된 style에 대해 검사할 수 있습니다. 반대로 이렇게 수정했기 때문에 inline-style와 외부 CSS에 대한 Style 검사는 불가능합니다.

 

  • 외부 CSS 테스트코드 적용하기
      //__tests__ > TodoItem.test.tsx
    
      ...
      // 스타일 검사 코드 수정 
      //이전 expect(span).toHaveStyle("text-decoration: line-through;");
      expect(span).toHaveClass("lineThrough");
      ...
    외부 CSS로 적용한 Style에 경우 위 방법으로 확인이 불가능한 것 같습니다. ( 다른 방법을 아직 못찾았어요 ) 제가 생각한 방법으로는 style에 속성이 아닌 ClassName으로 찾는 방법입니다.

이렇게 CSS, styled-compoents 스타일을 검사하는 방법에 대해서 알아봤습니다. 이는 공식문서가 아닌 구글링을 통해 알게된 정보입니다. 따라서 올바르지 않는 방법일 수 있습니다. 이 부분에 대한 좋은 인사이트 혹은 3가지 방법을 다 통과할 수 있는 코드가 있다면 공유 부탁드립니다ㅠㅠ

 

마무리

테스트코드와 함께 todoList를 만들어 봤습니다. 기반이된 벨로퍼트님 글을 보면 단계별로 진행되지만, 저는 강의보다는 제 학습과 정보 전달에 목적을 두고 있기 때문에 다 작성된 마지막 코드만 첨부했습니다. 디테일하게 학습 또는 정보가 필요하신 분들은 참고 란에 걸어둔 링크를 확인해주세요!

저는 연습할 때 TDD형식으로 적용해봤습니다. 아직 익숙하지 않아서 그런지 컴포넌트를 구현하기 전에 테스트 코드를 먼저 작성하는 부분이 어렵게 느껴지더라구요. 코드를 한번에 작성하기 보다는 큰 틀만 잡은 상태에서 하나의 테스트 코드를 작성하고 그 테스트 코드가 통과되도록 하나씩 작성해 나가는 것이 더 빠른 것 같습니다.

 

참고

벨로퍼트와 함께하는 리액트 테스팅

Comments