C.W.K.
Stream
Lesson 02 of 04 · published

쿼리: 접근성 우선, Test-Id 는 최후

~18 min · vitest-components, queries, accessibility, rtl

Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"사용자가 보는 걸로 쿼리해. Accessibility 트리가 사용자 인터페이스야."

우선순위 리스트 (와 그 이유)

RTL 공식 쿼리 우선순위 — 다음으로 fallback 하기 전 시도해야 할 순서:

  1. getByRole + { name } 옵션 — 골드 스탠다드.
  2. getByLabelText — 적절한 라벨 있는 폼 필드용.
  3. getByPlaceholderText — 라벨 없을 때만 (드뭄).
  4. getByText — non-interactive 컨텐츠용.
  5. getByDisplayValue — 채워진 폼 필드용.
  6. getByAltText — 이미지용.
  7. getByTitle — title 속성 가진 element 의 최후 수단.
  8. getByTestId — 다른 게 element 식별 못 할 때 escape hatch.

이 리스트가 이 순서인 이유: 위쪽 쿼리들이 assistive tech (스크린 리더, 보이스 컨트롤) 가 element 찾는 방식을 거울처럼 반영, 그게 사람이 페이지 훑는 방식이기도 해. 이 쿼리 쓰는 테스트는 암묵적으로 페이지가 쓸 수 있는지 테스트. getByTestId 에 손 뻗는 테스트는 페이지가 스크린 리더에 못 쓰는데 통과 가능.

`getByRole` + `name` 이 일꾼

대부분 인터랙티브 element 가 암묵적 ARIA role 가져: <button>button, <h2>heading, <a href>link, <input type="checkbox">checkbox. getByRole('button', { name: /save/i }) 가 접근 가능 이름이 /save/i 매칭되는 버튼 찾아 — resilience 위한 case-insensitive regex 매칭.

name 옵션은 순서대로 계산: aria-label, aria-labelledby 내용, element 텍스트 컨텐츠, 특정 속성 값 (이미지는 alt, 일부 element 는 title). 이름으로 쿼리하면 스크린 리더가 발음할 같은 문자열 쿼리하는 거.

Role 로 쿼리 못 하면 컴포넌트가 접근 가능하지 않을 수 있어. 클릭 가능한 <div onClick> 는 role 도 name 도 없어 — 스크린 리더가 못 찾고, 너의 테스트도 못 찾아. 컴포넌트 고쳐 (<button> 써), getByTestId 를 workaround 로 쓰지 마.

외울 Role 치트시트

70+ ARIA role 다 외울 필요 없어. 아래 한 다스가 케이스 95% 커버:

  • button — <button> (그리고 <input type="button|submit|reset">)
  • link — <a href>
  • heading — <h1> 부터 <h6> (필요하면 { level: 2 } 로 필터)
  • textbox — <input type="text|email|..."> 와 <textarea>
  • checkbox, radio, combobox (select), option
  • list — <ul>, <ol>
  • listitem — <li>
  • img — <img alt> (alt 없으면 role 없음)
  • navigation — <nav>
  • main — <main>
  • article — <article>
  • dialog — <dialog> 또는 role="dialog" 가진 모달

`getByText` 가 값어치할 때

getByText 는 non-interactive 컨텐츠 — 본문, 상태 메시지, 에러 텍스트 — 에 맞아. "비어있을 때 폼 제출하면 'Email is required' 보임" 은 자연스럽게 screen.getByText(/email is required/i). 텍스트가 매칭된다고 인터랙티브 element 에 쓰지 마. Submit 버튼은 여전히 getByRole('button', { name: /submit/i }) 가 이겨.

Code

접근 가능한 로그인 폼·tsx
// Component
export function LoginForm({ onSubmit }: { onSubmit: (email: string) => void }) {
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const email = new FormData(e.currentTarget).get('email') as string;
        onSubmit(email);
      }}
    >
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      <button type="submit">Sign in</button>
    </form>
  );
}
좋은 쿼리 vs 안티 패턴·tsx
// ✅ Good — queries mirror how users find elements
import { render, screen } from '@testing-library/react';
import { LoginForm } from './login-form';

it('renders the form with the right controls', () => {
  render(<LoginForm onSubmit={() => {}} />);

  expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});

// ❌ Anti-pattern — using test ids when role + name would work
it('renders the form (the brittle way)', () => {
  // Now the test depends on data-testid attributes the user can't see.
  // If a designer removes the data-testid, the test breaks but the UI works.
  render(<LoginForm onSubmit={() => {}} />);
  expect(screen.getByTestId('email-input')).toBeInTheDocument();
  expect(screen.getByTestId('submit-button')).toBeInTheDocument();
});
쿼리 정제 — `level`, `name`, regex 유연성·tsx
// Refining queries with options
import { render, screen } from '@testing-library/react';

it('finds the right heading level', () => {
  render(<ArticleWithMultipleHeadings />);

  // Match the h2 specifically, not the h1 or h3
  const sectionHeading = screen.getByRole('heading', {
    name: /related articles/i,
    level: 2,
  });

  expect(sectionHeading).toBeInTheDocument();
});

it('handles multiple buttons with disambiguation', () => {
  render(<DeleteConfirmDialog />);

  // The dialog has two buttons; the `name` option disambiguates.
  expect(
    screen.getByRole('button', { name: /confirm delete/i })
  ).toBeInTheDocument();
  expect(
    screen.getByRole('button', { name: /cancel/i })
  ).toBeInTheDocument();
});
test-id — 정직한 escape hatch·tsx
// When test-id is genuinely the right answer
// (rare — usually for components that have no other accessible identifier)

import { render, screen } from '@testing-library/react';

it('finds a chart container', () => {
  render(<RevenueChart data={...} />);

  // Charts often render as <svg> with no inherent role or accessible name.
  // A test-id is honest here: there is no user-visible name to query by.
  expect(screen.getByTestId('revenue-chart')).toBeInTheDocument();
});

// Better, if you can add accessibility metadata:
// <svg role="img" aria-label="Revenue chart">  →  getByRole('img', { name: /revenue chart/i })

External links

Exercise

위 코드 블록의 로그인 폼 가져와. 추가해: (1) 빈 이메일로 submit 하면 나타나는 에러 메시지, (2) 'Remember me' 체크박스, (3) 'Forgot password?' 링크. 그 다음 각각 쿼리하는 테스트 짜: 헤딩 (role: heading), 이메일 input (role: textbox, name), submit 버튼 (role: button, name), 에러 텍스트 (getByText), 체크박스 (role: checkbox, name), forgot 링크 (role: link, name). 아무것도 getByTestId 안 써.
Hint
test-id 없는 쿼리 못 찾으면 컴포넌트가 접근성 메타데이터 (라벨, aria-label, alt 텍스트) 빠진 거. 컴포넌트 먼저 고쳐 — 테스트는 자연스럽게 따라와.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.