「프론트엔드 테스트 제대로 하기」 시리즈의 7편입니다. 전체 목차 보기 · 용어집

Testing Library는 React를 비롯한 컴포넌트 테스트의 사실상 표준 도구입니다. 그런데 이 도구의 진짜 알맹이는 API가 아니라 철학 하나예요 — 컴포넌트 테스트에 들어서면 “클래스 이름으로 찾을까? 내부 state를 들여다볼까?” 하는 유혹이 생기는데, Testing Library는 단호하게 말합니다. 사용자가 하는 대로 하라. 이 글에서 그 철학이 무슨 뜻인지, 쿼리를 고르는 순서, 그리고 왜 이 방식이 접근성과 한 몸인지를 검색 컴포넌트를 직접 만들며 확인합니다.

시리즈로 따라오셨다면 — 1장에서 기른 함수 단위의 기초 체력(AAA·비동기·테스트 더블)을 들고, 오늘부터 2장 실제 화면으로 넘어갑니다. 검색으로 처음 오셨어도 괜찮아요. 예제 컴포넌트는 이 글에서 처음부터 만들고, 쓰이는 유틸 함수 하나만 단위 테스트 편에서 가져옵니다. (React 예제지만 쿼리 철학은 Vue·Svelte용 Testing Library에서도 동일합니다.)

실습 코드: 이 편의 상태가 step-07 태그에 그대로 담겨 있습니다. git checkout step-07으로 따라오시거나, step-06과 비교로 이 편이 더한 것만 볼 수도 있고, StackBlitz에서 바로 열기도 됩니다.

이번 편에서 배울 것

  • ‘구현이 아닌 동작’을 테스트한다는 말의 의미
  • 쿼리 우선순위 — getByRole이 왜 첫 번째인가
  • getBy / queryBy / findBy 3형제 구분
  • 왜 이 철학이 접근성과 통하는가
Testing Library 쿼리 우선순위를 계단으로 표현한 다이어그램 - 맨 위 getByRole(역할과 이름, 사용자·접근성 관점)부터 getByLabelText, getByPlaceholderText·getByText를 거쳐 맨 아래 최후의 수단인 getByTestId까지 내려가며, 아래로 갈수록 사용자 관점에서 멀어진다는 화살표가 표시되어 있습니다
Testing Library 쿼리 우선순위를 계단으로 표현한 다이어그램 - 맨 위 getByRole(역할과 이름, 사용자·접근성 관점)부터 getByLabelText, getByPlaceholderText·getByText를 거쳐 맨 아래 최후의 수단인 getByTestId까지 내려가며, 아래로 갈수록 사용자 관점에서 멀어진다는 화살표가 표시되어 있습니다

드디어, 화면을 만듭니다

환경 세팅 편에서 예고했던 사용자 검색 컴포넌트의 첫 버전을 만들 차례예요. (Vitest·Testing Library 설치와 설정도 그 편에 있으니, 처음 오셨다면 세팅만 빌려오세요.) 아직 서버 연동은 없이(그건 이 챕터 뒷부분에서), 고정된 목록을 검색하는 것부터 시작합니다. src/components/UserSearch.tsx:

tsx
import { useState } from 'react'
import { filterByQuery, type User } from '../lib/users'

const initialUsers: User[] = [
  { id: '1', name: 'Alice Kim', email: '[email protected]' },
  { id: '2', name: 'Bob Lee', email: '[email protected]' },
  { id: '3', name: 'Carol Park', email: '[email protected]' },
]

export function UserSearch() {
  const [query, setQuery] = useState('')
  const visible = filterByQuery(initialUsers, query)

  return (
    <section aria-labelledby="search-heading">
      <h2 id="search-heading">사용자 검색</h2>
      <label htmlFor="q">검색</label>{' '}
      <input
        id="q"
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="이름 또는 이메일"
      />
      {visible.length === 0 ? (
        <p>검색 결과가 없습니다.</p>
      ) : (
        <ul>
          {visible.map((u) => (
            <li key={u.id}>
              {u.name} <small>{u.email}</small>
            </li>
          ))}
        </ul>
      )}
    </section>
  )
}

단위 테스트 편에서 만든 filterByQuery가 드디어 화면에 데뷔했습니다. 그리고 마크업을 잘 보면 — labelhtmlFor로 입력창과 연결돼 있고, section은 제목과 aria-labelledby로 묶여 있고, 입력창은 type="search"예요. 그냥 “착한 HTML"을 쓴 것뿐인데, 이게 잠시 뒤 테스트에서 그대로 무기가 됩니다.

App.tsx<main> 안에 <UserSearch />를 추가하면 준비 끝입니다.


사용자는 role로 봅니다

이 화면을 사용자는 어떻게 인식할까요? “사용자 검색이라는 제목 아래 검색이라고 적힌 입력창이 있고, 목록이 보인다.” — 클래스 이름도, useState도 등장하지 않죠. Testing Library의 쿼리는 정확히 이 인식을 코드로 옮깁니다.

같은 화면을 두 방식으로 본 비교 다이어그램 - 왼쪽은 눈으로 본 UI(사용자 검색 제목, 검색 입력창, 사용자 3명 목록)이고, 오른쪽은 같은 화면의 role과 name 트리(heading 사용자 검색, searchbox 검색, list 아래 listitem 셋)입니다. 각 role 옆에 h2, type=search+label, ul, li 등 어떤 HTML에서 왔는지 표시되어 있고, 하단에 Testing Library의 쿼리는 오른쪽 세계에서 요소를 찾는다는 설명이 있습니다
같은 화면을 두 방식으로 본 비교 다이어그램 - 왼쪽은 눈으로 본 UI(사용자 검색 제목, 검색 입력창, 사용자 3명 목록)이고, 오른쪽은 같은 화면의 role과 name 트리(heading 사용자 검색, searchbox 검색, list 아래 listitem 셋)입니다. 각 role 옆에 h2, type=search+label, ul, li 등 어떤 HTML에서 왔는지 표시되어 있고, 하단에 Testing Library의 쿼리는 오른쪽 세계에서 요소를 찾는다는 설명이 있습니다
ts
screen.getByRole('heading', { name: '사용자 검색' })  // "제목이 있다"
screen.getByLabelText('검색')                         // "검색이라고 적힌 입력창"
screen.getByRole('searchbox')                         // type="search"가 주는 role

role은 요소의 의미(제목·버튼·검색창·목록)이고, name은 그 요소를 부르는 이름입니다. 둘 다 스크린 리더가 사용자에게 읽어주는 바로 그 정보예요. 그리고 이 정보는 따로 달아주는 게 아니라 HTML에서 저절로 나옵니다<h2>는 heading, type="search" 입력창은 searchbox, <li>는 listitem. 착한 HTML을 썼다면 role은 이미 준비돼 있어요.

첫 컴포넌트 테스트를 이 눈으로 써봅시다. 처음 보는 도구 둘만 소개할게요 — **render**는 컴포넌트를 테스트용 가짜 브라우저 화면(jsdom)에 실제로 그리는 함수이고, **screen**은 그 화면에서 요소를 찾는 창구입니다. src/components/UserSearch.test.tsx:

tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { UserSearch } from './UserSearch'

describe('UserSearch', () => {
  it('제목·검색창·전체 목록을 보여준다', () => {
    render(<UserSearch />)

    expect(screen.getByRole('heading', { name: '사용자 검색' })).toBeInTheDocument()
    expect(screen.getByRole('searchbox', { name: '검색' })).toBeInTheDocument()
    expect(screen.getAllByRole('listitem')).toHaveLength(3)
  })
})

getAllByRolegetByRole의 복수형입니다 — 목록 항목처럼 여럿인 요소를 배열로 받아요. toBeInTheDocument()는 “화면에 존재한다"를 읽히는 대로 검증해주는 매처로, 환경 세팅 편에서 깔아둔 jest-dom이 제공합니다. 세 줄의 검증 모두 “화면에 무엇이 보이는가"만 묻고 있습니다. useState가 몇 개인지는 테스트가 알 바 아니에요.


쿼리 우선순위 — 위에서부터 고르세요

요소를 찾는 방법이 여러 가지라면, Testing Library는 이 순서로 고르라고 권합니다. 위로 갈수록 사용자가 실제로 인식하는 방식에 가깝습니다.

  1. getByRole — 역할 + 이름. 거의 모든 것을 이걸로 찾을 수 있고, 찾을 수 있어야 정상입니다
  2. getByLabelText — 폼 요소라면. 레이블은 사용자도 보는 정보니까요
  3. getByPlaceholderText / getByText — 차선책. 플레이스홀더는 레이블 대용품이 못 되지만, 현실에선 종종 만납니다
  4. getByTestId최후의 수단. 사용자에겐 보이지 않는 개발자용 표식이에요

왜 이 순서일까요? 1번으로 찾을 수 없는 요소는 대개 마크업에 문제가 있기 때문입니다. 버튼인데 role이 button이 아니라면(div onClick…), 테스트가 불편해지기 전에 사용자가 먼저 불편했을 거예요. 쿼리 우선순위는 편의 순서가 아니라, 화면의 품질을 검사하는 순서입니다.

getBy / queryBy / findBy — 상황 3형제

접두사도 상황에 따라 셋으로 나뉩니다. 단위 테스트 편의 경계값 질문(“비어 있으면? 없으면?")과 묶어 기억하면 좋아요.

  • getBy — 지금 있어야 정상 → 없으면 즉시 실패
  • queryBy없어야 정상 → 없으면 null (에러 아님). “검색 결과 없음 문구가 사라졌다” 같은 부재 검증용
  • findBy 나타날 예정 → 나타날 때까지 기다림. 서버 응답 후 그려지는 요소용 (다음 편들에서 활약합니다)
ts
expect(screen.queryByText('검색 결과가 없습니다.')).not.toBeInTheDocument()

“없다"를 검증할 때 getBy를 쓰면 요소를 못 찾는 순간 테스트가 터져버립니다. 부재 검증은 반드시 queryBy — 이건 시험에 나와요. (진짜로, 면접에서요.)

getBy, queryBy, findBy 세 접두사를 세 개의 카드로 비교한 다이어그램 - getBy는 지금 있어야 정상이고 없으면 즉시 실패, queryBy는 없어야 정상이고 없으면 에러 대신 null을 반환해 부재 검증에 사용, findBy는 곧 나타날 요소를 기다렸다가 반환하는 비동기용입니다. 하단에 있어야 하면 get, 없어야 하면 query, 기다려야 하면 find라는 요약이 있습니다
getBy, queryBy, findBy 세 접두사를 세 개의 카드로 비교한 다이어그램 - getBy는 지금 있어야 정상이고 없으면 즉시 실패, queryBy는 없어야 정상이고 없으면 에러 대신 null을 반환해 부재 검증에 사용, findBy는 곧 나타날 요소를 기다렸다가 반환하는 비동기용입니다. 하단에 있어야 하면 get, 없어야 하면 query, 기다려야 하면 find라는 요약이 있습니다

구현을 테스트하지 마라

이 철학의 반대편도 봐둬야 합니다. 만약 테스트가 “useState가 두 번 호출된다"나 “내부 함수 이름"에 의존한다면 — UserSearch를 useReducer로 리팩터링하는 순간, 동작은 그대로인데 테스트가 와르르 깨집니다. 테스트 전략 편에서 봤던 ‘구현 박제’ 함정이 컴포넌트에서 재현되는 거죠.

반대로 오늘처럼 “제목이 보인다, 검색창이 있다, 목록이 3개다"만 검증하면, 내부를 어떻게 갈아엎어도 테스트는 태연합니다. 리팩터링을 견디는 테스트가 좋은 테스트예요. 기준은 하나 — 사용자가 알아챌 변화면 테스트도 알아채야 하고, 사용자가 모를 변화면 테스트도 몰라야 합니다.


일부러 부숴봅시다

이번 편의 부수기는 특별합니다. UserSearch.tsx에서 htmlFor="q"를 지워보세요.

tsx
<label>검색</label>   {/* htmlFor 삭제! */}

화면은 멀쩡해 보입니다. 레이블도 입력창도 그대로 그 자리에 있어요. 하지만 테스트는 빨간불이 됩니다.

bash
 × UserSearch › 제목·검색창·전체 목록을 보여준다
   TestingLibraryElementError: Unable to find an accessible element
   with the role "searchbox" and name "검색"
htmlFor를 지운 뒤의 두 시점 비교 - 왼쪽은 눈으로 본 화면으로 레이블과 입력창이 그대로라 티가 나지 않지만, 오른쪽 role·name 시점에서는 label과 input의 연결이 끊겨 searchbox의 Name이 빈 문자열이 된 실측값이 보입니다. 아래에는 실제 테스트 실패 메시지 Unable to find an accessible element with the role searchbox and name 검색이 있습니다
htmlFor를 지운 뒤의 두 시점 비교 - 왼쪽은 눈으로 본 화면으로 레이블과 입력창이 그대로라 티가 나지 않지만, 오른쪽 role·name 시점에서는 label과 input의 연결이 끊겨 searchbox의 Name이 빈 문자열이 된 실측값이 보입니다. 아래에는 실제 테스트 실패 메시지 Unable to find an accessible element with the role searchbox and name 검색이 있습니다

연결이 끊긴 순간 입력창은 “검색"이라는 이름을 잃었습니다. 스크린 리더 사용자에게는 정체불명의 입력창이 된 거예요. 눈으로 백 번 봐도 잡을 수 없는 문제를, 사용자 관점 쿼리가 한 방에 잡아냈습니다. htmlFor를 되돌리면 다시 초록불.

이게 이 철학의 보너스입니다. getByRole로 못 찾는 요소는 스크린 리더도 못 찾습니다. 좋은 테스트를 쓰는 것만으로 접근성 회귀가 잡히는 거예요. 이 이야기는 조금 뒤에 본격적으로 다룹니다.


한 장 요약

  • Testing Library의 철학 = 사용자가 하는 대로 — 구현(클래스·state)이 아니라 화면에 보이는 동작을 검증
  • 쿼리는 위에서부터: getByRole > getByLabelText > getByText > getByTestId(최후) — 우선순위는 편의가 아니라 화면 품질의 검사 순서
  • 1번으로 못 찾는 요소는 마크업을 먼저 의심할 것(사용자가 먼저 불편했을 테니)
  • 3형제: 있어야 하면 getBy, 없어야 하면 queryBy, 곧 나타나면 findBy
  • getByRole로 못 찾으면 스크린 리더도 못 찾는다 — 좋은 쿼리가 곧 접근성 검증

사용자의 눈으로

컴포넌트 테스트의 눈을 갖췄습니다. 화면을 구현의 창고가 아니라 사용자의 무대로 보는 눈이요. 다음 편에서는 이 무대에서 실제로 움직여봅니다 — 타이핑하고, 지우고, 결과가 바뀌는 상호작용 테스트입니다.

getByTestId는 정말 최후의 수단으로만 아껴두세요. 남발하는 순간 “사용자처럼"이 아니라 “개발자처럼” 테스트하게 되고, 그 테스트는 화면의 품질에 대해 아무 말도 해주지 않게 됩니다. (가끔은 필요합니다. 가끔만요.)

레벨업: 구현이 아니라 사용자 관점으로 컴포넌트를 쿼리할 수 있습니다.

다음 편: 폼·상호작용·상태 테스트 — user-event로

낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.