๐ 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)