# 접근성을 테스트에 녹이기 — role 기반 쿼리

> 잘 짠 테스트는 곧 접근성 검증입니다. role 기반 쿼리와 접근 가능한 이름(accessible name)을 활용해, 테스트로 접근성 회귀를 잡는 법을 다룹니다.

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

---


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

"접근성 테스트"라고 하면 어떤 그림이 떠오르시나요? 전문 컨설팅을 의뢰하고, 별도의 검사 도구를 도입하고, 체크리스트 수백 개를 훑는… 그런 큰일처럼 느껴지기 쉽습니다. 그런데 놀랍게도, **접근성 검증의 절반은 컴포넌트 테스트를 "잘" 쓰는 것만으로 공짜로 따라옵니다.** 이 글은 그 원리와 실전 방법입니다 — div 버튼 걸러내기, 아이콘 버튼에 이름 붙이기, 사라지는 포커스 지키기까지.

시리즈로 따라오셨다면 이 블로그의 색깔이 가장 진하게 나오는 편이에요. 검색으로 처음 오셨다면, 필요한 배경은 딱 네 가지입니다:

- **스크린 리더**: 화면을 음성으로 읽어주는 보조기술입니다. 시각장애인 사용자는 화면 대신 이 소리로 웹을 탐색해요
- **쿼리(query)**: 테스트 코드에서 "화면의 요소를 찾는 함수"를 말합니다. `getByRole('button')`이면 "버튼 역할의 요소를 찾아줘"라는 뜻이에요 (자세한 기초는 [철학 편](/posts/frontend-testing-testing-library/)에)
- **무대**: 이 글의 예제는 "사용자 목록을 검색하는 화면"(`UserSearch`)입니다 — 입력창에 이름을 치면 목록이 걸러지고, ✕ 버튼으로 지우는 흔한 UI예요
- **환경**: 코드는 Vitest + React Testing Library로 도는 컴포넌트 테스트입니다 ([환경 세팅 편](/posts/frontend-testing-setup-vitest/) 그대로)

이 넷만 쥐고 있으면, 오늘 글은 이 한 편으로 완결됩니다.

> 실습 코드: 이 편의 상태가 [`step-10` 태그](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-10)에 담겨 있습니다(코드는 지난 편과 동일 — 이 편은 이미 있던 코드를 접근성 계약의 눈으로 다시 읽는 편이에요). [StackBlitz에서 바로 열기](https://stackblitz.com/github/IsaacEryn/frontend-testing-lab/tree/step-10)도 됩니다.

## 이 편을 읽고 나면

- role·accessible name이 곧 접근성 계약
- 테스트가 접근성 회귀를 잡는 원리
- div 버튼 같은 안티패턴을 테스트로 걸러내기

{{< img src="images/contents/three-readers.png" alt="같은 화면을 세 독자가 읽는 구조 다이어그램 - 눈으로 보는 사용자는 렌더링된 화면을, 테스트 코드의 getByRole 쿼리와 스크린 리더는 둘 다 접근성 트리라는 같은 명단을 읽습니다. 테스트와 스크린 리더가 같은 정보원을 보기 때문에 테스트 통과가 곧 접근성 검증이 된다는 설명이 붙어 있습니다" >}}

---

## 처음이라면 — 테스트는 이렇게 돌아갑니다

혹시 테스트 코드를 직접 돌려본 적이 없다면, 이 그림만 잡고 갑시다. (이미 돌려보신 분은 다음 섹션으로 바로 넘어가셔도 좋아요.)

**테스트는 파일입니다.** 컴포넌트 옆에 나란히 놓인 `UserSearch.test.tsx` 같은 파일에, "이 화면은 이래야 한다"는 확인 목록을 코드로 적어둔 거예요.

**실행은 한 줄입니다.** 터미널에서 `npm test`를 치면 Vitest가 테스트 파일을 전부 찾아 실행합니다. `npm run test:watch`로 켜두면 코드를 **저장할 때마다 자동으로 다시** 돌려주고요 — 사람이 다시 시키지 않아도요.

**결과는 이렇게 보입니다.** 이 글의 무대인 검색 화면의 테스트를 실제로 돌린 출력입니다:

```bash
 ✓ UserSearch > 사용자 목록을 불러와 보여준다
 ✓ UserSearch > 검색어로 목록을 필터링한다
 ✓ UserSearch > 서버 오류 시 role="alert"로 알린다
 ✓ UserSearch > 지우기 버튼은 접근 가능한 이름을 가진 버튼이다

 Test Files  1 passed (1)
      Tests  4 passed (4)
   Duration  520ms
```

0.5초. 같은 네 가지를 눈으로 확인하면 몇 분이 걸리고, 그마저도 **오늘 한 번뿐**입니다. 테스트는 이 확인을 코드가 바뀔 때마다 반복해줘요 — 반년 뒤 누군가 이 컴포넌트를 리팩터링해도, 네 가지 약속이 지켜지는지 0.5초 만에 다시 검사됩니다. 테스트로 얻는 것의 본체가 이겁니다: **한 번 적어둔 확인이, 자동으로 영원히 반복되는 것.**

그리고 네 번째 줄을 봐주세요 — "지우기 버튼은 접근 가능한 이름을 가진 버튼이다". 오늘 글은 저 한 줄이 왜 접근성 검증이 되는지, 그리고 저 초록불이 빨간불로 바뀌는 순간 무엇이 드러나는지의 이야기입니다. (빨간불 장면은 글 끝 「일부러 부숴봅시다」에서 직접 만듭니다.)

---

## getByRole은 접근성 계약

브라우저는 화면을 그리면서 또 하나의 자료구조를 만듭니다 — **접근성 트리**(accessibility tree). DOM에서 시각 장식을 걷어내고 "이 요소는 무엇이고(role) 뭐라고 불리는가(name)"만 남긴 목록으로, 스크린 리더가 읽는 원본이 바로 이거예요. 비유하자면 요소들의 **명함첩**입니다. 명함에는 직함(role: 버튼·제목·검색창)과 이름(name: '검색어 지우기')이 적혀 있고요.

직함(role)은 따로 달아주는 게 아니라 **HTML에서 저절로 나옵니다.** `<button>`은 button, `<h2>`는 heading, `<input type="search">`는 searchbox — 올바른 태그를 썼다면 명함첩 등록은 이미 끝나 있어요. (필요하면 `role="alert"`처럼 직접 달 수도 있는데, 그 쓰임새는 잠시 뒤에 나옵니다.)

이제 테스트 쿼리를 이 눈으로 봅시다. `getByRole('button', { name: '검색어 지우기' })`가 통과하려면, 그 요소의 명함에 **button이라는 직함**과 **'검색어 지우기'라는 이름**이 실제로 올라가 있어야 합니다. 스크린 리더가 사용자에게 읽어주는 것("검색어 지우기, 버튼")과 정확히 같은 정보를 요구하는 거예요. 그래서 이 쿼리는 단순한 "요소 찾기"가 아니라 **계약**입니다 — "이 화면은 보조기술 사용자에게 이렇게 읽힌다"는 계약. 테스트가 초록불이라는 건 그 계약이 지켜지고 있다는 뜻입니다.

그럼 이름은 어떻게 정해질까요? 브라우저가 따지는 우선순위가 있습니다.

1. `aria-labelledby` — 다른 요소를 가리켜 "쟤가 내 이름표"
2. `aria-label` — 직접 적어넣은 이름
3. `<label>` 연결(폼 요소) 또는 요소 안의 텍스트(버튼·링크)
4. placeholder는? — **이름이 되지 못합니다.** [철학 편](/posts/frontend-testing-testing-library/)에서 htmlFor를 지우자 이름이 빈 문자열이 됐던 게 그 실측 증거예요

{{< img src="images/contents/accessible-name-calc.png" alt="접근 가능한 이름이 정해지는 우선순위 다이어그램 - 1순위 aria-labelledby, 2순위 aria-label, 3순위 label 연결이나 요소 안의 텍스트 순서로 검사하며, placeholder는 이름이 되지 못한다고 별도로 표시되어 있습니다. 하단에는 이 이름이 곧 스크린 리더가 읽는 이름이자 getByRole의 name이라는 설명이 있습니다" >}}

---

## div 버튼을 잡아내다

CSS로 버튼처럼 꾸민 `<div onClick>`은 화면에선 버튼과 구분이 안 됩니다. 하지만 명함첩에는 직함 없이 올라가요 — 그리고 잃는 게 셋입니다.

- **Tab 키로 갈 수 없습니다** (포커스 불가 — 키보드 사용자에겐 존재하지 않는 버튼)
- **Enter/Space로 누를 수 없습니다** (onClick은 마우스 클릭만 받으니까)
- **스크린 리더가 "버튼"이라고 안 읽어줍니다** (그냥 "✕, 텍스트")

`<button>`을 쓰면 이 셋이 전부 **공짜**인데 말이죠. 그리고 role 기반 쿼리는 이 안티패턴을 그냥 지나치지 않습니다.

```tsx
// 이렇게 짜면
<div className="btn" onClick={onClear}>✕</div>
// getByRole('button', ...) 은 실패한다 → 테스트가 접근성 문제를 고발
```

테스트가 빨간불이 되는 순간, 접근성 문제도 함께 드러나는 거죠. 마크업을 고치기 전까지는 초록불을 안 줍니다.

{{< img src="images/contents/div-vs-button.png" alt="div로 만든 가짜 버튼과 진짜 button 요소의 비교 다이어그램 - 왼쪽 div 버튼은 겉모습은 같지만 Tab 포커스 불가, Enter와 Space 미반응, 스크린 리더에 버튼으로 안 읽힘이라는 세 가지를 잃고 getByRole 쿼리도 실패합니다. 오른쪽 button 요소는 세 가지가 전부 기본 제공되고 쿼리도 통과합니다" >}}

---

## 실전: 아이콘 버튼에 이름 붙이기

무대인 검색 화면에는 검색어를 한 번에 지우는 **✕ 버튼**이 있습니다. 시리즈로 오셨다면 [상호작용 편](/posts/frontend-testing-user-event/)에서 본 그 코드예요 — 그때는 "클릭을 어떻게 테스트하나"의 눈으로 봤다면, 오늘은 같은 코드를 **접근성 계약의 눈**으로 다시 봅니다.

아이콘만 있는 버튼의 문제는 이렇습니다. 이름 우선순위 3번(요소 안의 텍스트)에 걸리는 게 **✕ 한 글자뿐**이라, 명함에 "✕"가 이름으로 올라가요. 스크린 리더 사용자는 **"곱하기 기호, 버튼"** 같은 소리를 듣게 됩니다 — 지우는 버튼인지, 닫는 버튼인지, 뭘 하는 버튼인지 알 길이 없죠. `aria-label`을 붙이면(우선순위 2번이라 ✕를 이김) 제대로 된 이름이 명함에 오르고, 테스트도 그 이름으로 버튼을 찾을 수 있게 됩니다. **한 번에 두 마리.** (repo 실제 코드+테스트)

```tsx
// src/components/UserSearch.tsx
{query && (   // 검색어가 있을 때만 버튼을 보여준다
  <button type="button" aria-label="검색어 지우기" onClick={clearQuery}>
    ✕
  </button>
)}
```

이걸 못 박는 테스트입니다. 처음 보는 재료 셋만 짚으면 — `render`는 컴포넌트를 테스트용 가짜 화면에 그리는 함수, `screen`은 그 화면에서 요소를 찾는 창구, `userEvent`는 타이핑·클릭을 실제 사용자처럼 재현하는 도구예요(각각 [철학 편](/posts/frontend-testing-testing-library/)·[상호작용 편](/posts/frontend-testing-user-event/)의 주인공들입니다).

```tsx
it('지우기 버튼은 접근 가능한 이름을 가진 버튼이다', async () => {
  const user = userEvent.setup()
  render(<UserSearch />)
  await screen.findByText('Alice Kim')   // 서버 로딩 완료 대기
  await user.type(screen.getByLabelText('검색'), 'bob')

  // aria-label이 준 이름으로 버튼을 찾는다 — 이름이 없으면 여기서 실패
  await user.click(screen.getByRole('button', { name: '검색어 지우기' }))

  expect(screen.getByLabelText('검색')).toHaveValue('')
})
```

에러 알림도 같은 원리입니다. 다만 "긴급 알림"에 해당하는 HTML 태그는 없어서, 이럴 때 아까 말한 **직접 다는 role**이 나섭니다. 에러 문구에 `role="alert"`를 붙이면 스크린 리더는 화면 어디에 있든 **즉시 읽어줍니다** — "빨간 글씨로 보여주기"의 비시각 버전인 셈이죠. 그리고 테스트는 `findByRole('alert')`로 그 알림의 존재를 검증합니다. ([MSW 편의 서버 오류 테스트](/posts/frontend-testing-msw/)가 정확히 이렇게 검증했어요.)

---

## 지우면, 포커스는 어디로?

한 걸음 더. 지우기 버튼을 클릭하면 검색어가 비면서 **버튼 자체가 사라집니다.** 그럼 방금까지 버튼에 있던 포커스는? 허공으로 떨어져요. 마우스 사용자는 눈치채지 못하지만, 키보드 사용자는 길을 잃습니다.

답은 포커스를 입력창으로 되돌려주는 것.

```tsx
const inputRef = useRef<HTMLInputElement>(null)

const clearQuery = () => {
  setQuery('')
  // 버튼이 사라지므로 포커스를 입력창으로 되돌린다 (키보드 사용자 배려)
  inputRef.current?.focus()
}
```

그리고 이 배려도 테스트로 못 박습니다.

```tsx
await user.click(screen.getByRole('button', { name: '검색어 지우기' }))

expect(screen.getByLabelText('검색')).toHaveValue('')
// 버튼이 사라졌으니 포커스는 입력창으로 돌아와야 한다
expect(screen.getByLabelText('검색')).toHaveFocus()
```

`toHaveFocus()` 한 줄이, 나중에 누가 이 코드를 리팩터링하다가 포커스 처리를 지워버리는 걸 막아줍니다. 접근성 배려가 회귀하지 않도록 테스트가 지키는 거예요.

---

## 일부러 부숴봅시다

이번 편의 경보기 점검. 버튼에서 `aria-label`을 지워보세요.

```tsx
<button type="button" onClick={clearQuery}>   {/* aria-label 삭제! */}
  ✕
</button>
```

화면은 아무 일 없습니다. ✕ 버튼은 그대로 그 자리에서 잘 눌려요. 하지만 테스트는 곧장 빨간불이 됩니다.

```bash
 × 지우기 버튼은 접근 가능한 이름을 가진 버튼이다
   TestingLibraryElementError: Unable to find an accessible element
   with the role "button" and name "검색어 지우기"
```

실패 출력 아래에는 지금 화면의 명함첩이 통째로 인쇄되는데, 문제의 버튼은 이렇게 나옵니다 — `button: Name "✕"`. 이름이 사라진 게 아니라 **"✕"가 이름이 된** 거예요. 눈으로는 백 번을 봐도 멀쩡한 이 변화가, 스크린 리더 사용자에게는 "검색어 지우기, 버튼"이 "곱하기 기호, 버튼"으로 바뀌는 사고입니다. 그리고 그 사고를, 접근성 검사를 따로 돌린 것도 아닌 **평범한 컴포넌트 테스트가** 잡아냈습니다. `aria-label`을 되돌려 저장하면 곧장 다시 초록불이에요.

{{< img src="images/contents/a11y-query-bridge.png" alt="하나의 계약과 두 명의 수혜자를 보여주는 다이어그램 - aria-label이 달린 버튼을 테스트는 getByRole로 찾아 통과하고 스크린 리더는 검색어 지우기 버튼으로 읽어 이해합니다. 계약이 깨지면 테스트는 빨간불, 스크린 리더는 정체불명으로 같은 이유로 함께 실패하기에, 테스트가 접근성 문제를 자동으로 고발하게 됩니다" >}}

---

> 다음 챕터의 axe 검사와 합치면, 우리는 두 겹의 접근성 안전망을 갖게 됩니다: 쿼리 단위의 계약 + 페이지 단위의 자동 스캔.

---

## 한 장 요약

- 테스트는 컴포넌트 옆 `.test.tsx` 파일 + `npm test` 한 줄 — **한 번 적어둔 확인이 코드가 바뀔 때마다 자동 반복**됩니다
- `getByRole('button', { name: '...' })` = **접근성 계약** — 통과 조건이 스크린 리더가 읽는 정보와 동일
- div 버튼·라벨 없는 입력은 쿼리가 실패하며 **자동으로 고발**됩니다 — 테스트가 곧 접근성 잔소리꾼
- 아이콘 버튼은 `aria-label`이 없으면 **"✕" 같은 기호가 이름이 됩니다** — `aria-label`로 제대로 된 이름을, 에러 메시지엔 `role="alert"`를
- 사라지는 요소에 있던 **포커스는 되돌려줄 것** — `toHaveFocus()` 한 줄이 그 배려의 회귀를 막습니다
- 이 쿼리 계약(요소 단위) 위에 axe 스캔(페이지 단위)을 얹으면 이중 안전망 — 다음 장에서 완성

---

## 정리하며

컴포넌트 챕터를 마쳤습니다. 다음 챕터는 진짜 브라우저 — Playwright로 E2E의 세계에 들어갑니다.

"접근성은 나중에"라고 미룬 코드는, 테스트를 짜는 순간 대개 정체가 드러납니다. 그리고 좋은 소식 — 오늘 배운 것 중 새로 설치한 도구는 하나도 없습니다. 어제까지 쓰던 쿼리를 그대로 썼을 뿐인데, 접근성 검증이 딸려 왔어요.

> **레벨업**: 테스트로 접근성 회귀까지 잡는, 흔치 않은 개발자가 됐습니다.

> **다음 편**: Playwright E2E 입문 — 첫 시나리오

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

