[React Testing] 리액트 자동화 테스트

summereuna🐥·2023년 8월 14일
0

React JS

목록 보기
69/69

✅ Testing이란?

📝 Manual Testing (수동 테스팅)


개발자로서 코드를 작성해 특정 속성을 구현하거나 특정 문제를 해결하고 나서,
브라우저에서 앱을 보고 테스트한 것을 의미한다.

  • 이는 아주 중요하다. 내가 보는 것이 사용자가 보게 될 것이기 때문이다.
  • 하지만 이런 수동적인 앱 테스팅은 오류 발생이 쉽다.
    왜냐하면 수동으로는 가능한 모든 조합과 시나리오를 전부 테스트하기는 어렵기 때문이다.
    어떤 것을 변경한 것이 앱에서 다른 속성을 손상 시킬 수도 있다. 이를 빨리 발견하면 좋지만 나중에 시간이 흐른 후 잡아내서 추가적인 작업을 해야할 수도 있다.

📝 Automated Testing (자동 테스팅)


그렇기 때문에 자동화된 테스팅이 필요하다. 이는 수동 테스트를 대체하는 것은 아니다.
수동 테스트도 매우 중요하다. 거기에 추가하여 해야 한다.

자동화된 테스팅은 추가적인 코드를 작성하여 이 코드가 실행되면서 다른 코드를 테스트한다. 앱의 메인 코드를 말이다.

  • 장점은 전체 앱을 자동으로 테스트하는 코드를 작성하기 때문에 어떤 것을 바꾸더라도 항상 모든 것을 테스트할 수 있다는 것이다.
  • 시간이 많이 걸리는 것도 아니다. 앱의 서로 다른 개별 구성요소에 대한 테스트를 한다.
    그런 다음 모든 개별 구성요소들을 다 같이 테스트한다. 코드를 변경할 때 마다 말이다.
    모든 것을 상시 테스트할 수 있다.
  • 수동 테스팅과 함께 사용하여 오류를 훨씬 더 일찍 잡을 수 있고 더 나은 코드를 작성해여 앱에 제공할 수 있다.

⚙️ 1. Unit Tests (단위 테스트)


  • 유닛 테스트는 앱의 가장 작은 단위인 개별 빌딩 블럭, 즉 구성 요소(함수, 컴포넌트)에 대한 테스트하는 것이다.
  • 앱을 구성하는 모든 유닛, 즉 모든 함수와 컴포넌트를 테스트하기 때문에 프로젝트에는 일반적으로 많은 유닛 테스트가 포함된다.
  • 유닛 테스트는 가장 일반적이로 중요한 종류의 테스트이다.

⚙️ 2. Integration Tests (통합 테스트)


전체 앱에서도 실제로 잘 작동하는지 확인하기 위해 모든 유닛을 모아서 통합 테스트를 할 수 있다.

  • 통합 테스트는 여러개의 구성 요소의 조합을 테스트한다.
  • 프로젝트에는 유닛 테스트 만큼 많지는 않지만 일반적으로 몇 가지 통합 테스트가 포함된다.

테스팅 시 유닛 테스트와 통합 테스트를 구별하는 것이 항상 쉬운 것은 아니다.
흔히 컴포넌트를 테스트 할 때, 한 컴포넌트가 다른 컴포넌트를 사용하기 때문이다..^ㅇ^..

  • 따라서 일반적으로 통합 테스트도 매우 중요하다.

⚙️ 3. End-toEnd(E2E) Tests (전 구간 테스트)


애플리케이션의 전체 워크플로우를 테스트하는 것이다. 즉, 전체 시나리오를 테스트하는 것이다. 사용자가 로그인해서 특정 페이지로 이동하는 것 등을 테스트한다.

  • 따라서 실제로 사용자가 웹 사이트에서 수행하는 작업을 재현하는 것을 목표로 한다.
    수동 테스트로도 할 수 있는 것을 자동화하여 테스트 한다.

  • 유닛 테스트와 통합 테스트가 잘 작동한다면, 전체적으로 앱이 잘 작동한다고 꽤 확신할 수 있기 때문에 위 2가지 테스트보다는 많이 사용하지는 않는다.
    유닛 테스트와 통합 테스트가 보통 테스트하기도 쉽고, 빠르고 집중적이다.

  • 하지만 전 구간 테스트도 중요하긴 하다.


👉 테스팅 입문으로 유닛 테스트와 통합 테스트에 대해 좀 더 많이 다뤄보자!


✅ 사전 체크

📝 무엇을 어떻게 테스트할까? (What & How)


무엇을 어떻게 테스트해야 할까? 어떻게 기술적으로 테스트 할지를 말하는 것이 아니다. 테스팅 코드에 어떤 종류의 코드를 넣어야 할지에 대해 말하는 것이다.

1. What to test?

  • 서로 다른 구성 요소(빌딩 블럭)를 테스트해야 한다.
  • Unit Tests: 가장 작은 단위의 빌딩 블럭 테스트
    작고 집중된 테스트로 각각 하나의 주요 사항만 테스트한다.

2. How to test?

  • 사용자가 앱과 상호작용 시 발생할 수 있는 성공 및 오류 사례를 테스트해야 한다.
  • 드물지만 가능한 시나리오와 결과도 테스트해야한다.

📝 기술 설정 및 관련된 도구: Jest, React Testing Library


  1. 테스팅 코드를 실행하고 결과를 확인하기 위한 도구: Jest
  2. 리액트 엡에서 리액트 앱과 컴포넌트들을 렌더링하는 것을 시뮬레이팅하는 도구: React Testing Library

이 둘은 CRA로 생성한 프로젝트에서 작업할 경우 이미 설치 및 설정되어 있다.
CRA로 생성한 프로젝트의 package.json 파일을 보면 디펜던시에 testing-library 패키지를 확인 할 수 있다.



✅ 테스팅 연습하기


/src/App.test.js 파일이 앱 컴포넌트를 테스트하기 위해 있는 파일이다.
테스팅 파일의 이름은 컴포넌트 파일과 같게 짓는 것이 관례이고, 파일에 .test.js를 확장자로 붙인다.
App.test.js 이 파일에는 test 함수가 있다.

📍 App.js

import "./App.css";
import Greeting from "./components/Greeting";

function App() {
  return (
    <div className="App">
      <p>learn react</p>
      <Greeting />
    </div>
  );
}

export default App;

📍 App.test.js

//테스팅 코드 포함하는 파일

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

//test 함수 - 글로벌 함수
//1. 첫 번째 인자: 테스트에 대한 설명, 테스트 출력 시 테스트 식별하는데 필요
//2. 두 번째 인자: 테스트 코드 포함하는 익명 함수, 테스트 시 실행되는 코드
test("renders learn react link", () => {
  render(<App />); // 테스트 마지막에 App 컴포넌트 렌더링함
  const linkElement = screen.getByText(/learn react/i); //가상의 화면, 즉 시뮬레이팅된 브라우저에 App 컴포넌트를 렌더링함
  //요소 식별 시 렌더링되는 텍스트로 식별하도록 함 => learn react 라는 텍스트를 대소문자 구분 없이 찾음 (/learn react/)이건 정규식 표현
  expect(linkElement).toBeInTheDocument(); //요소가 실제로 문서에 있는지 확인
});
  • npm test 하면 테스트가 실행되고, 이렇게 테스트 결과를 볼 수 있다.

📝 3 A's: Arrange, Act, Assert


테스트에는 3개의 A가 존재한다.

  1. Arrange (준비)
    테스트 하고자 하는 컴포넌트 렌더링
    테스트 데이터, 테스트 조건, 테스트 환경 설정

  2. Act (실행)
    테스트 실행

  3. Assert (단언)
    테스트 아웃품 검토하여 예상과 같은지 체크하기

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

test("render Hello World as a text", () => {
  // 3As
  //1. Arrange (준비)
  //테스트 하고자 하는 컴포넌트 렌더링
  //테스트 데이터, 테스트 조건, 테스트 환경 설정
  render(<Greeting />);

  //2. Act (실행)
  //테스트 실행
  //여기선 없음

  //3. Assert (단언)
  //테스트 아웃품 검토하여 예상과 같은지 체크하기
  const helloWorldElement = screen.getByText(/hello world/i);
  //screen: 가상 DOM에 액세스할 수 있게 해줌
  //screen의 get 함수가 에러를 발생시켜 엘리먼트를 찾을 수 없으면, find함수가 promise를 반환한다
  expect(helloWorldElement).toBeInTheDocument();
  //테스트 결과값 전달할 수 있는 expect함수
  //expect 함수의 결과에 matcher 함수로 toBeInTheDocument()함수를 사용하여 HTML 엘리먼트가 문서 안에 있는지 확인
});

📝 Test Suites


앱의 규모가 커질수록 많은 테스트를 가지게 되는데, 이러한 다수의 다른 테스트를 서로 다른 테스트 suite에 넣어서 그룹화하고 정리한다.

앱 내의 하나의 특징 또는 하나의 컴포넌트에 속하는 모든 테스트는 한 테스틑 suite 그룹에 들어간다.

test suite 생성하기: describe()

test()함수가 글로벌 함수이듯, 글로벌 함수인 describe함수를 사용하여 테스트 suite를 생성한다.

  1. describe()함수의 첫 번째 매개변수:
    서로 다른 테스트들이 어디에 속할지에 관한 카테고리 설명
    "<Greeting />" 혹은 "Greeting Component"라고 작성하면 Greeting 컴포넌트에 속하는 테스트라는 의미

  2. describe()함수의 두 번째 매개변수:
    익명 함수
    함수에 자체 테스트 코드 쓰지 않고 다른 테스트를 넣어서 테스트를 함수에 추가한다.
    자체 테스트 코드는 작성하지 않고, 다른 테스트 코드들을 넣으면 된다.

📍 Greeting.test.js

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

describe("Greeting Component", () => {
  //자체 테스트 코드는 작성 하지 X

  //다른 테스트들 넣음
  // Greeting 컴포넌트 테스트
  test("render Hello World as a text", () => {
    render(<Greeting />);

    const helloWorldElement = screen.getByText(/hello world/i);
    expect(helloWorldElement).toBeInTheDocument();
  });
});
  • npm test 테스트를 실행해 보면 "render Hello World as a text"가 수트 안에 들어간 것을 확인할 수 있다.

📝 사용자 상호작용 및 state 테스트하기


  • 클릭 전

  • 클릭 시

📍 Greeting.js

import { useState } from "react";

const Greeting = () => {
  const [changedText, setChangedText] = useState(false);
  const changeTextHandler = () => {
    setChangedText((prev) => !prev);
  };
  return (
    <div>
      <h2>Hello World!</h2>
      {!changedText && <p>It's good to see you!</p>}
      {changedText && <p>Changed!</p>}
      <button onClick={changeTextHandler}>Change Text!</button>
    </div>
  );
};

export default Greeting;

📍 Greeting.test.js

import { render, screen } from "@testing-library/react";
import Greeting from "./Greeting";
import userEvent from "@testing-library/user-event";

describe("Greeting Component", () => {
  //✅ 1. Greeting 컴포넌트 테스트
  test("renders Hello World as a text", () => {
    render(<Greeting />);

    const helloWorldElement = screen.getByText(/hello world/i);
    expect(helloWorldElement).toBeInTheDocument();
  });

  //changedText가 true일 때 Changed!가 있는지
  //🔥 가능한한 모든 경우 테스트하기

  //✅ 2. 버튼 클릭 전, It's good to see you 렌더되는지 체크
  test("renders It's good to see you if the button was NOT clicked", () => {
    render(<Greeting />);
    const outputElement = screen.getByText("good to see you", {
      exact: false,
    });
    expect(outputElement).toBeInTheDocument();
  });

  //✅ 3. 버튼 클릭 후, Changed 렌더되는지 체크
  test("renders Changed if the button was clicked", () => {
    //1. Arrange
    render(<Greeting />);

    //2. Act
    //🔥 버튼을 클릭하는 것
    const buttonElement = screen.getByRole("button"); //getByRole로 버튼 엘리먼트 잡아오기
    userEvent.click(buttonElement); //버튼이 클릭되는 이벤트

    //3. Assert
    const outputElement = screen.getByText("Changed", { exact: false });
    expect(outputElement).toBeInTheDocument();
  });

  //✅ 4. 버튼 클릭 후, it's good to see you 렌더 🔥안되는지 한 번 더 체크
  //버튼이 클릭되고 나면 It's good to see you 안보이는지 테스트
  test("does not renders it's good to see you if button was clicked", () => {
    //Arrange
    render(<Greeting />);
    
    //Act
    const buttonElement = screen.getByRole("button"); //getByRole로 버튼 엘리먼트 잡아오기
    userEvent.click(buttonElement); //버튼이 클릭되는 이벤트

    //Assert
    //getByText()는 찾아지지 않으면 오류가 나서 테스트 통과가 안된다.
    //그런데 찾아지지 않는것이 내 목적이기 때문에 이럴 땐 queryByText를 사용하면 된다.
    const outputElement = screen.queryByText("good to see you", {
      exact: false,
    });
    //queryByText()는 찾아지지 않으면 단순히 null을 반환하기 때문에 outputElement가 null인지 확인하는 toBeNull() 메서드를 사용하면 된다.
    expect(outputElement).toBeNull();
  });
});
  • 테스트 결과

📝 연결된 컴포넌트 테스트


만약 <Output /> 컴포넌트로 버튼 클릭시 바뀌는 문장들을 감싸더라도, 반드시 Greeting.test.js에 작성한 테스트 로직을 다른 테스트로 분리할 필요는 없다.

import { useState } from "react";
import Output from "./Output";

const Greeting = () => {
  const [changedText, setChangedText] = useState(false);
  const changeTextHandler = () => {
    setChangedText((prev) => !prev);
  };

  return (
    <div>
      <h2>Hello World!</h2>
      {!changedText && <Output>It's good to see you!</Output>} 
                       //🔥 아웃풋 컴포넌트로 감쌈
      {changedText && <Output>Changed!</Output>}
      <button onClick={changeTextHandler}>Change Text!</button>
    </div>
  );
};

export default Greeting;

<Greeting /> 컴포넌트를 렌더링할 때 컴포넌트 트리 전체를 렌더링한다.
<Greeting /> 컴포넌트를 렌더링할 때 JSX 코드에서 사용된 다른 하위 컴포넌트인 <Output /> 컴포넌트를 무시하지 않고 다 렌더링한다.
이렇게 하나 이상의 유닛, 즉 하나 이상의 컴포넌트가 관련된 것을 통합 테스트 라고 한다.
물론 자체적 논리가 없는 래퍼 컴포넌트를 다루기 때문에 통합 테스트라는 것은 정확한 표현은 아니지만 말이다.

좀 더 복잡하거나 상태를 관리하기 시작한다면 테스트를 분리하면 좋다.
그런 경우엔 그리팅 컴포넌트의 핵심 논리와 테스트를 분리해야 한다.


📝 비동기 코드 테스트


HTTP 비동기 데이터를 받아오는 Async 컴포넌트를 테스트해보자.

📍 Async.js

import { useEffect, useState } from "react";

const Async = () => {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/posts")
      .then((response) => response.json())
      .then((data) => {
        setPosts(data);
      });
  }, []);

  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

export default Async;

📍 Async.test.js

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

describe("Async component", () => {
  test("renders posts if request succeeds", async () => {
    //Arrange
    render(<Async />);
    
    //Act
    //원하는게 렌더링 뿐이므로 Act는 필요 없음
    //useEffect로 자동으로 포스트 가져오니까 Act는 필요 없음

    //Assert: li 아이템 있으면 포스트 가져온 거겠지 체크해보자
    const listItemElements = await screen.findAllByRole("listitem");
    //복수의 요소 얻고 싶으면 All 붙은 메서드 사용

    //그런데!! listitem를 못찾아서 오류가 뜬다.
    //getAllByRole은 🔥즉시 스크린 요소를 찾는다.
    //하지만 http 요청은 🔥비동기이기 때문에 즉각 렌더링이 안된다.
    //그래서 첫 렌더 사이클에서는 리스트 아이템을 못 찾는다.

    // 첫 렌더 사이클이 지나간 후 이펙트가 즉시 실행된다. http 요청이 전송되고 응답이 돌아오면 상태를 업데이트한다.
    //그러면 Async 컴포넌트가 리렌더링된다. 이때서야 비로소 리스트 아이템이 존재하게 된다.

    //이 문제를 피하기 위해 findAllByRole을 사용하면 된다.
    //get 쿼리 대신 사용하는 find 쿼리들은 promise를 반환한다.
    //리액트 테스팅 라이브러리는 이 테스트 과정이 성공할 때 까지 screen을 여러 차례 재평가한다.
    //따라서 findAllByRole은 HTTP 요청이 성공할 때 까지 기다린다.

    //findAllByRole의 세번째 인자로 timeout 기간을 설정할 수 있는데, 디폴트 값으로 1초가 설정되어 있다.
    //1초로도 충분하므로 넘어가고 ㅇㅇ..
    //그리고 테스트 코드는 비동기로 promise를 반활할 수 있으므로 async-await을 추가할 수 있다.

    //복수 요소이기 때문에 listItemElements는 li 아이템 배열이 된다.
    //따라서 배열의 길이 확인하여 배열이 비었다면 렌더링 되지 않은 것으로 판단할 수 있다.
    expect(listItemElements).not.toHaveLength(0); // 빈 배열이 아닌지 확인
  });
});

하지만 이 테스트 방법은 베스트가 아니다!!!

📝 이 방법이 베스트가 아닌 이유: Async.js에서 HTTP 요청을 보내고 있기 때문


일반적으로 개발 과정에서 테스트를 실행할 때는 서버에 HTTP 요청을 전송하지 않는다!

이유는

  1. 많은 네트워크 트래픽을 일으키기 때문에 서버가 요청으로 인해 과부하 걸린다. 특히 많은 요청에 대한 많은 테스트 존재할 경우 서버에 과부하 걸린다.

  2. 데이터를 가져오지는 않지만 일부 컴포넌트가 서버로 포스트 요청을 전송한다면 테스트로 인해 데이터베이스에 데이터가 찐으로 삽입되거나 혹은, 서버 내용 변경 될 수도 있다.
    왜냐하면 그런 종류의 요청이 전송되는 컴포넌트와 시나리오도 테스트해야 하기 때문이다. 물론 테스트하면서 서버의 내용을 변경시키는 일이 발생하면 안되지만 말이다.

따라서 보통 테스트를 작성할 때 취하는 방식은 진짜 요청을 전송하지 않거나, 혹은 일종의 테스팅 서버로 요청을 전송하는 방법을 사용한다.

📝 모의 데이터로 테스트하기: 애초에 http 요청을 서버에 전송하지 않는 방식, 더미 데이터로 테스트하기

당연한 말이긴 한데, 테스트를 작성할 때는 내가 작성하지 않은 코드를 테스트해서는 안된다. 😇

fetch 함수는 내가 작성한것이 아닌, 브라우저 내장 함수이다.

  • 따라서 브라우저 벤더가 이 함수를 올바로 작성했다고 믿고, ❗️fetch가 성공적으로 요청을 전송하는지 테스트해서는 안된다.
  • 대신 ✅ 전송된 요청의 서로 다른 결과에 따라 컴포넌트가 올바로 작동하는지를 테스트해야 한다.
    • 즉, 응답 데이터를 받았을 때 컴포넌트가 제대로 작동하는지 테스트해야 한다.
    • 그리고 에러가 발생했을 때도 제대로 작동하는지 테스트해야 한다.

fetch가 요청 전송에 성공하는지 테스트하지 않아야 하므로, fetch 함수를 소위 mock 함수로 대체해야 한다.

  • 내장 함수를 덮어쓰는 더미 함수를 사용하면 된다.
  • 이는 아주 흔한 시나리오기 때문에 우리가 사용하는 테스팅 툴인 Jest에도 이와 같은 함수를 흉내 내는 내장 지원책이 있다.
import { render, screen } from "@testing-library/react";
import Async from "./Async";

describe("Async component", () => {
  test("renders posts if request succeeds", async () => {
    //fetch함수를 우리가 정의한 새로운 함수로 설정하기
    // jest 객체는 테스팅 코드 내에서 전역으로 활용 가능
    //jest 객체의 유틸리티 메소드 중 하나인 fn 메소드 사용 =>  mock 함수, 즉 더미 함수 만들기
    window.fetch = jest.fn(); //이렇게 내장 fetch 함수를 테스트 코드 내에서 더미 함수로 덮어 씌울 수 있음

    //방금 만든 mock함수를 다시 사용해서 특수 메소드인 mockResolvedValueOnce()를 호출할 수 있다.
    //🔥 이는 fetch함수가 호출되었을 때 결정되어야 하는 값
    //(👉프로미스 응답에 대해 json()메서드로 객체 만듦)을 설정할 수 있게 해준다.
    //따라서 객체에 json의 값으로 응답 받은것으로 사용할 더미 데이터를 담아서 보내주자.
    //반환하는 값 중 무엇을 시뮬레이션할지는 내가 정하면 된다.
    //이 경우 데이터는 Async 컴포넌트의 API 엔드포인트에 대한 배열이므로, json 호출됐을 때 배열을 반환하면 된다.
    window.fetch.mockResolvedValueOnce({
      json: async () => [
        { id: "p1", title: "First post" },
        { id: "p2", title: "Second post" },
        { id: "p3", title: "Third post" },
      ],
    });

    //Arrange
    render(<Async />);

    //Act
    //...

    //Assert: li 아이템 있으면 포스트 가져온 거임
    const listItemElements = await screen.findAllByRole("listitem");
    expect(listItemElements).not.toHaveLength(0);
  });
});
  • 👉 프로미스 응답에 대해 json()메서드로 객체 만들고 있기 때문에 위에도 저렇게 ㅇㅇ!

📚 참고

📚 테스트를 실행하고 결과를 가정해보는 데 유용한 툴인 Jest
문서에 나오는 셋업 단계 모두 따를 필요는 없다. CRA로 만들면 Jest는 자동으로 포함되어 있기 때문이다.
그리고 Jest는 리액트만을 위한 툴이 아니라 범용 자바스크립트 테스팅 툴이다. ㅇㅇ..!
더 자세한 비동기 코드 테스트 및 mock 함수 활용에 대해서는 문서를 확인하자.

📚 리액트 테스트를 위한 React Testing Library
예시와 활용 가능한 API가 소개되어 있다.
get, findBy, queryBy의 차이점에 대해 설명이 있고, 이벤트 발생시키기에 대한 내용도 있다.
비동기 코드 설명도 있고 다 있으니 살펴보자.

📚 리액트 훅, 특히 커스텀 훅 테스트를 간단하게 해주는 react-hooks-testing-library
테스트하고 싶은 커스텀 훅이 있을 때 react-hooks-testing-library 익스텐션을 사용하면 된다.

profile
Always have hope🍀 & constant passion🔥

0개의 댓글