๐Ÿ™ ๋ฆฌ์•กํŠธ ํ…Œ์ŠคํŒ… ๊ธฐ๋ณธ(with. Testing Library)

swยท2025๋…„ 2์›” 20์ผ

1. Testing Library

@testing-library๋Š” ์‚ฌ์šฉ์ž ์ค‘์‹ฌ UI ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ์— ๋„์›€์„ ์ฃผ๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ง‘ํ•ฉ์ด๋‹ค.

1-1. Core API(React Testing Library)

ํ…Œ์ŠคํŠธ๋Š” ์ปดํฌ๋„ŒํŠธ์˜ ์š”์†Œ๋ฅผ ์„ ํƒํ•œ ํ›„, ์ด๋ฒคํŠธ API๋‚˜ user-event๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค๊ณ  ํŽ˜์ด์ง€์™€์˜ ์ƒํ˜ธ์ž‘์šฉ์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜๊ฑฐ๋‚˜ ์š”์†Œ์— ๋Œ€ํ•œ assertion์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค.

1-1-1. ์ฟผ๋ฆฌํ•จ์ˆ˜

1-1-1-1. prefix(get, find, query)
  • query๋กœ ์‹œ์ž‘ํ•˜๋Š” ํ•จ์ˆ˜๋Š” ์ผ์น˜ํ•˜๋Š” ์š”์†Œ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ null ๋˜๋Š” ๋นˆ ๋ฐฐ์—ด(queryAll์ธ ๊ฒฝ์šฐ)์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค(get, find๋กœ ์‹œ์ž‘ํ•˜๋Š” ํ•จ์ˆ˜๋Š” ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜)
  • find๋กœ ์‹œ์ž‘ํ•˜๋Š” ํ•จ์ˆ˜๋Š” Promise๋ฅผ ๋ฐ˜ํ™˜ํ•˜์—ฌ์„œ ์„œ๋ฒ„์™€์˜ ํ†ต์‹ ์ด ํ•„์š”ํ•œ UI ์ปดํฌ๋„ŒํŠธ์˜ ๊ฒฝ์šฐ ์‚ฌ์šฉ๋œ๋‹ค(1์ดˆ ํ›„์—๋„ ์š”์†Œ๊ฐ€ ์—†์œผ๋ฉด reject๋œ๋‹ค. get, query๋กœ ์‹œ์ž‘ํ•˜๋Š” ํ•จ์ˆ˜๋Š” ์š”์†Œ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.)
1-1-1-2. suffix

์ฟผ๋ฆฌํ•จ์ˆ˜์˜ suffix๋กœ๋Š” role, labelText, placeholderText, text, displayView, altText, title, testid๋ฅผ ์„ ํƒํ•  ์ˆ˜ ์žˆ๋‹ค

  • getByRole: ์ ‘๊ทผ์„ฑ ํŠธ๋ฆฌ์— ๋…ธ์ถœ๋œ ๋ชจ๋“  ์š”์†Œ๋ฅผ ์ฟผ๋ฆฌํ•˜๋Š”๋ฐ ์‚ฌ์šฉํ•œ๋‹ค. getByRole("button", {name: /submit/i})์˜ ํ˜•ํƒœ๋ฅผ ๊ฐ€์žฅ ๋งŽ์ด ์‚ฌ์šฉํ•œ๋‹ค.
  • getByLabelText: ํผ ํ•„๋“œ์—์„œ ์ฃผ๋กœ ์‚ฌ์šฉ. label text๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์š”์†Œ๋ฅผ ์ฐพ๋Š”๋‹ค.
  • getByPlaceholderText
  • getByText: ๋น„๋Œ€ํ™”ํ˜• ์š”์†Œ์ธ div, span ๋“ฑ์„ ์ฐพ๋Š”๋ฐ ์‚ฌ์šฉ๋˜๋ฉฐ, ํผ ์™ธ๋ถ€์—์„œ ์‚ฌ์šฉ์ž๊ฐ€ ์š”์†Œ๋ฅผ ์ฐพ๋Š” ์ฃผ์š” ๋ฐฉ๋ฒ•์ด๋‹ค.
  • getByDisplayValue: ์ฑ„์›Œ์ง„ ๊ฐ’์ด ์žˆ๋Š” ํŽ˜์ด์ง€๋ฅผ ํƒ์ƒ‰ํ•  ๋•Œ ์œ ์šฉํ•˜๋‹ค.
  • getByAltText: ์š”์†Œ๊ฐ€ alt ํ…์ŠคํŠธ๋ฅผ ์ง€์›ํ•˜๋Š” ๊ฒฝ์šฐ ์‚ฌ์šฉ
  • getByTitle
1-1-1-3. ์ฟผ๋ฆฌํ•จ์ˆ˜์˜ ์‚ฌ์šฉ

์ฟผ๋ฆฌ ํ•จ์ˆ˜๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์ฒซ๋ฒˆ์งธ ์ธ์ˆ˜์— container DOM์„ ์ „๋‹ฌํ•ด์•ผ ํ•œ๋‹ค.

Testing library์—์„œ๋Š” screen์„ ํ†ตํ•ด์„œ ์ฟผ๋ฆฌ ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜๋„ ์žˆ๋‹ค.

import {screen, getByLabelText} from '@testing-library/dom';

// with screen
const inputNode1 = screen.getByLabelText("Username");

// without screen
const inputNode2 = getByLabelText(document.querySelector("#app"), 'Username');
1-1-1-4. Text Match
  • string์œผ๋กœ ๋งค์นญํ•˜๋Š” ๋ฐฉ๋ฒ•
    screen.getByText('Hello World', {exact: false}); // true์ธ ๊ฒฝ์šฐ full match, false์ธ ๊ฒฝ์šฐ substring match or ignore case
  • ์ •๊ทœ์‹์œผ๋กœ ๋งค์นญํ•˜๋Š” ๋ฐฉ๋ฒ•
    screen.getByText(/Hello World/i)
  • ํ•จ์ˆ˜๋กœ ๋งค์นญํ•˜๋Š” ๋ฐฉ๋ฒ•
    screen.getByText((content, element) => content.startsWith("Hello"))
1-1-1-5. Helper ํ•จ์ˆ˜
  • logRoles
    DOM ๋…ธ๋“œ ํŠธ๋ฆฌ ๋‚ด์˜ ๋ชจ๋“  ์•”๋ฌต์  ARIA ์—ญํ•  ๋ชฉ๋ก์„ ์ถœ๋ ฅํ•˜๋Š”๋ฐ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.
import { logRoles } from '@testing-library/dom';

logRoles(document.createElement("nav"));
1-1-1-6 context API

contextAPI๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ, ํ•ด๋‹น ์„ค์ •์„ ์ ์šฉํ•ด์•ผ ํ•œ๋‹ค.

// test-utility.tsx
import {render, RenderOptions} from '@testing-library/react'
import {ThemeProvider} from 'my-ui-lib'
import {TranslationProvider} from 'my-i18n-lib'
import defaultStrings from 'i18n/en-x-default'

// ์˜ˆ์‹œ
const AllTheProviders = ({children}: {children: React.ReactNode}) => {
  return (
    <ThemeProvider theme="light">
      <TranslationProvider messages={defaultStrings}>
        {children}
      </TranslationProvider>
    </ThemeProvider>
  )
}

// render ํ•จ์ˆ˜์— AlltheProviders๊ฐ€ wrapping ๋œ๋‹ค.
// test ํŒŒ์ผ์—์„œ render๋ฅผ importํ•  ๋•Œ, @testing-library/react๊ฐ€ ์•„๋‹Œ test-utility.tsx๋ฅผ importํ•ด์„œ ์‚ฌ์šฉํ•˜๋ฉด ๋œ๋‹ค.
const customRender = (
	ui: ReactElement,
    options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, {wrapper: AlltheProviders, ...options})
1-1-1-7. render ํ•จ์ˆ˜

ํ…Œ์ŠคํŠธ ํ•  ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ์ „๋‹ฌํ•œ๋‹ค.

๋ฐ˜ํ™˜๋˜๋Š” { container }๋Š” logRoles๋ฅผ ํ†ตํ•ด ํ‘œ์‹œ๋  ์ˆ˜ ์žˆ๋‹ค.

const {container} = render(<Options />);
logRoles(container);

1-2. @testing-library/user-event

@testing-library/user-event๋Š” ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ƒํ˜ธ์ž‘์šฉ์ด ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ, ๋ฐœ์ƒํ•˜๋Š” ์ด๋ฒคํŠธ๋ฅผ ์ „์†กํ•˜์—ฌ ์‚ฌ์šฉ์ž ์ƒํ˜ธ์ž‘์šฉ์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜๋Š” ๋ณด์กฐ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์ด๋‹ค.

1-2-1. fireEvent์™€์˜ ์ฐจ์ด์ 

fireEvents๋Š” ๋ธŒ๋ผ์šฐ์ €์˜ ์ €์ˆ˜์ค€ dispatchEvent API Wrapper๋กœ, ๊ฐœ๋ฐœ์ž๊ฐ€ ๋ชจ๋“  ์š”์†Œ์—์„œ ๋ชจ๋“  ์ด๋ฒคํŠธ๋ฅผ ํŠธ๋ฆฌ๊ฑฐํ•  ์ˆ˜ ์žˆ๋‹ค. ๋ฌธ์ œ๋Š” ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ผ๋ฐ˜์ ์œผ๋กœ ํ•˜๋‚˜์˜ ์ƒํ˜ธ์ž‘์šฉ์— ๋Œ€ํ•ด ํ•˜๋‚˜ ์ด์ƒ์˜ ์ด๋ฒคํŠธ๋ฅผ ํŠธ๋ฆฌ๊ฑฐํ•œ๋‹ค๋Š” ๊ฒƒ์ด๋‹ค.

user-event๋Š” ๊ตฌ์ฒด์ ์ธ ์‚ฌ์šฉ์ž์˜ ์ƒํ˜ธ์ž‘์šฉ์„ ๋ฐœ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค. ๊ทธ๋ž˜์„œ user-event๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹๋‹ค.
์•„์ง ๊ตฌํ˜„๋˜์ง€ ์•Š์€ ์ด๋ฒคํŠธ๋“ค์ด ์žˆ๋Š”๋ฐ ์ด๋Ÿฌํ•œ ๊ฒฝ์šฐ๋Š” fireEvent๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋œ๋‹ค.

1-2-2. user-event ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•

import userEvent from '@testing-library/user-event';

it("test", async () => {
	// render ํ•˜๊ธฐ ์ „์— ํ˜ธ์ถœ
	const user = userEvent.setup();
    
    render(<MyComponent />);
    
    const element = screen.getByRole("button", {name: /click/i});
    // user event๋Š” Promise๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค
    await user.click(element);
    
})

user.Type ํ•จ์ˆ˜๋Š” ์ž…๋ ฅ ๊ฐ€๋Šฅํ•œ ์š”์†Œ์— ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ๋‹ค.
user.clear ํ•จ์ˆ˜๋Š” ํŽธ์ง‘ ๊ฐ€๋Šฅํ•œ ์š”์†Œ๋ฅผ ์‰ฝ๊ฒŒ ์ง€์šธ ์ˆ˜ ์žˆ๋‹ค.

it("test", () => {
	const user = userEvent.setup();
    ...
    const element = screen.getByRole("textbox")
    await user.clear(element);
    await user.type(element, 'new Text!');
    
})

user.click ํ•จ์ˆ˜๋Š” element๋ฅผ ํด๋ฆญํ•  ์ˆ˜ ์žˆ๋‹ค

์ถ”๊ฐ€ ํ•จ์ˆ˜๋Š” ์—ฌ๊ธฐ์„œ ํ™•์ธํ•˜๋ฉด ๋œ๋‹ค.

1-3. @testing-library/jest-dom

jest-dom์€ Jest์— ๋Œ€ํ•œ ์‚ฌ์šฉ์ž ์ •์˜ DOM ์š”์†Œ ๋งค์ฒ˜๋ฅผ ์ œ๊ณตํ•˜๋Š” Testing Library ์ค‘ ํ•˜๋‚˜์ด๋‹ค.

์‚ฌ์šฉํ•˜๋Š” ๊ณณ์—์„œ import '@testing-library/jest-dom'๋ฅผ ํ•œ๋‹ค.

test(์›น ํŽ˜์ด์ง€์˜ ๋ˆˆ์— ๋ณด์ด๋Š” ์š”์†Œ๋“ค์„ ํ…Œ์ŠคํŠธ), it, description, expect ๋“ฑ์˜ ํ•จ์ˆ˜๋ฅผ ์ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค

1-4. ๊ธฐํƒ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ

eslint-plugin-testing-library, eslint-plugin-jest-dom ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ๋ชจ๋ฒ” ์‚ฌ๋ก€๋ฅผ ๋”ฐ๋ฅด๊ณ  ์ผ๋ฐ˜์ ์ธ ์‹ค์ˆ˜๋ฅผ ์˜ˆ์ธกํ•˜๋Š”๋ฐ ๋„์›€์ด ๋˜๋Š” ESLint ํ”Œ๋Ÿฌ๊ทธ์ธ

์ฐธ๊ณ : eslint-plugin-testing-library, eslint-plugin-jest-dom

2. Vitest

Vite๋กœ ๊ตฌ๋™๋˜๋Š” ํ…Œ์ŠคํŠธ๋Ÿฌ๋„ˆ

2-1. Test API

  • test(it): ์ฝ”๋“œ๋ฅผ ํ…Œ์ŠคํŠธ(UI๋Š” jest-dom)
    • .extend
    • .skip: ํ…Œ์ŠคํŠธ์˜ ์‹คํ–‰์„ ๊ฑด๋„ˆ๋œ€
    • .skipIf
    • .runIf: skipIf์˜ ๋ฐ˜๋Œ€
    • .only: ํŠน์ • ํ…Œ์ŠคํŠธ๋งŒ ์‹คํ–‰
    • .concurrent
    • .sequential
    • .todo: ๋‚˜์ค‘์— ๊ตฌํ˜„ํ•  ํ…Œ์ŠคํŠธ
    • .fails: ์‹คํŒจํ•  ๊ฒƒ์ž„์„ ๋ช…์‹œ์ ์œผ๋กœ ํ‘œ์‹œ
    • .each
    • .for
  • bench: ์ด ํ•จ์ˆ˜๋ฅผ ์—ฌ๋Ÿฌ๋ฒˆ ์‹คํ–‰ํ•˜์—ฌ ๋‹ค์–‘ํ•œ ์„ฑ๋Šฅ ๊ฒฐ๊ณผ๋ฅผ ํ‘œ์‹œ
    • .skip
    • .only
    • .todo
  • describe
    • .skip
    • .skipIf
    • .runIf
    • .only
    • .concurrent
    • .sequential
    • .shuffle
    • .todo
    • .each
    • .for
  • beforeEach
  • afterEach
  • beforeAll
  • afterAll
  • onTestFinished
  • onTestFailed

2-2. Expect

  • toBe
  • toBeDefined
  • toBeUndefined
  • toBeTruthy
  • toBeFalsy
  • toBeNull
  • toBeNaN
  • toBeGreaterThan
  • toEqual
  • toMatch
  • ...

2-3. config

// vite.config.ts
...
export defineConfig({
  ...
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: "./src/setupTests.js",
    // you might want to disable the `css: true` line, since we don't have
    // tests that rely on CSS -- and parsing CSS is slow.
    // I'm leaving it in here becasue often people want to parse CSS in tests.
    css: true,
  },
  ...
})

3. MSW

ํ…Œ์ŠคํŠธ์—์„œ API๋ฅผ ๋ชจํ‚นํ•˜๊ธฐ ์œ„ํ•ด์„œ ์‚ฌ์šฉํ•œ๋‹ค.

// src/mocks/handlers.js
import { http, HttpResponse } from 'msw'
 
export const handlers = [
  // Intercept "GET https://example.com/user" requests...
  http.get('https://example.com/user', () => {
    // ...and respond to them using this JSON response.
    return HttpResponse.json({
      id: 'c7b3d8e0-5e0b-4b0f-8b3a-3b9f4b3d3b3d',
      firstName: 'John',
      lastName: 'Maverick',
    })
  }),
]

// setupTests.js
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
 
const server = setupServer(...handlers)
 
beforeAll(() => {
  // Start the interception.
  server.listen()
})
 
afterEach(() => {
  // Remove any handlers you may have added
  // in individual tests (runtime handlers).
  server.resetHandlers()
})
 
afterAll(() => {
  // Disable request interception and clean up.
  server.close()
})

์ฐธ๊ณ 

ํ…Œ์ŠคํŠธ ํ•  ๋•Œ, ์ค‘์ฒฉ์„ ์ง€์–‘ํ•˜์ž
Testing Library
Vitest
Mock Service Worker

profile
abcde

0๊ฐœ์˜ ๋Œ“๊ธ€