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

버튼 하나에 이름이 붙었는지는 그 버튼만 봐도 압니다. 그런데 글씨 색이 배경과 충분히 대비되는지, 페이지에 언어가 지정됐는지, id가 중복되진 않았는지는 요소 하나만 봐선 알 수 없어요. 화면 전체를, 그것도 실제로 그려진 상태로 봐야 하죠. 이건 사람이 매번 눈으로 하기엔 지치는 일이고, 그래서 자동 검사 엔진이 필요합니다.

axe가 바로 그 엔진입니다. 렌더된 페이지를 통째로 훑어 접근성 위반을 자동으로 잡아줘요 — 대비 부족, 이름 없는 버튼, 잘못된 ARIA 같은 것들을요. 브라우저 확장(axe DevTools)으로 한 번씩 돌려본 분도 많을 텐데, 이 글은 그걸 Playwright E2E에 넣어 매번 자동으로 돌게 만드는 방법입니다. 몇 줄이면 됩니다.

시리즈로 따라오셨다면 — 쿼리 단위의 접근성에 이어 오늘은 페이지 단위, 두 번째 안전망이에요. 검색으로 처음 오셨어도 코드는 자급자족합니다(Playwright 기초는 입문 편 참고).

실습 코드: frontend-testing-lab — 이 편 기준 스냅샷은 step-15 태그에 고정돼 있습니다.

오늘 다루는 것

  • axe-core가 어떤 엔진이고 어떻게 동작하는지
  • @axe-core/playwright 통합과 위반을 테스트 실패로 게이트하기
  • 검사 범위 좁히기·넓히기(태그·포함·제외)
  • 자동 검사가 잡는 것, 못 잡는 것, 그리고 “검토 필요”
axe 접근성 스캔이 페이지의 위반 항목들을 목록으로 보고한 화면
axe 접근성 스캔이 페이지의 위반 항목들을 목록으로 보고한 화면

axe-core가 뭔가요

방금 “axe"라고 뭉뚱그렸는데, 정확히는 axe-core입니다. 접근성 컨설팅 회사 Deque가 만든 오픈소스 접근성 검사 엔진이에요. 여기가 중요한데 — 크롬 확장 axe DevTools도, 구글 Lighthouse의 접근성 점수도, 수많은 CI 접근성 검사도 속을 열어보면 다 이 엔진 하나를 씁니다. 우리가 오늘 붙일 것도 그거예요. 한 번 배워두면 도구가 바뀌어도 결과 읽는 법은 그대로입니다.

동작 방식이 이 엔진의 성격을 말해줍니다. axe-core는 HTML 소스가 아니라, 브라우저가 실제로 그려낸 결과를 봅니다. 렌더된 DOM 트리에 계산된 스타일(computed style — CSS가 다 적용된 최종 색·크기·표시 여부)을 얹어, 약 100개의 규칙으로 하나씩 대조하죠. 그래서 CSS로 옅어진 글씨 색도, JavaScript가 나중에 주입한 ARIA 속성도 그대로 잡힙니다. 소스만 봐선 절대 알 수 없는 것들이에요.

그럼 우리가 설치할 @axe-core/playwright는 뭐냐 — 엔진(axe-core)을 Playwright가 띄운 페이지에 주입해 실행하고, 결과를 받아오는 얇은 어댑터입니다. 엔진은 브라우저 안에서 돌고, 우리는 그 결과를 Node 쪽 테스트에서 받아 검사하는 구조죠.

각 규칙은 태그로 묶여 있습니다. wcag2a·wcag2aa·wcag21aa·wcag22aa는 WCAG(웹 접근성 표준)의 버전·수준을, best-practice는 표준은 아니지만 권장되는 관행을 뜻해요. 뒤에서 이 태그로 검사 범위를 조절합니다.


페이지 전체 스캔

설치는 한 줄, 사용도 몇 줄이에요. (repo e2e/dashboard.spec.ts의 실제 테스트)

bash
npm i -D @axe-core/playwright
ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

test('접근성 위반이 없다', async ({ page }) => {
  await page.goto('/')
  await page.getByText('Alice Kim').waitFor()   // 로딩 완료 후 스캔

  const results = await new AxeBuilder({ page }).analyze()  // 페이지 전체 스캔
  expect(results.violations).toEqual([])   // 위반 0이어야 통과 — 실패 시 위반 내용이 diff로
})

코드를 뜯어보면, 핵심은 두 줄입니다. new AxeBuilder({ page }).analyze()가 지금 페이지에 엔진을 주입해 전체를 스캔하고, expect(results.violations).toEqual([])가 그 결과의 위반 배열이 비어 있어야 통과라고 못 박아요. toEqual([])로 둔 데는 이유가 있는데, 실패하면 위반 내용 전체가 diff로 출력되거든요 — 어떤 규칙을, 어느 요소가 어겼는지까지 전부. (배열이 커지면 diff도 커져 읽기 벅찰 수 있는데, 그 얘기는 곧 실패 장면에서 다시 합니다.)

돌려보면 이렇게 끝납니다.

bash
Running 1 test using 1 worker
1 [chromium] › e2e/dashboard.spec.ts:16:1 › 접근성 위반이 없다 (712ms)
  1 passed (2.1s)

검사 범위 좁히기·넓히기

기본은 “페이지 전체를 모든 규칙으로"입니다. 실무에선 이걸 조절할 일이 생기죠. AxeBuilder.analyze() 앞에 메서드를 체이닝해서 범위를 바꿉니다.

태그로 규칙을 좁히기 — 특정 WCAG 수준만 보고 싶을 때.

ts
new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze()
// 'wcag2a'·'wcag2aa' = WCAG의 A·AA 수준 규칙만 검사

특정 영역만 검사하거나 제외하기 — CSS 셀렉터로 범위를 지정합니다. 제3자 위젯(광고·지도·챗봇처럼 내가 못 고치는 iframe)은 검사에서 빼는 게 실용적이에요. 남의 코드 위반까지 내 테스트를 빨갛게 만들 필요는 없으니까요.

ts
new AxeBuilder({ page })
  .include('main')                 // 본문 영역만
  .exclude('.third-party-widget')  // 이 요소(및 하위)는 제외
  .analyze()

특정 규칙만 끄기 — 알려진 오탐이나, 팀이 의도적으로 예외로 둔 규칙이 있을 때.

ts
new AxeBuilder({ page }).disableRules(['color-contrast']).analyze()

끄는 건 신중히 하세요. disableRules로 규칙을 빼면 그 위반은 영영 안 보입니다 — 오탐이 확실할 때만, 이유를 주석으로 남기고요.


무엇을 잡나

대비(contrast) 부족, 이름 없는 컨트롤, 잘못된 ARIA, 라벨 없는 입력, 언어 미지정, id 중복 같은 규칙 기반 위반을 잡아요. 전편의 role 쿼리가 “이 버튼에 이름이 있나"를 요소 단위로 본다면, axe는 이렇게 페이지를 가로지르는 문제를 봅니다. 눈으로는 몇 번을 봐도 “옅은 게 디자인이지” 하고 넘길 대비 문제를 숫자로 잡아내고요. CI에 넣으면(다음 장에서 함께 합니다) 이런 회귀가 배포 전에 걸립니다.

그런데 axe 결과가 위반(violations)만 있는 건 아니에요. 검사 결과는 네 갈래로 나뉩니다.

axe 검사 결과가 네 갈래로 나뉘는 다이어그램 - 렌더된 화면(DOM과 계산된 스타일)을 axe-core 엔진이 약 100개 규칙으로 검사해 violations(위반, 규칙을 확실히 어긴 것으로 테스트 실패로 게이트), incomplete(검토 필요, axe가 판단 못 해 사람이 확인해야 하는 것, 예를 들어 배경 이미지 위 텍스트), passes(통과), inapplicable(해당 없음, 그 규칙을 적용할 요소가 페이지에 없음)으로 분류한다. axe는 소스가 아니라 실제 그려진 결과를 검사하므로 CSS로 바뀐 색과 JS로 주입된 ARIA까지 반영한다
axe 검사 결과가 네 갈래로 나뉘는 다이어그램 - 렌더된 화면(DOM과 계산된 스타일)을 axe-core 엔진이 약 100개 규칙으로 검사해 violations(위반, 규칙을 확실히 어긴 것으로 테스트 실패로 게이트), incomplete(검토 필요, axe가 판단 못 해 사람이 확인해야 하는 것, 예를 들어 배경 이미지 위 텍스트), passes(통과), inapplicable(해당 없음, 그 규칙을 적용할 요소가 페이지에 없음)으로 분류한다. axe는 소스가 아니라 실제 그려진 결과를 검사하므로 CSS로 바뀐 색과 JS로 주입된 ARIA까지 반영한다

우리가 위에서 게이트한 건 이 중 violations입니다. 눈여겨볼 건 incomplete(검토 필요)예요. axe가 규칙을 적용하려다 자동으로는 판단 못 한 항목입니다. 대표적인 게 배경 이미지 위의 텍스트 — 배경이 단색이 아니라 사진이면 대비를 계산할 기준색을 못 정하거든요. 그래서 “위반"이 아니라 “사람이 봐주세요"로 빼둡니다. results.violations만 보면 이걸 놓치니, 접근성을 진지하게 챙긴다면 results.incomplete도 로그로 남겨 한 번씩 훑는 걸 권해요.

말로 하면 심심하니, violations가 직접 잡히는 장면을 봅시다.


일부러 부숴봅시다

목록의 이메일 글씨 색을 회색에서 더 옅은 회색으로 한 줄 바꿔봤습니다 — 눈으로는 “좀 연해졌네?” 수준의, 리뷰에서 그냥 통과될 변화예요.

css
small { color: #b0b0b0; }   /* 원래는 #595959 */

axe 테스트는 곧장 빨간불입니다.

bash
1 [chromium] › e2e/dashboard.spec.ts:16:1 › 접근성 위반이 없다 (707ms)

    Error: expect(received).toEqual(expected) // deep equality
    - Expected  -   1
    + Received  + 128
    - Array []
    + Array [
    +   Object {
    +     "id": "color-contrast",
    +     "impact": "serious",
    +     "help": "Elements must meet minimum color contrast ratio thresholds",
    +     "helpUrl": "https://dequeuniversity.com/rules/axe/4.12/color-contrast...",
    +     "nodes": Array [
    +       Object {
    +         "failureSummary": "Fix any of the following:
    +   Element has insufficient color contrast of 2.16
    +   (foreground color: #b0b0b0, background color: #ffffff,
    +   font size: 10.0pt, font weight: normal).
    +   Expected contrast ratio of 4.5:1",
    +         "html": "<small>[email protected]</small>",
    +         "target": Array [ "li:nth-child(1) > small" ],
    ...

읽어볼 게 많아요. 어긴 규칙(color-contrast)과 심각도(serious), 고치는 법 문서 링크(helpUrl), 문제가 된 바로 그 요소(<small>[email protected]</small>)와 셀렉터까지. 압권은 failureSummary입니다 — 현재 대비율 2.16, 기준 4.5:1. “좀 연한 것 같다"는 느낌이 아니라 숫자로 말해주죠. 색 하나 바꿨을 뿐인데 diff가 128줄인 이유도 있어요 — 목록의 다섯 행 전부가 각각 위반 노드로 잡혔거든요. 실패 출력만 따라가도 고칠 곳이 나옵니다. 에러 메시지가 이렇게 친절한 검사 도구는 흔치 않아요.


자동화의 한계

axe는 만능이 아니에요. “맥락에 맞는 대체텍스트인지”, “포커스 순서가 논리적인지"는 여전히 사람이 봐야 합니다. 자동 검사는 접근성의 ‘바닥’을 지켜줄 뿐, ‘천장’은 사람이 올려요.


role 쿼리 계약 + 이번 편의 axe 스캔 = 두 겹의 접근성 안전망. 여기에 곧 CI 게이트까지 얹으면, 접근성이 ‘가끔 점검’에서 ‘상시 보증’으로 바뀝니다.

두 겹의 접근성 안전망 다이어그램 - 첫 번째 그물은 요소 단위의 role 쿼리 계약으로 이름 없는 버튼과 끊긴 레이블을 테스트 실패로 잡고, 두 번째 그물은 페이지 단위의 axe 스캔으로 색 대비와 잘못된 ARIA, 언어 미지정을 잡습니다. 두 그물을 통과해도 대체텍스트의 적절성이나 포커스 순서 같은 맥락 판단은 사람의 몫으로 남는다는 안내가 아래 붙어 있습니다
두 겹의 접근성 안전망 다이어그램 - 첫 번째 그물은 요소 단위의 role 쿼리 계약으로 이름 없는 버튼과 끊긴 레이블을 테스트 실패로 잡고, 두 번째 그물은 페이지 단위의 axe 스캔으로 색 대비와 잘못된 ARIA, 언어 미지정을 잡습니다. 두 그물을 통과해도 대체텍스트의 적절성이나 포커스 순서 같은 맥락 판단은 사람의 몫으로 남는다는 안내가 아래 붙어 있습니다

axe를 처음 붙이면 위반이 우수수 쏟아져 당황할 수 있어요. 미루던 건강검진 결과지를 한꺼번에 받아든 기분이지만 — 정상입니다. 하나씩 고치다 보면 어느새 초록불이에요.


한 장 요약

  • axe-core = Deque의 오픈소스 접근성 검사 엔진. 크롬 확장·Lighthouse·CI가 다 쓰는 그것 — 소스가 아니라 렌더된 화면(DOM+계산된 스타일)을 약 100개 규칙으로 검사
  • @axe-core/playwright는 그 엔진을 페이지에 주입·실행하는 어댑터 — 로딩 완료를 기다린 뒤 new AxeBuilder({ page }).analyze()expect(results.violations).toEqual([])
  • toEqual([])의 보너스: 실패 시 위반 내용 전체(규칙·요소·도움말·대비율)가 diff로 출력
  • 범위 조절: withTags(수준), .include()/.exclude()(영역·제3자 위젯 제외), disableRules(오탐 — 신중히)
  • 결과는 네 갈래 — violations(위반)·incomplete(검토 필요)·passes·inapplicable. incomplete는 axe가 판단 못 한 것이니 사람이 훑기
  • 자동 검사는 접근성의 바닥을 지킬 뿐 — 맥락 판단(대체텍스트의 적절성, 포커스 순서 등)은 여전히 사람 몫

두 겹의 안전망을 걸고

접근성까지 자동으로 지키게 됐습니다. 다음 편은 눈에 보이는 회귀를 잡는 시각적 회귀 테스트입니다.

레벨업: 페이지 전체의 접근성 위반을 자동으로 걸러낼 수 있습니다.

다음 편: 시각적 회귀 테스트 — 스크린샷 비교

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