개발자로서 코드를 작성해 특정 속성을 구현하거나 특정 문제를 해결하고 나서,
브라우저에서 앱을 보고 테스트한 것을 의미한다.
그렇기 때문에 자동화된 테스팅이 필요하다. 이는 수동 테스트를 대체하는 것은 아니다.
수동 테스트도 매우 중요하다. 거기에 추가하여 해야 한다.
자동화된 테스팅은 추가적인 코드를 작성하여 이 코드가 실행되면서 다른 코드를 테스트한다. 앱의 메인 코드를 말이다.
전체 앱에서도 실제로 잘 작동하는지 확인하기 위해 모든 유닛을 모아서 통합 테스트를 할 수 있다.
테스팅 시 유닛 테스트와 통합 테스트를 구별하는 것이 항상 쉬운 것은 아니다.
흔히 컴포넌트를 테스트 할 때, 한 컴포넌트가 다른 컴포넌트를 사용하기 때문이다..^ㅇ^..
애플리케이션의 전체 워크플로우를 테스트하는 것이다. 즉, 전체 시나리오를 테스트하는 것이다. 사용자가 로그인해서 특정 페이지로 이동하는 것 등을 테스트한다.
따라서 실제로 사용자가 웹 사이트에서 수행하는 작업을 재현하는 것을 목표로 한다.
수동 테스트로도 할 수 있는 것을 자동화하여 테스트 한다.
유닛 테스트와 통합 테스트가 잘 작동한다면, 전체적으로 앱이 잘 작동한다고 꽤 확신할 수 있기 때문에 위 2가지 테스트보다는 많이 사용하지는 않는다.
유닛 테스트와 통합 테스트가 보통 테스트하기도 쉽고, 빠르고 집중적이다.
하지만 전 구간 테스트도 중요하긴 하다.
무엇을 어떻게 테스트해야 할까? 어떻게 기술적으로 테스트 할지를 말하는 것이 아니다. 테스팅 코드에 어떤 종류의 코드를 넣어야 할지에 대해 말하는 것이다.
Jest
React Testing Library
이 둘은 CRA로 생성한 프로젝트에서 작업할 경우 이미 설치 및 설정되어 있다.
CRA로 생성한 프로젝트의 package.json 파일을 보면 디펜던시에 testing-library 패키지를 확인 할 수 있다.
/src/App.test.js
파일이 앱 컴포넌트를 테스트하기 위해 있는 파일이다.
테스팅 파일의 이름은 컴포넌트 파일과 같게 짓는 것이 관례이고, 파일에 .test.js
를 확장자로 붙인다.
App.test.js
이 파일에는 test 함수가 있다.
import "./App.css";
import Greeting from "./components/Greeting";
function App() {
return (
<div className="App">
<p>learn react</p>
<Greeting />
</div>
);
}
export default App;
//테스팅 코드 포함하는 파일
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가 존재한다.
Arrange (준비)
테스트 하고자 하는 컴포넌트 렌더링
테스트 데이터, 테스트 조건, 테스트 환경 설정
Act (실행)
테스트 실행
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 엘리먼트가 문서 안에 있는지 확인
});
앱의 규모가 커질수록 많은 테스트를 가지게 되는데, 이러한 다수의 다른 테스트를 서로 다른 테스트 suite에 넣어서 그룹화하고 정리한다.
앱 내의 하나의 특징 또는 하나의 컴포넌트에 속하는 모든 테스트는 한 테스틑 suite 그룹에 들어간다.
test()
함수가 글로벌 함수이듯, 글로벌 함수인 describe
함수를 사용하여 테스트 suite를 생성한다.
describe()함수의 첫 번째 매개변수:
서로 다른 테스트들이 어디에 속할지에 관한 카테고리 설명
"<Greeting />"
혹은 "Greeting Component"
라고 작성하면 Greeting 컴포넌트에 속하는 테스트라는 의미
describe()함수의 두 번째 매개변수:
익명 함수
함수에 자체 테스트 코드 쓰지 않고 다른 테스트를 넣어서 테스트를 함수에 추가한다.
자체 테스트 코드는 작성하지 않고, 다른 테스트 코드들을 넣으면 된다.
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"가 수트 안에 들어간 것을 확인할 수 있다.클릭 전
클릭 시
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;
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 컴포넌트를 테스트해보자.
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;
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); // 빈 배열이 아닌지 확인
});
});
하지만 이 테스트 방법은 베스트가 아니다!!!
일반적으로 개발 과정에서 테스트를 실행할 때는 서버에 HTTP 요청을 전송하지 않는다!
이유는
많은 네트워크 트래픽을 일으키기 때문에 서버가 요청으로 인해 과부하 걸린다. 특히 많은 요청에 대한 많은 테스트 존재할 경우 서버에 과부하 걸린다.
데이터를 가져오지는 않지만 일부 컴포넌트가 서버로 포스트 요청을 전송한다면 테스트로 인해 데이터베이스에 데이터가 찐으로 삽입되거나 혹은, 서버 내용 변경 될 수도 있다.
왜냐하면 그런 종류의 요청이 전송되는 컴포넌트와 시나리오도 테스트해야 하기 때문이다. 물론 테스트하면서 서버의 내용을 변경시키는 일이 발생하면 안되지만 말이다.
따라서 보통 테스트를 작성할 때 취하는 방식은 진짜 요청을 전송하지 않거나, 혹은 일종의 테스팅 서버로 요청을 전송하는 방법을 사용한다.
당연한 말이긴 한데, 테스트를 작성할 때는 내가 작성하지 않은 코드를 테스트해서는 안된다. 😇
fetch
함수는 내가 작성한것이 아닌, 브라우저 내장 함수이다.
fetch
가 성공적으로 요청을 전송하는지 테스트해서는 안된다.✅ fetch
가 요청 전송에 성공하는지 테스트하지 않아야 하므로, fetch
함수를 소위 mock 함수
로 대체해야 한다.
더미 함수
를 사용하면 된다.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);
});
});
📚 테스트를 실행하고 결과를 가정해보는 데 유용한 툴인 Jest
문서에 나오는 셋업 단계 모두 따를 필요는 없다. CRA로 만들면 Jest는 자동으로 포함되어 있기 때문이다.
그리고 Jest는 리액트만을 위한 툴이 아니라 범용 자바스크립트 테스팅 툴이다. ㅇㅇ..!
더 자세한 비동기 코드 테스트 및 mock 함수 활용에 대해서는 문서를 확인하자.
📚 리액트 테스트를 위한 React Testing Library
예시와 활용 가능한 API가 소개되어 있다.
get, findBy, queryBy의 차이점에 대해 설명이 있고, 이벤트 발생시키기에 대한 내용도 있다.
비동기 코드 설명도 있고 다 있으니 살펴보자.
📚 리액트 훅, 특히 커스텀 훅 테스트를 간단하게 해주는 react-hooks-testing-library
테스트하고 싶은 커스텀 훅이 있을 때 react-hooks-testing-library 익스텐션을 사용하면 된다.