2024. 3. 24. 10:56ㆍE8

React 테스팅
Mocking -특정 동작을 흉내 내는 것
ex) 실제 API를 호출하는 게 아니라, 가짜 payload를 반환하는 mocking function을 만듦
Stubbing -더미를 채워 넣는 것 ex) Child 컴포넌트를 렌더링 하지 않고, 대신 그 자리에<div> 등을 채워 넣음
setup -테스트하기 위한 환경 생성 / mock data, mock function 등을 준비
expectation –원하는 테스트 결과를 만들기 위한 코드를 작성
assertion –정말 원하는 결과가 나왔는지를 검증
const transformUser= (user) => {
const{ name, age, address} = user
return[name, age, address]
}
test("Test transformUser", () => {
// setup
const mockUser = { name: "testName", age: 20, address: "testAddress" };
// expectation
const result = transformUser(mockUser);
// assertion
expect(result).toBe(["testName", 20, "testAddress"]);
});
화이트박스 테스팅은 컴포넌트 내부 구조를 미리 안다고 가정하고 테스트 코드를 작성
블랙박스 테스팅은 컴포넌트 내부 구조를 모른 채 어떻게 동작하는지에 대한 테스트 코드를 작성
Unit Testing
다른 부분과 분리된 작은 코드를 만들고 그것을 테스트
작은 코드는 function, module, class 등을 의미
각 부분이 원하는 대로 동작함을 보장하기 위함
테스트는 서로 분리되어야 함 ex) 특정 컴포넌트가 데이터에 따라 잘 렌더링 되는지를 테스트하는 경우
Integration Testing
앱의 특정 부분이 동작하는지 테스트함 ex) 여러 컴포넌트가 한꺼번에 동작하거나, 어떤 페이지의 부분이 잘 동작하는지를 테스트하는 경우ex) react-router, redux 등이 특정 컴포넌트와 함께 잘 동작하는지를 테스트하는 경우
jest
| Assertion matchers | jest는 풍부한 matcher를 제공하여, 여러 상황에서 match를 체크 expect()는 expectation object를 리턴 이 object의 메서드를 이용해 여러 매칭 상황을 assert |
| Async Assertion | 비동기 상황의 테스트를 처리할 수 있는 여러 방법을 제공 callback, promise, async/await을 모두 활용 |
| Mock functions | mock function을 만듦 모듈 라이브러리 전체를 mocking |
| Lifecycle functions | 각 테스트의 시작과 끝, 전체 테스트의 시작과 끝에 원하는 작업을 할 수 있음 beforeEach, afterEach, beforeAll, afterAll 함수를 활용 describe 블록 안에 사용하면 별도의 scope |
| Grouping | describe 함수를 이용해 여러 test() 함수를 논리적으로 나눔 describe 함수 안에 describe 함수가 중첩될 수 있음 |
| Snapshot Testing | 특정 함수, 모듈, 컴포넌트 등의 결과를 serializable 한 형태의 snapshot으로 저장하고, 추후 변경이 발생했을 때 이전의 snapshot과 새로운 snapshot을 비교하여 변경이 발생했는지를 추측 jest의 주요 기능으로, 코드의 변경이 컴포넌트의 렌더링 결과에 영향을 미치는지를 파악하기에 적합 |
beforeAll, beforeEach, afterEach, afterAll의 순서로 Lifecycle 함수들이 실행
describe 블록 안에 있는 before-*, after-* 함수는 해당 블록의 범위 안에서 실행
describe 함수는 모든 test() 함수 이전에 실행
따라서 test() 함수들은 순차적으로 한꺼번에 실행됨
test("User component", () => {
const mockProps = { name: "test-username", age: 20 }; // setup
const { container } = render(<User {...mockProps} />); // expectation
expect(container.firstChild).toMatchSnapshot(); // assertion
});
jest 활용
Assertion Matchers 활용
function isPythagorean(a, b, c) {
return a * a + b * b === c * c;
}
test("Should 3, 4, 5 pythagorean", () => {
expect(isPythagorean(3, 4, 5)).toBe(true);
});
test("Should 3, 4, 6 not pythagorean", () => {
expect(isPythagorean(3, 4, 6)).toBe(false);
});
function createTodo(id, title, content) {
return { id, title, content };
}
test("Should create user", () => {
const id = 1,
title = "Test todo",
content = "Test content";
expect(createUser(id, title, content)).toEqual({ id, title, content });
});
test("Should create user", () => {
const id = 1,
title = "Test todo",
content = "Test content";
expect(createUser(id, title, content).title).toMatch("Test todo");
});
function transformUser(user) {
const { name, age, address } = user;
return [name, age, address];
}
test("Should contain name after transformUser", () => {
const user = { name: "test name", age: 20, address: "test address" };
expect(transformUser(user)).toContain("test name");
});
test("Should contain name after transformUser", () => {
const user = { name: "test name", age: 20, address: "test address" };
expect(transformUser(user)).not.toContain(30);
});
callback 패턴의 경우, test() 함수가 제공하는 done() 함수를 활용하여 콜백이 끝나고 done()을 호출. 에러가 발생하면 done()의 인자로 에러를 넘김
Promise 패턴의 경우 async/await을 활용하거나, Promise를 리턴
function isPythagoreanAsync(a, b, c) {
return newPromise((resolve) => {
setTimeout(() => {
const result = isPythagorean(a, b, c);
if (result) return resolve(result);
reject(newError("Not pythagorean"));
}, 500);
});
}
test("Should 3, 4, 5 be pythagoreanasync", (done) => {
isPythagoreanAsync(3, 4, 5).then(done).catch(done);
});
test("Should 3, 4, 5 be pythagoreanasync", () => {
return expect(isPythagoreanAsync(3, 4, 5)).resolves.toBe(true);
});
test("Should 3, 4, 6 be not pythagoreanasync", () => {
return expect(isPythagoreanAsync(3, 4, 6)).rejects.toBe("Not pythagorean");
});
Mock functions 활용
jest.fn()을 활용하여, mock function 객체를 만듦
mockReturnValueOnce() 등으로 리턴하는 값을 임의로 조작 / 여러 번 호출하면, 순서대로 세팅된 값을 반환함
mockResolvedValue()로 promise가 resolve 하는 값을 조작
jest.mock()으로 특정 모듈을 mocking
| toHaveBeenCalled | 이 함수가 호출되었는지 검증 |
| toHaveBeenCalledWith(arg1, arg2, ...) | 이 함수가 특정 인자와 함께 호출되었는지 검증 |
| toHaveBeenLastCalledWith(arg1, arg2, ...) | 마지막으로 특정 인자와 함께 호출되었는지 검증 |
test("Should 3, 4, 5 be pythagoreanasync", (done) => {
isPythagoreanAsync(3, 4, 5).then(done).catch(done);
});
test("Should 3, 4, 5 be pythagoreanasync", () => {
return expect(isPythagoreanAsync(3, 4, 5)).resolves.toBe(true);
});
test("Should 3, 4, 6 be not pythagoreanasync", () => {
return expect(isPythagoreanAsync(3, 4, 5)).rejects.toBe("Not pythagorean");
});
Lifecycle functions
beforeEach(() => {setupMockData()})
afterEach(() => {clearMockData()})
Grouping
describe("This is group 1", () => {
describe("This is inner group 1", () => {
test("Test 1", () => {});
});
describe("This is inner group 2", () => {
test("Test 2", () => {});
});
});
Snapshot testing
toMatchSnapshot()을 호출하면, 기존에 스냅샷이 없었을 경우. snap 파일을 만듦
기존 스냅샷이 있을 경우, 새로운 스냅샷과 비교하여 변경사항이 있으면 테스트는 실패함
toMatchInlineSnapshot()을 호출하면 별도의 스냅샷 파일을 만들지 않음
이 경우, 어떻게 스냅샷이 쓰였는지를 하나의 파일 안에서 알 수 있게 됨
test("Snapshot test form", () => {
const { container } = render(<MyForm />);
expect(container.firstChild).toMatchSnapshot();
});
test("Snapshot test form", () => {
const { container } = render(<MyForm />);
expect(container.firstChild).toMatchInlineSnapshot();
});
expect(container.firstChild).toMatchInlineSnapshot(`<div></div>`)
react-testing-library

실제 유저가 사용하는 방식대로 테스트하는 접근
유저가 페이지에서 어떤 DOM 요소에 접근하는 방법을 흉내
React 컴포넌트가 렌더링 한 결과에 대한 접근만 가능
| get | getBy 관련 쿼리는 원하는 요소를 찾지 못할 경우나 여러 개의 요소를 찾을 경우 에러를 던짐 getAllBy 관련 쿼리는 여러 요소를 찾아 배열을 반환 - 원하는 요소를 찾지 못할 경우 에러를 던짐 원소가 반드시 페이지에 존재해야만 하는 경우 활용 |
| find | findBy 관련 : 원하는 원소가 없더라도 비동기적으로 기다림 / 여러 원소를 찾거나, 정해진 timeout 동안 찾지 못하면 에러를 던짐 findAllBy 관련: 여러 원소를 검색해 배열을 반환 / 정해진 timeout 동안 찾지 못하면 에러를 던짐 Promise를 리턴하며, 실패 시 reject, 성공 시 resolve 어떤 유저의 동작 후에 등장하는 원소 등을 테스트하고자 할 때 활용 |
| query | queryBy 관련 : getBy와 비슷하게 원소를 찾아 반환하나, 못 찾을 경우 에러를 던지지 않고 null을 반환함. 여러 원소를 찾으면 에러를 던짐 queryAllBy 관련 : getAllBy와 비슷하게 여러 개의 원소를 찾아 배열로 반환하나, 하나도 찾지 못하면 에러 대신 빈 배열을 반환 특정 요소를 찾을 수 없음을 assertion의 기준으로 둘 때 활용 |
| container | 컴포넌트를 렌더한 결과를 감싸는 원소 queryselector(), querySelectorAll()을 이용해 selector 문법으로 원소를 선택 |
| jest-dom | react-testing-library는 jest를 확장하여, 좀 더 쓰기 편한 assertion을 제공 toBeInTheDocument(), toHaveValue(), toBeDisabled(), toBeVisible() 등, DOM 테스팅에 특히 유용한 assertion 메서드를 제공 |
쿼리의 우선순위
유저가 페이지를 이동하는 방식에 가까운 쿼리일수록 우선순위가 높음
접근성 높은 HTML을 작성할수록 테스트가 용이한 코드
ByRole
자주 사용되는 Role : button, checkbox, listitem, heading, img, form, textbox, link
자주 사용되는 accessible name : button -텍스트 / label -텍스트 / a -텍스트 / img -alt 텍스트
TestForm.jsx
function TestForm() {
const formRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
formRef.current.reset();
};
return (
<form onSubmit={handleSubmit} ref={formRef}>
<label htmlFor="username">Username</label>
<input id="username" type="text" name="username" />
<input type="submit" value="Submit" />
</form>
);
}
test("제출버튼을찾아클릭하면, Username 인풋이비워진다.", () => {
const { getByRole } = render(<TestForm />);
const usernameInput = getByRole("textbox", { name: "Username" });
const submitButton = getByRole("button", { name: "Submit" });
userEvent.type(usernameInput, "test username");
userEvent.click(submitButton);
expect(usernameInput).toHaveValue("");
});
Text
유저가 볼 수 있는 Text 값을 기준으로 쿼리를 찾음
| ByLabelText | label과 연관된 원소를 찾음 |
| ByPlaceholderText | placeholder와 연관된 원소를 찾음 |
| ByText | 주어진 Text와 연관된 원소를 찾음 |
| ByDisplayValue | input, textarea, select 등의 value를 기준으로 원소를 찾음 |
test("제출버튼을찾아클릭하면, Username 인풋이비워진다.", () => {
const { getByLabelText, getByText } = render(<SimpleTestForm />);
const usernameInput = getByLabelText("Username");
const submitButton = getByText("Submit");
userEvent.type(usernameInput, "test username");
userEvent.click(submitButton);
expect(usernameInput).toHaveValue("");
});
semantic queries
유저에게 보이지 않지만, 접근성 스펙에 적합한 alt, title을 이용하여 원소를 검색
ByAltText – img, area, input 등의 alt 속성으로 원소를 검색
ByTitle – title 속성으로 원소를 검색
Test ID
data-testid 속성을 원하는 원소에 지정하고, 쿼리를 이용해 찾음
유저가 해당 속성을 기반으로 화면의 요소를 찾는 게 아니므로 우선순위가 낮음
다른 쿼리로 테스트를 작성할 수 없을 때 이 쿼리를 백도어로 활용
test("제출버튼을찾아클릭하면, Username 인풋을비운다.", () => {
const { getByTestId } = render(<SimpleTestForm />);
const usernameInput = getByTestId("username-input");
const submitButton = getByTestId("submit-button");
userEvent.type(usernameInput, "test username");
userEvent.click(submitButton);
expect(usernameInput).toHaveValue("");
});
유저 이벤트
내장 이벤트 함수인 fireEvent, createEvent를, 좀 더 직관적이고 범용적으로 사용할 수 있도록 만든 라이브러리
click, type, keyboard, upload, hover, tab 등 유저가 실제로 웹페이지를 사용하며 만드는 이벤트를 메서드로 제공
userEvent.click(submitButton, { shiftKey: true}, { clickCount: 5})
user event click
test("숨은텍스트를보여준다.", () => {
const text = "Hidden text!";
const { getByRole, queryByText } = render(<Expansion text={text} />);
expect(queryByText("Hidden text!")).toBe(null); // 화면에 보이지 않아야 함
const showButton = getByRole("button", { name: "Expand" });
expect(showButton).toBeInTheDocument();
userEvent.click(showButton); // 버튼 누르면 내용 보이게
expect(queryByText(text)).toBeInTheDocument();
});
function Expansion({ text }) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<button onClick={() => setExpanded((b) => !b)}>Expand</button>
{expanded && <div>{text}</div>}
</div>
);
}
user event type
userEvent.type(inputElement, 'react advanced')
userEvent.type(inputElement, 'react{space}advanced{enter}')
await userEvent.type(inputElement, 'react advanced', { delay: 300})
test("Typeahead에서쿼리에따른검색결과를보인다", () => {
const mockSearchData = ["kim", "song", "shim", "park", "yoon"];
const { getByPlaceholderText, getAllByRole } = render(
<Typeahead searchData={mockSearchData} />
);
const inputElement = getByPlaceholderText("Type name...");
userEvent.type(inputElement, "s");
expect(getAllByRole("listitem").length).toBe(2);
userEvent.clear(inputElement);
expect(getAllByRole("listitem").length).toBe(mockSearchData.length);
});
유저 정보 토글앱
import React, { useState } from "react";
export default function SimpleToggle() {
const [show,setShow] = useState(false)
return (
<div>
{ !show && <div>유저 정보를 보려면 버튼을 누르세요.</div> }
{ show && ( <ul>
<li>Email - elice@elicer.com</li>
<li>Address - 서울시 강남구 테헤란로 401</li>
</ul> )}
<button onClick={() => setShow(!show)}>유저정보 {!show ? "보기":"가리기"}</button>
</div>
);
}
import { screen, render } from "@testing-library/react";
import SimpleToggle from "./App";
import userEvent from '@testing-library/user-event'
describe("앱을 렌더링합니다.", () => {
test("버튼이 있습니다.", () => {
render(<SimpleToggle />);
const button = screen.getByRole("button", { name: "유저정보 보기" }); // "유저정보 보기" 버튼을 찾기
expect(button).toBeInTheDocument(); // 버튼 존재하는지 체크
});
test("버튼을 누르지 않았을 시, 유저 정보 안내문이 보입니다.", () => {
render(<SimpleToggle />);
const text = screen.getByText("유저 정보를 보려면 버튼을 누르세요.");
expect(text).toBeInTheDocument();
});
});
describe("토글 기능을 테스트합니다.", () => {
test("버튼을 눌렀을 시, 유저 정보가 보입니다.", () => {
render(<SimpleToggle />);
const infoText = /유저 정보를 보려면 버튼을 누르세요./i
const text = screen.getByText(infoText);
expect(text).toBeInTheDocument(); // 있는지 체크
const button = screen.getByRole("button", { name: "유저정보 보기" });
userEvent.click(button); // 버튼을 클릭
expect(screen.queryByText(infoText)).not.toBeInTheDocument();
// 위에서 찾은 텍스트가 보이지 않는지 체크
const email = screen.getByText("Email - elice@elicer.com"); // 이메일 정보를 찾기
expect(email).toBeInTheDocument();
const address = screen.getByText("Address - 서울시 강남구 테헤란로 401");
expect(address).toBeInTheDocument();
expect(button).toHaveTextContent("유저정보 가리기")
});
test("버튼을 두번 누르면, 유저 정보가 보이지 않습니다.", () => {
render(<SimpleToggle />);
const button = screen.getByRole("button", { name: "유저정보 보기" });
userEvent.click(button, { clickCount:1});
const email = screen.getByText("Email - elice@elicer.com")
expect(email).toBeInTheDocument(); // 이메일 정보가 문서에 있는지 체크
userEvent.click(button, { clickCount:1});
expect(email).not.toBeInTheDocument(); // 이메일 정보가 문서에서 사라졌는지 체크
});
});
ShoppingCart 컴포넌트
mockCarts는 가짜 아이템
만일 이 컴포넌트를 실제 앱에 사용한다면 해당 데이터를 서버로부터 받아와야겠지만, 지금은 받아왔다고 가정하고 컴포넌트가 유저에게 올바른 데이터를 보여주는지 테스트
import React from "react";
const getDiscountPrice = ({ price, quantity, discount }) => (price - price * discount) * quantity;
const getTotalPrice = (carts) => carts.map(({ price, quantity, discount }) => getDiscountPrice({ price, quantity, discount }))
.reduce((acc, cur) => acc + cur, 0);
export default function ShoppingCart({ carts }) {
return (
<div>
<h2>쇼핑 목록</h2>
<ul>
{carts.map(({ id, image, name, price, quantity, discount }) => (
<Cart key={id} image={image} name={name} quantity={quantity} price={getDiscountPrice({ price, quantity, discount })} />
))}
</ul>
<div>총 가격 : {getTotalPrice(carts)}원</div>
</div>
);
}
function Cart({ image, name, quantity, price }) {
return (
<li>
<div>
<img src={image} alt={name} />
</div>
<div>
<div>개수 : {quantity}</div>
<p>상품 가격 : {price}원</p>
</div>
</li>
);
}
import { screen, render } from "@testing-library/react";
import ShoppingCart from "./App";
const getDiscountPrice = ({ price, quantity, discount }) => (price - price * discount) * quantity
const mockCarts = [
{
id: 1,
name: "강아지 신발 사이즈 xs",
price: 14000,
discount: 0.1,
quantity: 1,
image: "https://via.placeholder.com/150.png",
},
{
id: 2,
name: "베이비 물티슈 200매",
price: 2000,
discount: 0.2,
quantity: 10,
image: "https://via.placeholder.com/150.png",
},
{
id: 3,
name: "강아지 사료 4kg",
price: 40000,
discount: 0.3,
quantity: 3,
image: "https://via.placeholder.com/150.png",
},
];
describe("ShoppingCart 컴포넌트를 렌더링합니다.", () => {
test("헤더가 있습니다.", () => {
render(<ShoppingCart carts={mockCarts} />);
const header = screen.getByRole("heading", { name: "쇼핑 목록" });
expect(header).toBeInTheDocument(); // 헤더가 화면에 있는지 테스트
});
test("아이템 3개를 보여줍니다.", () => {
render(<ShoppingCart carts={mockCarts} />);
const lis = screen.getAllByRole("listitem")
expect(lis.length).toBe(3) // 모두 총 3개인지 체크
});
test("아이템의 이미지를 노출합니다.", () => {
render(<ShoppingCart carts={mockCarts} />);
const image = screen.getByAltText("강아지 사료 4kg")
expect(image).toHaveAttribute('src',mockCarts[2].image) // 이미지의 src attribute가 데이터와 같은지 체크
});
});
describe("계산된 값을 노출합니다.", () => {
test("할인된 값을 보여줍니다.", () => {
render(<ShoppingCart carts={mockCarts} />);
const { price, discount, quantity } = mockCarts[0];
const discountPrice = (price - price * discount) * quantity
const prices = screen.getAllByText(/상품 가격 :/i);
expect(prices[0]).toHaveTextContent(`상품 가격 : ${discountPrice}`);
});
test("총 가격을 보여줍니다.", () => {
render(<ShoppingCart carts={mockCarts} />);
const getTotalPrice = (carts) => carts.map(({ price, quantity, discount }) => getDiscountPrice({ price, quantity, discount }))
.reduce((acc, cur) => acc + cur, 0);
const totalPrice=getTotalPrice(mockCarts)
expect(screen.getByText(`총 가격 : ${totalPrice}원`)).toBeInTheDocument()
});
});
#코딩독학 #코딩인강 #코딩배우기 #개발자 #코딩이란 #코딩교육
#프론트엔드부트캠프 #백엔드부트캠프 #국비지원부트캠프 #개발자 #백엔드 #AI부트캠프 #개발자국비지원 #백엔드개발자 #프론트엔드개발자
'E8' 카테고리의 다른 글
| React 스타일링 2 (0) | 2024.03.30 |
|---|---|
| SSR (0) | 2024.03.25 |
| REDUX (1) | 2024.03.23 |
| 상태 관리 (0) | 2024.03.20 |
| React의 비동기 통신 (1) | 2024.03.17 |
