# Testing Library 철학 — 사용자처럼 쿼리하기

> React Testing Library의 핵심 철학 '사용자처럼 테스트하기'. getByRole 중심의 쿼리 우선순위와, 구현이 아닌 동작을 테스트해 리팩터링을 견디는 법을 익힙니다.

**Published:** 2026-07-10 | **Updated:** 2026-07-10

---



> 「프론트엔드 테스트 제대로 하기」 시리즈의 **7편**입니다. [전체 목차 보기](/series/프론트엔드-테스트-제대로-하기/) · [용어집](/posts/frontend-testing-glossary/)

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

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

> 실습 코드: 이 편의 상태가 [`step-07` 태그](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-07)에 그대로 담겨 있습니다. `git checkout step-07`으로 따라오시거나, [step-06과 비교](https://github.com/IsaacEryn/frontend-testing-lab/compare/step-06...step-07)로 이 편이 더한 것만 볼 수도 있고, [StackBlitz에서 바로 열기](https://stackblitz.com/github/IsaacEryn/frontend-testing-lab/tree/step-07)도 됩니다.

## 이번 편에서 배울 것

- '구현이 아닌 동작'을 테스트한다는 말의 의미
- 쿼리 우선순위 — getByRole이 왜 첫 번째인가
- getBy / queryBy / findBy 3형제 구분
- 왜 이 철학이 접근성과 통하는가

{{< img src="images/contents/query-priority.png" alt="Testing Library 쿼리 우선순위를 계단으로 표현한 다이어그램 - 맨 위 getByRole(역할과 이름, 사용자·접근성 관점)부터 getByLabelText, getByPlaceholderText·getByText를 거쳐 맨 아래 최후의 수단인 getByTestId까지 내려가며, 아래로 갈수록 사용자 관점에서 멀어진다는 화살표가 표시되어 있습니다" >}}

---

## 드디어, 화면을 만듭니다

[환경 세팅 편](/posts/frontend-testing-setup-vitest/)에서 예고했던 사용자 검색 컴포넌트의 첫 버전을 만들 차례예요. (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: 'alice@example.com' },
  { id: '2', name: 'Bob Lee', email: 'bob@example.com' },
  { id: '3', name: 'Carol Park', email: 'carol@example.com' },
]

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>
  )
}
```

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

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

---

## 사용자는 role로 봅니다

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

{{< img src="images/contents/role-view.png" alt="같은 화면을 두 방식으로 본 비교 다이어그램 - 왼쪽은 눈으로 본 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)
  })
})
```

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

---

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

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

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

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

### getBy / queryBy / findBy — 상황 3형제

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

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

```ts
expect(screen.queryByText('검색 결과가 없습니다.')).not.toBeInTheDocument()
```

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

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

---

## 구현을 테스트하지 마라

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

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

---

## 일부러 부숴봅시다

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

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

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

```bash
 × UserSearch › 제목·검색창·전체 목록을 보여준다
   TestingLibraryElementError: Unable to find an accessible element
   with the role "searchbox" and name "검색"
```

{{< img src="images/contents/broken-label.png" alt="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로

> 낯선 용어가 있었다면 — [용어집](/posts/frontend-testing-glossary/)에 전부 한 줄씩 정리돼 있어요.

