๐ Best Practices for Writing Tests with React Testing Library (opens in a new tab)
๐๏ธ ๋ฒ์ญ ๋ ์ง: 2024.05.31
๐ง ๋ฒ์ญํ ํฌ๋ฃจ: ๋ง์คํฐ์(๋ช ์ฌ์)
Updated On March 12, 2024 Keyword : React, React-Testing-Library, Testing
React Testing Library๋ก ํ ์คํธ๋ฅผ ์์ฑํ๋ ๋ชจ๋ฒ ์ฌ๋ก
React ํ ์คํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ (opens in a new tab)๋ React ์ปดํฌ๋ํธ ํ ์คํธ๋ฅผ ์ํ ์ฌ์ค์์ ํ์ค์ด ๋์์ต๋๋ค. ์ฌ์ฉ์ ๊ด์ ์์์ ํ ์คํธ์ ์ง์คํ๊ณ , ํ ์คํธ์์๋ ๊ตฌํ ์ธ๋ถ ์ฌํญ์ ๊ฐ์ถ๋ ๊ฒ์ด ์ฑ๊ณต์ ์ฃผ์ ์ด์ ์ค ์ผ๋ถ์ ๋๋ค.
์ฌ๋ฐ๋ฅด๊ฒ ์์ฑ๋ ํ ์คํธ๋ regressions์ ๋ฒ๊ทธ๊ฐ ์๋ ์ฝ๋๋ฅผ ๋ฐฉ์งํ๋ ๋ฐ ๋์์ด ๋ ๋ฟ๋ง ์๋๋ผ, React Testing Library์ ๊ฒฝ์ฐ ์ปดํฌ๋ํธ์ ์ ๊ทผ์ฑ (opens in a new tab)๊ณผ ์ ๋ฐ์ ์ธ ์ฌ์ฉ์ ๊ฒฝํ์ ํฅ์์ํต๋๋ค. React ์ปดํฌ๋ํธ๋ก ์์ ํ ๋๋ ์ ์ ํ ํ ์คํธ ๊ธฐ๋ฒ์ ์ฌ์ฉํ์ฌ React ํ ์คํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์์ ํํ ๋ฐ์ํ๋ ์ค์๋ฅผ ํผํ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค.
์ด ๊ธ์์๋ ํน์ ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ์ฌ ํ
์คํธ ์ปค๋ฒ๋ฆฌ์ง๋ฅผ ๊ฐ์ ํ๋ ๋ฐฉ๋ฒ, *ByRole ์ฟผ๋ฆฌ์ ์ค์์ฑ, ์ฌ์ฉ์ ์ํธ์์ฉ ์๋ฎฌ๋ ์ด์
์ ์ํด fireEvent๋ณด๋ค userEvent ๋ฉ์๋๋ฅผ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ, ๋น๋๊ธฐ ํ
์คํธ๋ฅผ ์ํด findBy* ์ฟผ๋ฆฌ์ waitForElementToBeRemoved๋ฅผ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ๊ณผ ๊ฐ์ ์ฃผ์ ์ ํจ๊ป React ํ
์คํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํ ๋ ๊ฐ์ฅ ํํ ์ ์ง๋ฅด๋ ์ค์ ๋ช ๊ฐ์ง๋ฅผ ๋ค๋ฃฐ ๊ฒ์
๋๋ค. ์ด ๊ธ์ด ๋๋๋ฉด ๋ ๋์ React ํ
์คํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ํ
์คํธ๋ฅผ ์์ฑํ๊ณ , ์ผ๋ฐ์ ์ธ ์ค์๋ฅผ ํผํ๊ณ , React ์ ํ๋ฆฌ์ผ์ด์
์ ์ ๋ฐ์ ์ธ ํ์ง์ ๊ฐ์ ํ ์ ์๋ ์ง์์ ๊ฐ์ถ๊ฒ ๋ ๊ฒ์
๋๋ค.
Default to *ByRole queries
React ํ
์คํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ๊ฐ๋ ฅํ ์ฅ์ ์ค ํ๋๋ ์ฌ๋ฐ๋ฅธ ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ๋ฉด ์ปดํฌ๋ํธ๊ฐ ์์๋๋ก ์๋ํ ๋ฟ๋ง ์๋๋ผ ์ ๊ทผ์ฑ (opens in a new tab)๋ ๋ณด์ฅํ ์ ์๋ค๋ ๊ฒ์
๋๋ค. ๊ทธ๋ ๋ค๋ฉด ์ด๋ค ์ฟผ๋ฆฌ๊ฐ ๊ฐ์ฅ ์ข์์ง ์ด๋ป๊ฒ ์์๋ผ ์ ์์๊น์? ๊ท์น์ ๋งค์ฐ ๊ฐ๋จํฉ๋๋ค. ๊ธฐ๋ณธ์ ์ผ๋ก *ByRole ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ์ธ์. ์ด ์ฟผ๋ฆฌ๋ ๋ง์ ์์, ์ฌ์ง์ด ๋ณต์กํ select components (opens in a new tab)์์๋ ์๋ํฉ๋๋ค.
๋๋ถ๋ถ์ ๊ท์น๊ณผ ๋ง์ฐฌ๊ฐ์ง๋ก ๋ชจ๋ HTML ์์์ ๊ธฐ๋ณธ ์ญํ ์ด ์๋ ๊ฒ์ ์๋๋ฏ๋ก ์์ธ๊ฐ ์์ต๋๋ค. HTML ์์์ ๊ธฐ๋ณธ ์ญํ ๋ชฉ๋ก์ w3.org (opens in a new tab)์์ ํ์ธํ ์ ์์ต๋๋ค.
Testing form components
์ด์ ์ ํํ ๋ฆฌ์ผ ๊ธ (opens in a new tab)์์ ๋ณํํ ๋ค์ ์ปดํฌ๋ํธ๋ฅผ ๋ณด๊ฒ ์ต๋๋ค.
export const Form = ({ saveData }) => {
const [state, setState] = useState({
name: "",
email: "",
password: "",
confirmPassword: "",
conditionsAccepted: false,
});
const onFieldChange = (event) => {
let value = event.target.value;
if (event.target.type === "checkbox") {
value = event.target.checked;
}
setState({ ...state, [event.target.id]: value });
};
const onSubmit = (event) => {
event.preventDefault();
saveData(state);
};
return (
<form className='form' onSubmit={onSubmit}>
<div className='field'>
<label>Name</label>
<input
id='name'
onChange={onFieldChange}
placeholder='Enter your name'
/>
</div>
<div className='field'>
<label>Email</label>
<input
type='email'
id='email'
onChange={onFieldChange}
placeholder='Enter your email address'
/>
</div>
<div className='field'>
<label>Password</label>
<input
type='password'
id='password'
onChange={onFieldChange}
placeholder='Password should be at least 8 characters'
/>
</div>
<div className='field'>
<label>Confirm password</label>
<input
type='password'
id='confirmPassword'
onChange={onFieldChange}
placeholder='Enter the password once more'
/>
</div>
<div className='field checkbox'>
<input type='checkbox' id='conditions' onChange={onFieldChange} />
<label>I agree to the terms and conditions</label>
</div>
<button type='submit'>Sign up</button>
</form>
);
};form elements๋ฅผ ํตํด ๋ฐ์ดํฐ ์
๋ ฅ์ ์๋ฎฌ๋ ์ด์
ํ๊ณ ์์์ ์ ์ถํ ๋ค์ saveData prop๊ฐ ์
๋ ฅํ ๋ฐ์ดํฐ๋ฅผ ์์ ํ๋์ง ํ์ธํ์ฌ ์ด๋ฅผ ํ
์คํธํ ์ ์์ต๋๋ค. ์ด๋ฅผ 3๋จ๊ณ๋ก ๋๋ ์ ์์ต๋๋ค:
- ํ ์คํธํ ํ๋์ ํ ์คํธ๋ฅผ ์ ๋ ฅํ๊ฑฐ๋ ํ์ธ๋์ ํด๋ฆญํฉ๋๋ค.
Sign up๋ฒํผ์ ํด๋ฆญํฉ๋๋ค.- ์
๋ ฅํ ๋ฐ์ดํฐ๋ก
saveData๊ฐ ํธ์ถ๋์๋์ง ํ์ธํฉ๋๋ค.
์ด workflow๋ ์ฌ์ฉ์๊ฐ form๊ณผ ์ํธ ์์ฉํ๋ ๋ฐฉ์์ ๋งค์ฐ ์ ์ฌํ๊ฒ ๋ฐ์ํฉ๋๋ค(์ ์ฅ๋ ๋ฐ์ดํฐ๋ฅผ ๋์ผํ ๋ฐฉ์์ผ๋ก ๊ฒ์ฌํ์ง๋ ์์ ์ ์์).
Querying by placeholder text
์ฒซ ๋ฒ์งธ ์ ๋ ฅ ํ๋์ ์ด๋ฆ์ ์ ๋ ฅํ๋ ๊ฒ์ผ๋ก ์์ํ๊ฒ ์ต๋๋ค. name placeholder์์ ์ ์์ผ๋ฏ๋ก, ํด๋น Inputd์ ์ด๋ฅผ ์ฌ์ฉํ์ฌ querying ํ๋ฉด ์ด๋จ๊น์?
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/extend-expect";
const defaultData = {
conditionsAccepted: false,
confirmPassword: "",
email: "",
name: "",
password: "",
};
describe("Form", () => {
it("should submit correct form data", async () => {
const user = userEvent.setup();
const mockSave = jest.fn();
render(<Form saveData={mockSave} />);
await user.type(screen.getByPlaceholderText("Enter your name"), "Test");
await user.click(screen.getByText("Sign up"));
expect(mockSave).toHaveBeenLastCalledWith({ ...defaultData, name: "Test" });
});
});์ด ๋ฐฉ๋ฒ๋ ํจ๊ณผ๊ฐ ์์ง๋ง ๊ทธ๋ณด๋ค ๋ ๋์ ๋ฐฉ๋ฒ์ด ์์ต๋๋ค. ์ฒซ์งธ, ์ด ์ ๊ทผ ๋ฐฉ์์ placeholder text๋ฅผ label๋ก ์ฌ์ฉํ๋ ๊ดํ์ ์กฐ์ฅํ ์ ์์ผ๋ฉฐ, ์ด๋ placeholder์ ์๋ ์ฉ๋๊ฐ ์๋๋ฉด์ W3C WAI (opens in a new tab)์์ ๊ถ์ฅํ์ง ์์ต๋๋ค. ๋์งธ, ์ ๊ทผ์ฑ ๋ฌธ์ ๋ฅผ ์ผ๋์ ๋๊ณ ํ ์คํธํ์ง ์์ต๋๋ค.
Querying specific components by role
๋์ ์ฟผ๋ฆฌ๋ฅผ getByRole๋ก ๋์ฒดํด ๋ณด๊ฒ ์ต๋๋ค. ๋ฌธ์์ ๋์ ์๋ฏ์ด text box์ ์ญํ ์ ๋ฐ๋ผ text ์ ํ์ ์
๋ ฅ์ ์ผ์น์ํฌ ์ ์์ต๋๋ค. ํ์ง๋ง ์์์ ์ฌ๋ฌ ๊ฐ์ text box๊ฐ ์์ผ๋ฏ๋ก ์ด๋ณด๋ค ๋ ๊ตฌ์ฒด์ ์ด์ด์ผ ํฉ๋๋ค. ๋คํํ๋ ์ฟผ๋ฆฌ๋ ์ต์
๊ฐ์ฒด์ธ ๋ ๋ฒ์งธ ๋งค๊ฐ ๋ณ์๋ฅผ ํ์ฉํ๋ฏ๋ก name ์์ฑ์ ์ฌ์ฉํ์ฌ ์ผ์นํ๋ ํญ๋ชฉ์ ๋ฒ์๋ฅผ ์ขํ ์ ์์ต๋๋ค.
RTL ๊ณต์๋ฌธ์ (opens in a new tab)๋ฅผ ๋ณด๋ฉด ์ฌ๊ธฐ์ name์ด ์
๋ ฅ์ ์ด๋ฆ ์์ฑ์ด ์๋๋ผ accessible name (opens in a new tab)์ ๊ฐ๋ฆฌํจ๋ค๋ ๊ฒ์ ์ ์ ์์ต๋๋ค. ๋ฐ๋ผ์ ์
๋ ฅ์ ๊ฒฝ์ฐ accessible name์ ๋ ์ด๋ธ์ ํ
์คํธ ์ฝํ
์ธ ์ธ ๊ฒฝ์ฐ๊ฐ ๋ง์ต๋๋ค. ์ฐ๋ฆฌ ์์์์๋ name input์ Name label์ด ์์ผ๋ฏ๋ก ์ด๋ฅผ ์ฌ์ฉํ๊ฒ ์ต๋๋ค.
user.type(screen.getByRole("textbox", { name: "Name" }), "Test");๊ทผ๋ฐ ํ ์คํธ๋ฅผ ๋๋ฆฌ๋ฉด ๋ค์๊ณผ ๊ฐ์ ์๋ฌ๊ฐ ๋ฐ์ํฉ๋๋ค:
TestingLibraryElementError: Unable to find an accessible element with the role "textbox" and name "Name"์๋์ help text๋ ์
๋ ฅ์ ์์ธ์คํ ์ ์๋ ์ด๋ฆ์ด ์๋ค๋ ๊ฒ์ ๋ณด์ฌ์ค๋๋ค.
Here are the accessible roles:
textbox:
Name "":
<input
id="name"
placeholder="Enter your name"
/>input์ ๋ํ label์ด ์๋๋ฐ ์ ์๋ํ์ง ์์๊น์? label์ input๊ณผ ์ฐ๊ฒฐ (opens in a new tab)ํด์ผ ํ๋ ๊ฒ์ผ๋ก ๋ฐํ์ก์ต๋๋ค. ์ด๋ฅผ ์ํด์๋ label์ ์ฐ๊ฒฐ๋ input์ ID์ ์ผ์นํ๋ for ์์ฑ์ด ์์ด์ผ ํฉ๋๋ค. ๋๋ input element๋ฅผ label ์์ ๋ํํ ์๋ ์์ต๋๋ค. input์ ์ด๋ฏธ id๊ฐ ์๋ ๊ฒ ๊ฐ์ผ๋ฏ๋ก for(React๋ฅผ ์ฌ์ฉํ๋ ๊ฒฝ์ฐ htmlFor) ์์ฑ์ ์ถ๊ฐํ๊ธฐ๋ง ํ๋ฉด ๋ฉ๋๋ค:
<label htmlFor="name">Name</label>
<input
id="name"
onChange={onFieldChange}
placeholder="Enter your name"
/>์ด์ input์ด label๊ณผ ์ฌ๋ฐ๋ฅด๊ฒ ์ฐ๊ฒฐ๋๊ณ ํ
์คํธ๊ฐ ํต๊ณผ๋ฉ๋๋ค. ๋ํ ์ ๊ทผ์ฑ๋ ํฌ๊ฒ ํฅ์๋ฉ๋๋ค.
์ฒซ์งธ, label์ ํด๋ฆญํ๊ฑฐ๋ ํญํ๋ฉด focus๊ฐ ์ฐ๊ฒฐ๋ input์ผ๋ก ๋ง์ถฐ์ง๋๋ค. ๋์งธ, ๊ฐ์ฅ ์ค์ํ ๊ฒ์ input์ ์ด์ ์ด ๋ง์ถฐ์ง๋ฉด screen readers๊ฐ label์ ์ฝ์ด ์ฌ์ฉ์์๊ฒ input์ ๋ํ ์ถ๊ฐ ์ ๋ณด๋ฅผ ์ ๊ณตํ๋ค๋ ์ ์
๋๋ค. ์ด๋ getByRole๋ก ์ ํํ๋ฉด ํ
์คํธ ์ปค๋ฒ๋ฆฌ์ง๊ฐ ํฅ์๋ ๋ฟ๋ง ์๋๋ผ form component์ ๋ํ ์ ๊ทผ์ฑ์ด ์ด๋ป๊ฒ ๊ฐ์ ๋๋์ง ๋ณด์ฌ์ค๋๋ค.
Improving the button test
ํ
์คํธ๋ฅผ ๋ค์ ์ดํด๋ณด๋ submit button์ getByText ์ฟผ๋ฆฌ๊ฐ ์ฌ์ฉ๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค. ์ ์๊ฐ์๋ *ByText๊ฐ ๊ฐ์ฅ ๊นจ์ง๊ธฐ ์ฝ๊ธฐ ๋๋ฌธ์ ์ตํ์ ์๋จ์ผ๋ก(๋๋ *ByTestId๋ณด๋ค ๋ค์์ ๋๋ฒ์งธ๋ก) ์ฌ์ฉํด์ผ ํฉ๋๋ค.
์ด ํ
์คํธ์์ screen.getByText("Sign up")๋ ํด๋น element๋ฅผ Sigh up text content๊ฐ ์๋ ํ
์คํธ ๋
ธ๋์ match์ํต๋๋ค. ๋์ค์ ๊ฐ์ ํ์ด์ง์ "Sign up"์ด๋ผ๋ ํ
์คํธ๊ฐ ์๋ ๋จ๋ฝ์ ์ถ๊ฐํ๊ธฐ๋ก ๊ฒฐ์ ํ๋ฉด ํด๋น element๋ matchํ๊ฒ ๋๊ณ , ์ฌ๋ฌ ๊ฐ์ matching element๋ก ์ธํด ํ
์คํธ๊ฐ ์ค๋จ๋ฉ๋๋ค. ๋ฌธ์์ด ๋์ ์ผ๋ฐ ์ ๊ท์์ ํ
์คํธ ์ผ์น์ ์ฌ์ฉํ๋ฉด ์ํฉ์ด ๋ ์
ํ๋ฉ๋๋ค: screen.getByText(/Sign up/i). ์ด ๊ฒฝ์ฐ ๋์๋ฌธ์์ ๊ด๊ณ์์ด 'sign up'์ด๋ผ๋ ๋ฌธ์์ด์ด ๋ ํฐ ๋ฌธ์ฅ์ ์ผ๋ถ์ด๋๋ผ๋ ๋ชจ๋ ์ผ์นํฉ๋๋ค.
์ด ํน์ ๋ฌธ์์ด๋ง ์ผ์นํ๋๋ก ์ ๊ท์์ ์์ ํ ์๋ ์์ง๋ง, ๋์ ๋ ์ ํํ ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ๋ฉด์ ๋์์ *ByRole ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ์ฌ form์ ์ก์ธ์คํ ์ ์๋์ง ํ์ธํ ์ ์์ต๋๋ค. ์ด ๊ฒฝ์ฐ ์ ํํ ์ฟผ๋ฆฌ๋ screen.getByRole("button", { name: "Sign up" }); ์
๋๋ค. ์ด๋ฒ์ ์ก์ธ์ค ๊ฐ๋ฅํ ์ด๋ฆ์ ๋ฒํผ์ ์ค์ text content์
๋๋ค. ๋ฒํผ์ aria-label์ ์ถ๊ฐํ๋ฉด ์ก์ธ์ค ๊ฐ๋ฅํ ์ด๋ฆ์ ํด๋น aria-label์ text content๊ฐ ๋ฉ๋๋ค.
์ต์ข ์ ์ผ๋ก ์ ๋ฐ์ดํธ๋ ํ ์คํธ๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค:
describe("Form", () => {
it("should submit correct form data", async () => {
const user = userEvent.setup();
const mockSave = jest.fn();
render(<Form saveData={mockSave} />);
await user.type(screen.getByRole("textbox", { name: "Name" }), "Test");
await user.click(screen.getByRole("button", { name: "Sign up" }));
expect(mockSave).toHaveBeenLastCalledWith({ ...defaultData, name: "Test" });
});
});form component๋ฅผ ํ ์คํธํ๊ธฐ ์ํด React-Testing-Library๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ ๊ด์ฌ์ด ์๋ค๋ฉด ์ด ๋ฌธ์๊ฐ ๋์์ด ๋ ์ ์์ต๋๋ค: Testing React Hook Form With React Testing Library. (opens in a new tab)
*ByRole vs *ByLabelText for input elements
์
๋ ฅ ์์์ ๋ํด *ByRole ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ๋ ์ด์ ์ ๋ํด ๊ถ๊ธํดํ ์ ์์ต๋๋ค. ๊ทธ ๋ชฉ์ ์ input element๋ฅผ ํด๋น label๊ณผ ์ฐ๊ด์ง์ด ๋งค์นญํ๋ ๊ฒ์
๋๋ค. ๊ถ๊ทน์ ์ผ๋ก ๊ฐ์ ๋ชฉํ๋ฅผ ๋ฌ์ฑํ๊ณ ๋ฌธ๋ฒ๋ ๋ ๊ฐ๋จํ *ByLabelText ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ด ๋ ์ฝ์ง ์์๊น์? ๋ ์ฟผ๋ฆฌ ์ฌ์ด์ ํฐ ์ฐจ์ด๊ฐ ์์ด ๋ณด์ผ ์ ์์ง๋ง, *ByRole ์ฟผ๋ฆฌ๋ ์์๋ฅผ ๋งค์นญํ ๋ ๋ ๊ฒฌ๊ณ ํ๋ฉฐ (opens in a new tab), <label>์์ aria-label๋ก ์ ํํด๋ ์ฌ์ ํ ์๋ํฉ๋๋ค.
๋ฐ๋ฉด, ๋ชจ๋ ์ ํ์ input ์์๊ฐ ๊ธฐ๋ณธ role์ ๊ฐ์ง๋ ๊ฒ์ ์๋๋๋ค. ์๋ฅผ ๋ค์ด, password๋ email input์ ๊ฒฝ์ฐ, *ByLabelText ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํด์ผ ํฉ๋๋ค. ๋ฐ๋ผ์ ๋ ๋ฐฉ๋ฒ ๋ชจ๋ ์ฅ์ ์ด ์์ง๋ง, ํน์ ์ฌ์ฉ ์ฌ๋ก๋ฅผ ๊ณ ๋ คํ๊ณ ์ํฉ์ ๊ฐ์ฅ ์ ํฉํ ์ฟผ๋ฆฌ๋ฅผ ์ ํํ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค.
Use userEvent instead of fireEvent
์ฐ๋ฆฌ๋ ์ด๋ฒคํธ๋ฅผ ์๋ฎฌ๋ ์ด์
ํ๊ธฐ ์ํด ๋ด์ฅ๋ fireEvent ๋ฉ์๋๋ฅผ ์ฌ์ฉํ์ง ์๊ณ ๋์ userEvent (opens in a new tab) ๋ฉ์๋๋ฅผ ๊ธฐ๋ณธ์ ์ผ๋ก ์ฌ์ฉํ๋ ๊ฒ์ ๋์น์ฑ์ ๊ฒ์
๋๋ค. fireEvent๊ฐ ๋ง์ ๊ฒฝ์ฐ์ ์๋ํ์ง๋ง, ์ด๋ dispatchEvent API ์์ ๊ฐ๋ฒผ์ด ๋ํผ๋ก์ ์ฌ์ฉ์ ์ํธ์์ฉ์ ์์ ํ ์๋ฎฌ๋ ์ด์
ํ์ง ์์ต๋๋ค. ๋ฐ๋ฉด์, userEvent๋ ์ฌ์ฉ์๊ฐ ๋ธ๋ผ์ฐ์ ์์ ์ํธ์์ฉํ๋ ๊ฒ๊ณผ ๋์ผํ ๋ฐฉ์์ผ๋ก DOM์ ์กฐ์ํ์ฌ ๋ ์ ๋ขฐํ ์ ์๋ ํ
์คํธ ๊ฒฝํ์ ์ ๊ณตํฉ๋๋ค. ๋ํ, ์ด๋ฌํ ์ ๊ทผ ๋ฐฉ์์ React Testing Library์ ์ฒ ํ๊ณผ๋ ๋ ์ ๋ง๊ณ , ๋ฌธ๋ฒ์ด ๋ ๋ช
ํํฉ๋๋ค.
testing-library/user-event version 13๋ถํฐ๋ ์ด๋ฒคํธ๋ฅผ ์๋ฎฌ๋ ์ด์
ํ๊ธฐ ์ ์ user event๋ฅผ ์ค์ ํ๋ ๊ฒ์ด ๊ถ์ฅ๋ฉ๋๋ค. ์ดํ ๋ฒ์ ์์๋ ์ด๊ฒ์ด ํ์๊ฐ ๋๋ฉฐ, userEvent์์ ์ง์ ์ด๋ฒคํธ๋ฅผ ์๋ฎฌ๋ ์ด์
ํ๋ ๊ฒ์ด ๋ ์ด์ ์๋ํ์ง ์์ ๊ฒ์
๋๋ค.
userEvent์ ์ค์ ์ ๊ฐ์ํํ๊ธฐ ์ํด, ์ด๋ฒคํธ ์ค์ ๊ณผ ์ปดํฌ๋ํธ ๋ ๋๋ง์ ๋์์ ์ฒ๋ฆฌํ๋ ์ ํธ๋ฆฌํฐ ํจ์๋ฅผ ์ถ๊ฐํ ์ ์์ต๋๋ค.
// setup userEvent
function setup(jsx) {
return {
user: userEvent.setup(),
...render(jsx),
};
}
describe("Form", () => {
it("should save correct data on submit", async () => {
const mockSave = jest.fn();
const { user } = setup(<Form saveData={mockSave} />);
await user.type(screen.getByRole("textbox", { name: "Name" }), "Test");
await user.click(screen.getByRole("button", { name: "Sign up" }));
expect(mockSave).toHaveBeenLastCalledWith({ ...defaultData, name: "Test" });
});
});๋ชจ๋ userEvent ๋ฉ์๋๋ ๋น๋๊ธฐ์ ์ด๋ฏ๋ก, ํ
์คํธ๋ ์ฝ๊ฐ ์กฐ์ ํ์ฌ ๋น๋๊ธฐ์ ์ผ๋ก ๋ง๋ค์ด์ผ ํฉ๋๋ค. ๋ํ, userEvent๋ ๋ณ๋์ ํจํค์ง์ด๋ฏ๋ก npm i -D @testing-library/user-event ๋ช
๋ น์ด๋ฅผ ํตํด ์ค์นํด์ผ ํฉ๋๋ค.
Simplify the waitFor queries with findBy*
์ข
์ข
๋งค์นญํ๋ ค๋ ์์๊ฐ ์ด๊ธฐ ๋ ๋๋ง ์์ ์๋ ์ฌ์ฉํ ์ ์๋ ๊ฒฝ์ฐ๊ฐ ์์ต๋๋ค. ์๋ฅผ ๋ค์ด, API์์ ํญ๋ชฉ์ ๊ฐ์ ธ์จ ํ์ ์ด๋ฅผ ํ์ํ ๋๊ฐ ๊ทธ๋ฌํฉ๋๋ค. ์ด๋ฌํ ์ํฉ์์๋ ์ฟผ๋ฆฌ๋ฅผ ์คํํ๊ธฐ ์ ์ ์ปดํฌ๋ํธ๊ฐ ๋ชจ๋ ๋ ๋๋ง ์ฌ์ดํด์ ์๋ฃํ๋๋ก ํด์ผ ํฉ๋๋ค. ์๋ฅผ ๋ค์ด, ListPage ์ปดํฌ๋ํธ๋ฅผ ์์ ํ์ฌ ๋น๋๊ธฐ์ ์ผ๋ก ํญ๋ชฉ ๋ชฉ๋ก์ด ๋ก๋๋๊ธฐ๋ฅผ ๊ธฐ๋ค๋ฆฌ๋๋ก ํฉ์๋ค:
export const ListPage = () => {
const [items, setItems] = useState([]);
useEffect(() => {
const loadItems = async () => {
setTimeout(() => setItems(["Item 1", "Item 2"]), 100);
};
loadItems();
}, []);
if (!items.length) {
return <div>Loading...</div>;
}
return (
<div className='text-list__container'>
<h1>List of items</h1>
<ItemList items={items} />
</div>
);
};ํ์ฌ ์ปดํฌ๋ํธ์ ๋ํ ํ
์คํธ๋ screen.getByRole ์ฟผ๋ฆฌ๊ฐ ํธ์ถ๋ ๋ ๋ก๋ฉ ํ
์คํธ๋ง ํ์๋๊ธฐ ๋๋ฌธ์ ๋ ์ด์ ์๋ํ์ง ์์ ๊ฒ์
๋๋ค. ์ปดํฌ๋ํธ๊ฐ ๋ก๋ฉ์ ์๋ฃํ ๋๊น์ง ๊ธฐ๋ค๋ฆฌ๊ธฐ ์ํด waitFor helper๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค. ๋ค์์ ์์ ๋ ํ
์คํธ ์ฝ๋์
๋๋ค:
import { waitFor } from "@testing-library/react";
//...
describe("ListPage", () => {
it("renders without breaking", async () => {
render(<ListPage />);
await waitFor(() => {
expect(
screen.getByRole("heading", { name: "List of items" })
).toBeInTheDocument();
});
});
});Enzyme์์ React Testing Library๋ก ํ ์คํธ๋ฅผ ๋ง์ด๊ทธ๋ ์ด์ ํ๋ ๊ฒ์ ๊ด์ฌ์ด ์๋ค๋ฉด, "Enzyme vs React Testing Library: A Migration Guide" (opens in a new tab)๋ผ๋ ์ํฐํด์ด ๋์์ด ๋ ์ ์์ต๋๋ค.
ํ์ฌ์ ๋ฐฉ์๋ ์๋ํ์ง๋ง, ๋น๋๊ธฐ ๋์์ด ๋ด์ฅ๋ ์ฟผ๋ฆฌ ํ์
์ด ์์ต๋๋ค: findBy* queries. ์ด ์ฟผ๋ฆฌ๋ค์ waitFor์ ๋ํผ๋ก์, ํ
์คํธ๋ฅผ ๋ ์ฝ๊ธฐ ์ฝ๊ฒ ๋ง๋ญ๋๋ค:
describe("ListPage", () => {
it("renders without breaking", async () => {
render(<ListPage />);
expect(
await screen.findByRole("heading", { name: "List of items" })
).toBeInTheDocument();
});
});์ฃผ๋ชฉํ ์ ์ ํ
์คํธ ๋ธ๋ก๋น ํ๋์ await ํธ์ถ์ด ์ผ๋ฐ์ ์ผ๋ก ์ถฉ๋ถํ๋ค๋ ๊ฒ์
๋๋ค. ์ด๋ ๋ชจ๋ ๋น๋๊ธฐ ์์
์ด ๊ทธ ์์ ๊น์ง ํด๊ฒฐ๋์๊ธฐ ๋๋ฌธ์
๋๋ค. ์ ์์ ์์ ์ถ๊ฐ๋ก ItemList์ 4๊ฐ์ item์ด ์๋์ง ํ
์คํธํ๊ณ ์ ํ๋ค๋ฉด, ๋น๋๊ธฐ findBy* ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ ํ์ ์์ด getBy* ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค.
๋ค์ ์์๋ ์๋ฌธ์๋ ์์ง๋ง ์ง์ ์ถ๊ฐํ ์์์์ ์๋ ค๋๋ฆฝ๋๋ค.
describe("ListPage", () => {
it("renders without breaking", async () => {
render(<ListPage />);
// Wait for the heading to be in the document
expect(
await screen.findByRole("heading", { name: "List of items" })
).toBeInTheDocument();
// Check that the items are displayed
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(4);
});
});Testing element's disappearance
์ด๋ ๊ฝค ์์ธ์ ์ธ ๊ฒฝ์ฐ์ด์ง๋ง, ๋น๋๊ธฐ ์์
ํ์ ์ด์ ์ ์กด์ฌํ๋ ์์๊ฐ DOM์์ ์ ๊ฑฐ๋์๋์ง ํ
์คํธํ๊ณ ์ ํ ๋๊ฐ ์์ต๋๋ค. React Testing Library๋ ์ด๋ฅผ ์ํด ์ ์ฉํ helper ํจ์์ธ waitForElementToBeRemoved๋ฅผ ์ ๊ณตํฉ๋๋ค. ์๋ฅผ ๋ค์ด, ListItem ์ปดํฌ๋ํธ์์ ๋ฆฌ์คํธ ํค๋๊ฐ ๋ํ๋ ๋๊น์ง ๊ธฐ๋ค๋ฆฌ๋ ๋์ , Loading... ํ
์คํธ๊ฐ ์ ๊ฑฐ๋ ๋๊น์ง ๊ธฐ๋ค๋ฆฌ๊ณ ์ถ์ ์ ์์ต๋๋ค.
it("renders without breaking", async () => {
render(<ListPage />);
await waitForElementToBeRemoved(() => screen.queryByText("Loading..."));
});Use React Testing Library Playground
์ด๋ค ์์์ ๋ํด ์ฌ๋ฐ๋ฅธ ์ฟผ๋ฆฌ๋ฅผ ์ฐพ๋ ๋ฐ ์ด๋ ค์์ ๊ฒช๋๋ค๋ฉด, React Testing Library Playground (opens in a new tab)๊ฐ ํฐ ๋์์ด ๋ ์ ์์ต๋๋ค. ํ ์คํธ ์ค์ธ ์ปดํฌ๋ํธ์ HTML์ ๊ฐ๋จํ ๋ถ์ฌ๋ฃ์ผ๋ฉด ๊ฐ ์์์ ๋ํด ์ ํฉํ ์ฟผ๋ฆฌ์ ๋ํ ์ ์ฉํ ์ ์์ ์ ๊ณตํฉ๋๋ค. ์ด ๋๊ตฌ๋ ๋ณต์กํ ์ปดํฌ๋ํธ์ ๊ฒฝ์ฐ ํนํ ์ข์๋ฐ, ์ด๋ค ์ฟผ๋ฆฌ๊ฐ ๊ฐ์ฅ ์ ํฉํ์ง ํญ์ ๋ช ํํ์ง ์์ ๋ ์ ์ฉํฉ๋๋ค.
Fixing the "not wrapped in act(...)" warnings
๋น๋๊ธฐ ๋ก์ง์ ํฌํจํ๋ ์ปดํฌ๋ํธ์ ์์ ํ ๋, ํ ์คํธ๋ฅผ ์คํํ๋ ๋์ ๊ฒฝ๊ณ ๋ฉ์์ง๋ฅผ ์ ํ ์ ์์ต๋๋ค.
Warning: An update to ComponentName inside a test was not wrapped in act(...).์ด ๊ฒฝ๊ณ ๋ ํ
์คํธ๊ฐ React๊ฐ ์ปดํฌ๋ํธ๋ฅผ ์
๋ฐ์ดํธํ๋ ๋ฐฉ์์ ์ ํํ๊ฒ ์๋ฎฌ๋ ์ด์
ํ์ง ๋ชปํ ์ ์์์ ์์ฌํ๋ฉฐ, ์ด๋ ํ
์คํธ ๊ฒฐ๊ณผ์์ false positives ๋๋ negatives์ ์ด๋ํ ์ ์์ต๋๋ค. ์ด ๊ฒฝ๊ณ ๋ ํ
์คํธ๊ฐ React Testing Library์์ ์ ๊ณตํ๋ act ํจ์ ์ธ๋ถ์์ ์ํ ์
๋ฐ์ดํธ๋ ๋ถ์์ฉ์ ํธ๋ฆฌ๊ฑฐํ์ฌ React๊ฐ ์ปดํฌ๋ํธ๋ฅผ ๋น๋๊ธฐ์ ์ผ๋ก ์
๋ฐ์ดํธํ ๋ ๋ฐ์ํฉ๋๋ค.
์ข
์ข
์ด ๊ฒฝ๊ณ ์ ์์ธ์ ๋น๋๊ธฐ ์์
ํ ์์๋ ์ปดํฌ๋ํธ๊ฐ ์
๋ฐ์ดํธ๋๋ ๊ฒฝ์ฐ์ getBy* ์ฟผ๋ฆฌ ๋์ findBy* ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์
๋๋ค.
๊ทธ๋ฌ๋ "act" ๊ฒฝ๊ณ ๊ฐ ์ ๋นํ๋ฉฐ ํ
์คํธ์์ false positives ๋๋ negatives์ ์์ ํ๊ธฐ ์ํด ํด๊ฒฐํด์ผ ํ๋ ๊ฒฝ์ฐ๋ ์์ต๋๋ค. ๊ทธ๋ฌํ ๊ฒฝ์ฐ ์ค ํ๋๋ Jest ํ์ด๋จธ์ ์์
ํ ๋ ๋ฐ์ํฉ๋๋ค. ํ์ด๋จธ๊ฐ ์๋ ์ปดํฌ๋ํธ๋ฅผ ํ
์คํธํ ๋๋ ํ์ด๋จธ์ ํ๋ฆ์ ์ ์ดํ๊ณ ๋ถ์ผ์น๋ฅผ ๋ฐฉ์งํ๊ธฐ ์ํด ๊ฐ์ง ํ์ด๋จธ(์: Jest์ useFakeTimers)๋ฅผ ์ฌ์ฉํฉ๋๋ค. ํ์ด๋จธ๋ฅผ ์คํํ๋ ค๋ฉด jest.runAllTimers();๋ฅผ ์ฌ์ฉํ ์ ์์ผ๋ฉฐ, ์ด๋ ๋ํ act๋ก ๊ฐ์ธ์ผ ํฉ๋๋ค.
jest.useFakeTimers();
// ... Set up the tests
act(() => {
jest.runAllTimers();
});
// ... Do assertions
jest.useRealTimers();์ด ๊ฒฝ๊ณ ๋ React 18์์ ๋ ์์ฃผ ๋ํ๋ ์ ์๋๋ฐ, ์ด๋ useEffect์ ์คํ ๋ฐฉ์์ ์ผ๋ถ ๋ณ๊ฒฝ (opens in a new tab)๋์๊ธฐ ๋๋ฌธ์
๋๋ค. ์ด์ ๋ฐ๋ผ ๋ ๋ง์ ํ
์คํธ๋ฅผ act๋ก ๊ฐ์ธ์ผ ํ ํ์๊ฐ ์์ต๋๋ค.
"act" ๊ฒฝ๊ณ ๋ฅผ ํด๊ฒฐํ๋ ๋ฐฉ๋ฒ์ ๋ํ ๋ณด๋ค ํฌ๊ด์ ์ธ ๊ฐ์ด๋๋ ๋ค์ ๊ธฐ์ฌ๋ฅผ ์ฐธ์กฐํ์ญ์์ค: Fix the "not wrapped in act(...)" warning. (opens in a new tab)
Writing smoke tests
๋๋๋ก ์ฐ๋ฆฌ๋ ์ปดํฌ๋ํธ๊ฐ ๋ ๋๋ง ์ค์ ๊นจ์ง์ง ์๋์ง ํ์ธํ๊ธฐ ์ํด ๊ธฐ๋ณธ์ ์ธ sanity ํ ์คํธ๋ฅผ ์ํฉ๋๋ค. ๋ค์ ๊ฐ๋จํ ์ปดํฌ๋ํธ๋ฅผ ๋ด ์๋ค:
export const ListPage = () => {
return (
<div className="text-list__container">
<h1>List of items</h1>
<ItemList />
</div>
);
};๋ค์๊ณผ ๊ฐ์ ํ ์คํธ๋ฅผ ํตํด ์ปดํฌ๋ํธ๊ฐ ๋ฌธ์ ์์ด ๋ ๋๋ง๋๋์ง ํ์ธํ ์ ์์ต๋๋ค:
import { render } from "@testing-library/react";
import React from "react";
import { ListPage } from "./ListPage";
describe("ListPage", () => {
it("renders without breaking", () => {
expect(() => render(<ListPage />)).not.toThrow();
});
});์ด ๋ฐฉ์์ ์ฐ๋ฆฌ์ ๋ชฉ์ ์ ๋ถํฉํ์ง๋ง, React Testing Library์ ๊ธฐ๋ฅ์ ์ถฉ๋ถํ ํ์ฉํ์ง ๋ชปํ๊ณ ์์ต๋๋ค. ๋์ , ๋ค์๊ณผ ๊ฐ์ด ํ ์ ์์ต๋๋ค:
import { render, screen } from "@testing-library/react";
import React from "react";
import { ListPage } from "./ListPage";
describe("ListPage", () => {
it("renders without breaking", () => {
render(<ListPage />);
expect(
screen.getByRole("heading", { name: "List of items" })
).toBeInTheDocument();
});
});๋น๋ก ์ด๊ฒ์ ๋งค์ฐ ๋จ์ํ๋ ์์์ด์ง๋ง, ์ด ์์ ๋ณ๊ฒฝ์ผ๋ก ์ธํด ์ปดํฌ๋ํธ๊ฐ ๋ ๋๋ง ์ค์ ๊นจ์ง์ง ์์ ๋ฟ๋ง ์๋๋ผ, ํ๋ฉด ํ๋
๊ธฐ์์ ์ ๋๋ก ์ ๊ทผํ ์ ์๋ List of items๋ผ๋ ์ด๋ฆ์ header ์์๋ฅผ ํฌํจํ๊ณ ์์์ ํ
์คํธํ ์ ์์ต๋๋ค.
Conclusion
์ด ์ํฐํด์์๋ React Testing Library ํ ์คํธ๋ฅผ ๊ฐ์ ํ๊ธฐ ์ํ ๋ช ๊ฐ์ง ๊ธฐ๋ฒ๊ณผ ๋ชจ๋ฒ ์ฌ๋ก๋ฅผ ํ๊ตฌํ๊ณ , ํผํด์ผ ํ ๊ฐ์ฅ ์ผ๋ฐ์ ์ธ ์ค์๋ค์ ๋์ดํ์ต๋๋ค. getByRole ์ฟผ๋ฆฌ๋ฅผ ์ฌ์ฉํจ์ผ๋ก์จ ํ ์คํธ๊ฐ ์ข์ ์ปค๋ฒ๋ฆฌ์ง๋ฅผ ์ ๊ณตํ ๋ฟ๋ง ์๋๋ผ ๊ฐ์น ์๋ ์ ๊ทผ์ฑ ํฅ์๋ ์ ๊ณตํ ์ ์์์ ํ์ธํ์ต๋๋ค. ๋ํ, fireEvent๋ณด๋ค userEvent ๋ฉ์๋๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ ์ด์ ๊ณผ ์ต์ ์ ์ฌ์ฉ์ ์ํ ์ค์ ๋ฐฉ๋ฒ์ ๋ฐฐ์ ์ต๋๋ค. ๋ง์ง๋ง์ผ๋ก, findBy* ์ฟผ๋ฆฌ์ waitForElementToBeRemoved๋ฅผ ์ฌ์ฉํ์ฌ ๋ ๊ฒฌ๊ณ ํ๊ณ ์ ๋ขฐํ ์ ์๋ ํ ์คํธ๋ฅผ ์์ฑํ๋ ๋ฐฉ๋ฒ๋ ์ดํด๋ณด์์ต๋๋ค.
์ด๋ฌํ ํ๊ณผ ์๋ น์ ๋ฐ๋ฅด๋ฉด ์ฝ๊ธฐ ์ฝ๊ณ , ์ ์ง๋ณด์ํ๊ธฐ ์ฌ์ฐ๋ฉฐ, ๋๋ฒ๊น (opens in a new tab)ํ๊ธฐ ์ฌ์ด ๋ ๋์ React ํ ์คํธ๋ฅผ ์์ฑํ ์ ์์ต๋๋ค. ํ ์คํธ๋ ๊ฐ๋ฐ ๊ณผ์ ์ ํ์์ ์ธ ๋ถ๋ถ์ด๋ฉฐ, ์ข์ ํ ์คํธ๋ฅผ ์์ฑํ๋ ๋ฐ ์๊ฐ์ ํฌ์ํ๋ ๊ฒ์ ์ฅ๊ธฐ์ ์ผ๋ก ํฐ ์ด์ต์ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค. ์ด๋ฌํ ๊ธฐ๋ฒ๊ณผ ๋ชจ๋ฒ ์ฌ๋ก๋ฅผ ํตํด ์ฐ๋ฆฌ์ React ์ ํ๋ฆฌ์ผ์ด์ ์ด ์ฒ ์ ํ๊ฒ ํ ์คํธ๋๊ณ ๋์ ํ์ง์ ์ ์งํ ์ ์๋๋ก ๋ณด์ฅํ ์ ์์ต๋๋ค.
References and resources
Beyond Console.log: Debugging Techniques In JavaScript (opens in a new tab) Creating Accessible Form Components with React (opens in a new tab) Kent C. Dodds: Fix the "not wrapped in act(...)" warning (opens in a new tab) MDN: The Label element (opens in a new tab) React Testing Library Playground (opens in a new tab) React Testing Library documentation: ByLabelText query (opens in a new tab) React Testing Library documentation: ByRole query (opens in a new tab) React Testing Library documentation (opens in a new tab) React documentation: How to Upgrade to React 18 (opens in a new tab) Testing Select Components with React Testing Library (opens in a new tab) TypeScript: Typing Form Events In React (opens in a new tab) User Event documentation (opens in a new tab) What is an accessible name? (opens in a new tab) w3.org: HTML-ARIA (opens in a new tab) w3.org: Placeholder text (opens in a new tab)