์ํํธ์จ์ด๊ฐ ์ฐ์ด๋ ๋ฐฉ์๊ณผ ๋ฎ์ ํ ์คํธ
Kent C.Dodds ๋์ ๋ธ๋ก๊ทธ๋ฅผ ๋ณด๋ฉด ์๋ ๋ฌธ์ฅ์ด ์๋นํ ์์ฃผ ๋ฑ์ฅํฉ๋๋ค. ๊ทธ๊ฐ ๋ง๋ Testing Library์ ์์น์ด๊ธฐ๋ ํฉ๋๋ค.
The more your tests resemble the way your software is used, the more confidence they can give you.
์ฌ์ฉ์(end-user)๋ ์ ํ์ ์ฌ์ฉํ ๋ ์ด๋ป๊ฒ ๊ตฌํ๋์๋์ง ์ ๊ฒฝ์ฐ์ง ์์ต๋๋ค. ๋ ์์ ๋ณด์ด๋(๋๋ ๊ท๋ก ๋ฃ๋) ํ ์คํธ, ๋ฒํผ, ์ ๋ ฅ ์ฐฝ๊ณผ ๊ฐ์ UI๋ฅผ ์ธ์งํ๊ณ ๊ทธ์ ๋ง๊ฒ ํ๋(์ ๋ ฅ, ํด๋ฆญ, ์คํฌ๋กค ๋ฑ)ํ์ฃ .
ํ ์คํธ ๋๊ตฌ ์ญ์ ์ ํ์ ์ฌ์ฉํ๋ ๋ ํ๋์ ์ฌ์ฉ์๋ก ์ทจ๊ธ๋์ด์ผ ํฉ๋๋ค. Testing Library์์๋ UI ์์๋ฅผ ์ ํํ๋ ๋ฐฉ๋ฒ(query)์ ์ฐ์ ์์๋ฅผ ์๋์ ๊ฐ์ด ์ ์ํฉ๋๋ค.
- ๋ชจ๋ ์ฌ์ฉ์์ ๊ฒฝํ์ ๋ฐ์ํ ์ ์๋ ์ฟผ๋ฆฌ:
role
,placeholder
์์ฑ,label
์์, DOM text node ๋ฑ - ์ ๊ทผ์ฑ์ ๊ณ ๋ คํ ์ฟผ๋ฆฌ:
alt
,title
์์ฑ - Test ID: ์ ํญ๋ชฉ์ ์์ธ ์ผ์ด์ค ๋์,
data-testid
๋ฑ์ ์์ฑ
์ฒซ ๋ฒ์งธ์ ๋ ๋ฒ์งธ ์ฐ์ ์์๋ ์น ์ ๊ทผ์ฑ ํ์ค์ ๋ฐ๋ฅด๋ ์์ฑ ๋ฐ ์์๋ฅผ ๊ธฐ์ค์ผ๋ก ํ๋ค๋ ์๋ฏธ์ ๋๋ค. ๊ทธ ๋์ ํ ์คํธ๋ฅผ ์์ฑํ ๋ ์ ๊ทผ์ฑ ๊ด๋ จ ์ ๋ณด๋ฅผ selector๋ก ํ์ฉํ ๊ฒฝํ์ด ์์๋๋ฐ ๊ฐ์ฅ ๋์ ์ฐ์ ์์์ ์๋ค๋ ์ ์ด ๋์ ๋์์ต๋๋ค. ์ ๊ทผ์ฑ ๊ด๋ จ ์์ฑ์ ์ ํ์ฉํ๋ ค๋ฉด accessibility tree๋ฅผ ๋จผ์ ์ดํดํด์ผ ํฉ๋๋ค.
์ ๊ทผ์ฑ ํธ๋ฆฌ (Accessibility Tree)
์ ๊ทผ์ฑ ํธ๋ฆฌ๋ DOM ํธ๋ฆฌ์ ๋ถ๋ถ ์งํฉ์ ๋๋ค. DOM ํธ๋ฆฌ๊ฐ DOM ์์๋ก ์ด๋ฃจ์ด์ง ๊ฒ์ฒ๋ผ ์ ๊ทผ์ฑ ํธ๋ฆฌ๋ ์ ๊ทผ์ฑ ๊ฐ์ฒด(accessibility object)๋ก ๊ตฌ์ฑ๋ฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ ๊ฐ DOM ์์๋ก๋ถํฐ ์ ๊ทผ์ฑ ๊ฐ์ฒด๋ฅผ ์์ฑํ ์ ์์ต๋๋ค.
์ ๊ทผ์ฑ ๊ฐ์ฒด๋ฅผ ๊ตฌ์ฑํ๋ ์์๋ ์๋์ ๊ฐ์ต๋๋ค. (์ถ์ฒ: MDN)
name | The name of a user interface element |
description | An accessible description provides additional information, related to an interface element, that complements the accessible name |
role | Main indicator of type |
state | A dynamic property expressing characteristics of an object that may change in response to user action or automated processes |
์ด ์์๋ฅผ ์กฐํฉํด accessible name
๊ณผ accessible description
์ ๋ง๋ญ๋๋ค.
๊ฐ๋จํ ์ฒดํฌ๋ฐ์ค ์์ ๋ฅผ ํตํด accessible name
์ด ์ด๋ป๊ฒ ์ ํด์ง๋์ง ์ดํด๋ณด๊ฒ ์ต๋๋ค. (์์ธํ ์ฐ์ ๋ฐฉ์์ Accessible Name and Description Computation 1.1์ ์ฐธ๊ณ ํด์ฃผ์ธ์)
<!-- Case 1: id์ for ์์ฑ์ผ๋ก ์ฐ๊ฒฐ์ง๊ธฐ --> <input id="my-checkbox" type="checkbox" /> <label for="my-checkbox">๊ฐ์์ง๋ฅผ ์ข์ํ์๋์?</label> <!-- Case 2: id์ aria-labelledby ์์ฑ์ผ๋ก ์ฐ๊ฒฐ์ง๊ธฐ --> <input aria-labelledby="my-checkbox" type="checkbox" /> <label id="my-checkbox">๊ฐ์์ง๋ฅผ ์ข์ํ์๋์?</label> <!-- Case 3: label ์์๋ก ๊ฐ์ธ๊ธฐ --> <label> <input type="checkbox" /> ๊ฐ์์ง๋ฅผ ์ข์ํ์๋์? </label>
ํฌ๋กฌ ๊ฐ๋ฐ์ ๋๊ตฌ์ '์์(Element)' ํญ์๋ ์ ๊ทผ์ฑ ํธ๋ฆฌ๋ฅผ ํฌํจํ ์ ๋ณด๊ฐ ์ ๊ณต๋ฉ๋๋ค. ์์ 3๊ฐ์ง ์์ ๋ฅผ ํ์ธํด๋ณด๋ฉด ๋ชจ๋ ์๋ ์คํฌ๋ฆฐ์ท๊ณผ ๊ฐ์ด '๊ฐ์์ง๋ฅผ ์ข์ํ์๋์?'๊ฐ accessible name
์ผ๋ก ์ค์ ๋๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค.
์ ๊ทผ์ฑ ์ ๋ณด ๊ธฐ๋ฐ์ ํ ์คํธ ์์ฑ
Testing Library์์๋ ํ ์คํธ ์ฝ๋ ์์ฑ ์ ์์ ์ดํด๋ณธ ์ ๊ทผ์ฑ ์ ๋ณด๋ฅผ ๊ธฐ์ค์ผ๋ก ์์๋ฅผ ์ ํํด ๊ฒ์ฆํ ์ ์์ต๋๋ค.
import { screen } from '@testing-library/dom' document.body.innerHTML = ` <label> <input type="checkbox" /> ๊ฐ์์ง๋ฅผ ์ข์ํ์๋์? </label> ` test('role ์์ฑ์ผ๋ก ์ ํํ๊ธฐ', async () => { const checkbox = screen.getByRole('checkbox') expect(checkbox).not.toBeChecked() }) test('label text ๊ฐ์ผ๋ก ์ ํํ๊ธฐ', async () => { const checkbox = screen.getByLabelText('๊ฐ์์ง๋ฅผ ์ข์ํ์๋์?') expect(checkbox).not.toBeChecked() })
๋ง์ฝ ์์ ๊ฐ๋ง์ผ๋ก ํ
์คํธํ๊ธฐ ์ด๋ ค์ด ์ํฉ์ธ ๊ฒฝ์ฐ placeholder
, alt
, title
๊ณผ ๊ฐ์ ์์ฑ์ ๊ธฐ๋ฐ์ผ๋ก ์์ฑํ ์๋ ์์ต๋๋ค.
import { screen } from '@testing-library/dom' document.body.innerHTML = ` <input type="text" placeholder="๊ฐ์์ง ์ด๋ฆ" value="ํธ๋ค" /> <img src="/puppy.png" alt="ํธ๋ค" title="๊ฐ์ ํธ๋ค์ ๋๋ค" /> ` test('placeholder ์์ฑ์ผ๋ก ์ ํํ๊ธฐ', async () => { const input = screen.getByPlaceholderText('๊ฐ์์ง ์ด๋ฆ') expect(input).toHaveValue('ํธ๋ค') }) test('alt ๊ฐ์ผ๋ก ์ ํํ๊ธฐ', async () => { const img = screen.getByAltText('ํธ๋ค') expect(img).toHaveAttribute('title', '๊ฐ์ ํธ๋ค์ ๋๋ค') }) test('title ๊ฐ์ผ๋ก ์ ํํ๊ธฐ', async () => { const img = screen.getByTitle('๊ฐ์ ํธ๋ค์ ๋๋ค') expect(img).toHaveAttribute('alt', 'ํธ๋ค') })
label, input๊ณผ ๊ฐ์ ํผ ์์, ์ด๋ฏธ์ง ์์ ๋ฑ์ ๊ธฐ๋ณธ์ ์ธ ์ ๊ทผ์ฑ ์ ๋ณด๋ฅผ ์ถฉ๋ถํ ๊ฐ์ถ๊ณ ์์ด ํ ์คํธ ์ฝ๋ ์์ฑ์ด ๋น๊ต์ ๋ช ํํ ํธ์ ๋๋ค.
๋ฐ๋ฉด ์ค๋ฌด์์๋ div
, span
์์์ ๊ฐ์ด ์ ๊ทผ์ฑ ์ ๋ณด์ ์๋ฏธ๊ฐ ๋ถ์ฌ๋์ง ์์ ๋งํฌ์
์ ์์ฑํด์ผ ํ๋ ๊ฒฝ์ฐ๋ ์ข
์ข
์์ต๋๋ค.
์ด๋ฌํ ์์์ ๋ํด Testing Library ํ
์คํธ๋ฅผ ์์ฑํ๋ ค๋ฉด ์๋์ ๊ฐ์ด testid
๋ฅผ ํ์ฉํ๋ ๋ฐฉ๋ฒ์ด ์์ต๋๋ค.
import { screen } from '@testing-library/dom' document.body.innerHTML = ` <div data-testid="my-pet"> <p>์ฐ๋ฆฌ์ง ๊ฐ์์ง๋ ๊ฐ์ ํธ๋ค</p> <div> ` test('testid ์์ฑ์ผ๋ก ์ ํ', () => { const div = screen.getByTestId('my-pet') expect(div).toHaveTextContent('์ฐ๋ฆฌ์ง ๊ฐ์์ง๋ ๊ฐ์ ํธ๋ค') })
ํ์ง๋ง Testing Library์์๋ testid
๋ฅผ ํ์ฉํ๋ ๋ฐฉ๋ฒ์ ์ตํ์ ์๋จ์ผ๋ก ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.
์ฌ์ฉ์๋ ๊ฐ๋ฐ์ ๋๊ตฌ๋ฅผ ์ด๊ณ document.querySelector('[data-testid="my-pet"]')
๋ฅผ ์คํํด UI๋ฅผ ์ฐพ์ง ์์ต๋๋ค. ๋์์ ์๋ (๋๋ ๊ท๋ก ๋ฃ๋) ์ธํฐํ์ด์ค๋ฅผ ๋ฐ๋ผ๊ฐ๊ฒ ๋ฉ๋๋ค. ๊ทธ๋์ testid
๋ฅผ ํ์ฉํ๋ ๋ฐฉ์์ ์ ํ์ด ์ค์ ๋ก ์ฌ์ฉ๋๋ ๊ณผ์ ๊ณผ ๋ฎ์ ์์ง ์์ต๋๋ค.
(๋ฐ๋ฉด์ ์ ํ ์ ์ฒด ๋ฒ์๋ฅผ ๋์์ผ๋ก ํ๋ e2e ํ
์คํธ์์๋ ์ฟผ๋ฆฌ๋ฅผ ๋ช
ํํ๊ฒ ์ง์ ํ ์ ์๋ค๋ ์ ์์ ์ ํฉํฉ๋๋ค)
๊ทธ๋ ๋ค๋ฉด ์ ๊ทผ์ฑ ์ ๋ณด๊ฐ ๋ถ์กฑํ ๋งํฌ์ ์ ๋ํ ํ ์คํธ๋ฅผ ์ํ ๋ฐฉ๋ฒ์๋ ์ด๋ค๊ฒ ์์๊น์?
ARIA
์์ฑ์ ์ง์ ์ค์ ํ๋ ๋ฐฉ๋ฒ์ด ์์ต๋๋ค. ์ ๊ทผ์ฑ ๊ด๋ จ ์์ฑ์ ํ์ด์ง์ ์๊ฐํ๋ ์ ๋ณด์ ๋นํด ๋ณ๋ ๊ฐ๋ฅ์ฑ์ด ์ ๊ธฐ ๋๋ฌธ์ ์ฝ๊ฒ ๊นจ์ง์ง ์๋ ํ
์คํธ ์ฝ๋๋ฅผ ์์ฑํ ์ ์์ต๋๋ค.
์์ด์ฝ ๋ฒํผ๊ณผ ๊ฐ์ด ์ ๊ทผ์ฑ ์์ฑ์ ์๋์ผ๋ก ์ค์ ํ๋ ๊ฒ์ด ๋ถ๊ฐํผํ ๊ฒฝ์ฐ๊ฐ ์์ต๋๋ค. ์๋์ ๊ฐ์ Button
์ปดํฌ๋ํธ๋ ํ
์คํธ๊ฐ ์๊ธฐ ๋๋ฌธ์ button
์์๊ฐ ๊ฐ์ง ๊ธฐ๋ณธ role
์์ฑ์ ํ์ฉํด์ผ ํฉ๋๋ค.
import { fireEvent, render, screen } from '@testing-library/react' const Button = ({ onClick }) => ( <button onClick={onClick}> <svg width="17" height="17" xmlns="http://www.w3.org/2000/svg"> <path d="m.967 14.217 5.8-5.906-5.765-5.89L3.094.26l5.783 5.888L14.66.26l2.092 2.162-5.766 5.889 5.801 5.906-2.092 2.162-5.818-5.924-5.818 5.924-2.092-2.162Z" fill="#000" /> </svg> </button> ) test('role ์์ฑ์ผ๋ก ์ ํ ๋ฐ ํด๋ฆญ', () => { const handleClick = jest.fn() render(<Button onClick={handleClick} />) fireEvent.click(screen.getByRole('button')) expect(handleClick).toHaveBeenCalledTimes(1) })
์ด ํ
์คํธ๋ ๋ฒํผ์ด ํ๋์ผ ๋ ์ ๋์ํ์ง๋ง ๋ง์ฝ ์ปดํฌ๋ํธ ๋ด์ ๋ฒํผ์ด ์ฌ๋ฌ ๊ฐ๋ผ๋ฉด ์ฟผ๋ฆฌ ๋ฐฉ์์ด ๋ ๋ณต์กํด์ง ๊ฒ์
๋๋ค. ์ด๋ฐ ๊ฒฝ์ฐ aria-label
์์ฑ์ ํ์ฉํด ์์๋ฅผ ๋ช
์์ ์ผ๋ก ์ ํํ ์ ์์ต๋๋ค.
import { fireEvent, render, screen } from '@testing-library/react' const Button = ({ onClick }) => ( <button aria-label="๋ชจ๋ฌ ๋ซ๊ธฐ"> <svg aria-hidden="true" focusable="false" width="17" height="17" xmlns="http://www.w3.org/2000/svg"> <path d="m.967 14.217 5.8-5.906-5.765-5.89L3.094.26l5.783 5.888L14.66.26l2.092 2.162-5.766 5.889 5.801 5.906-2.092 2.162-5.818-5.924-5.818 5.924-2.092-2.162Z" fill="#000" /> </svg> </button> ) test('aria-label ์์ฑ์ผ๋ก ์ ํ ๋ฐ ํด๋ฆญ', () => { const handleClick = jest.fn() render(<Button onClick={handleClick} />) fireEvent.click(screen.getByLabelText('๋ชจ๋ฌ ๋ซ๊ธฐ')) expect(handleClick).toHaveBeenCalledTimes(1) })
aria-label
์์ฑ์ ํ ๋นํด ์๋ ์คํฌ๋ฆฐ์ท๊ณผ ๊ฐ์ด ์ ๊ทผ์ฑ๋ ๋์ด๊ณ ํ
์คํธ ์ฝ๋๋ ๋ช
ํํ๊ฒ ์์ฑํ ์ ์๊ฒ ๋์ต๋๋ค.
์ถ๊ฐ๋ก ์์ด์ฝ๊ณผ ๊ฐ์ด ์ค๋ณต์ ๊ณ ๋ คํด ์ ๊ทผ์ฑ ํธ๋ฆฌ์์ ์ ์ธํ ์์๋ aria-hidden
๊ณผ svg focusable
์์ฑ์ผ๋ก ์ ์ดํ ์ ์์ต๋๋ค.
๋ง๋ฌด๋ฆฌ
Testing Library ๋ฌธ์๋ฅผ ์ฝ๊ณ , ์ค์ ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํด๋ณด๋ ์ฅ์ ์ด ์๋นํ ๋ง์์ต๋๋ค.
- ํ๊ทธ๋ช
์ด๋ ์ฌ์ฉ์ ์ ์ ์์ฑ(
data-*
)์ ์์กดํ๋ ๊ธฐ์กด ํ ์คํธ ๋ฐฉ์๊ณผ ๋ฌ๋ฆฌ ์ฟผ๋ฆฌ์ ์ฐ์ ์์์ ํ์ฉ ๋ฒ์๊ฐ ์ค์ ์ฌ์ฉ์์ ํ๋๊ณผ ์ ์ฌํจ getByRole
,getByLabelText
์ ๊ฐ์ ์ฟผ๋ฆฌ๋ฅผ ํ์ฉํ๋ ๊ณผ์ ์์ ์น ํ์ค๊ณผ ์ ๊ทผ์ฑ์ ์ค์ํ๋ ๋ฐฉํฅ์ผ๋ก ์ ํ ์ฝ๋๋ฅผ ์์ฑํ๊ฒ ๋จ- React ์ปดํฌ๋ํธ ๋ ๋๋ง ๊ณผ์ ์ค์ props, state ์ํ ์ ์ด๊ฐ์ ์ธ๋ถ ๊ตฌํ์ด ์๋ ๊ฒฐ๊ณผ๋ฌผ์ ๊ธฐ์ค์ผ๋ก ํ ์คํธํ๊ธฐ ๋๋ฌธ์ ์ต์ข ์ฌ์ฉ์ ํ๊ฒฝ์ ๋ค๋ฃจ๋ e2e ํ ์คํธ์ฒ๋ผ ์์ ๊ฐ์ ๊ฐ์ง ์ ์์
ํ์ Playwright, Cypress์ ๊ฐ์ e2e ํ ์คํธ๋ฅผ ์งํฅํ๋ ํฐ ๋ชฉ์ ์ค ํ๋๊ฐ ์ค์ ๋ก ์ฌ์ฉ์๊ฐ ์ ํ์ ์ฌ์ฉํ๋ ๊ฒ์ ์๋ํํ๊ธฐ ์ํจ์ด์๋๋ฐ Testing Library๊ฐ ๋ํด์ง๋ค๋ฉด ๋ณด๋ค ๊ฒฌ๊ณ ํ ์ ํ์ ๊ฐ์ถฐ๋๊ฐ ์ ์๊ฒ ๋ค๋ ์๊ฐ์ด ๋ญ๋๋ค.
References
- The Browser Accessibiliy Tree
- ์ ๊ทผ์ฑ ํธ๋ฆฌ
- What is an accessible name?
- Accessible Name and Description Computation spec
- ARIA in HTML
- Accessibility and Testing with VoiceOver OS (Mac)
- Accessibility in React
- Testing Library Playground