[번역] Test IDs are an a11y smell

[번역] Test IDs are an a11y smell

2026년 3월 27일

TkDodoTest IDs are an a11y smell를 번역한 글입니다.

1

요즘 들어 data-testid를 사용하는 것이 테스트에서 셀렉터를 정의하는 가장 좋은 방법이라고 주장하는 글들을 자주 접합니다. 요소 선택을 단순화하고, 유지보수성과 안정성을 높이며, UI 변경으로부터 테스트를 분리해준다고들 하죠.

저는 전혀 동의하지 않습니다.

저는 10년 넘게 data-testid 속성을 사용한 적이 없어서, 이런 주장이 아직도 나오고 있다는 게 솔직히 놀랍습니다. 예전에는 이게 하드코딩된 id나 className, 혹은 xpath 셀렉터밖에 선택지가 없던 시절의 유산이라고 생각했습니다. 만약 그것들이 (예를 들어 selenium 같은 도구로 테스트할 때처럼) 유일한 선택지라면, data-testid는 다른 모든 것보다 나아 보일 수 있습니다. 눈 먼 자들의 나라에서는 애꾸눈이 왕이니까요.

Testing Library

하지만 시대는 변했고, 이제 더 나은 선택지가 있습니다. Testing Library는 역할 기반(role-based) 셀렉터를 지향하며, 거의 모든 테스트 러너나 프레임워크와 함께 사용할 수 있습니다. playwright는 예외인데, 이미 자체적인 역할 기반 셀렉터를 내장하고 있습니다.

Testing Library의 핵심 원칙은 이렇습니다:

테스트가 소프트웨어가 사용되는 방식에 가까울수록, 더 많은 신뢰를 줄 수 있다.

이건 제가 테스팅을 바라보는 방식과 정확히 일치합니다. 구현 세부사항을 테스트하고 싶지 않습니다. 목(mock)은 적을수록 좋고, 주로 통합 테스트를 작성하는 것이 좋습니다.

사용자가 보고 상호작용할 수 있는 것에 집중해서 테스트를 작성하면, 최대한의 효과를 얻을 수 있습니다. 테스트를 바꾸지 않고도 내부를 자유롭게 리팩터링하고, 레이아웃을 바꾸고, API 호출 방식을 변경할 수 있습니다. 그것이 바로 유지보수성입니다.

결국 중요한 건 단 하나입니다: 사용자가 우리 앱을 사용할 수 있는가? API 호출 후 페이지가 뻗어버리는데 포매팅 유틸리티에 100% 유닛 테스트 커버리지가 있다고 해서 얻는 것은 아무것도 없습니다. 🤷‍♂️

그렇다면 왜 역할 기반 셀렉터가 다른 방식보다 더 나을까요? 핵심은 이겁니다:

사용자는 test ID를 볼 수 없습니다.

그래서 data-testid로 요소를 찾을 때마다 우리는 그 핵심 원칙을 위반하는 것입니다. 물론 그것만으로는 충분한 이유가 되지 않습니다. 원칙은 존재할 권리가 있지만, 우리가 무엇을 하는지 알고 있다면 무시할 권리도 있습니다. DRY처럼 “그냥 원칙이니까” 지켜야 한다는 건 충분한 이유가 못 됩니다.

접근성 (Accessibility)

다시 강조하지만, 중요한 건 사용자가 우리 앱과 상호작용할 수 있는가입니다. 모든 사용자를 위해서요. European Accessibility ActAmericans with Disabilities Act 같은 법률은 이 주제를 진지하게 다루도록 요구하며, WCAG 2.1 AA 호환성을 요구합니다.

접근성을 제대로 구현하는 건 어렵습니다. react-aria처럼 일급 접근성에 집중한 프리미티브를 사용하는 게 매우 도움이 되고, 저는 이런 라이브러리 없이 컴포넌트를 만드는 걸 권장하지 않습니다. 하지만 그래도 완전히 실수를 막아주지는 않습니다. 그리고 사실 대부분의 팀은 명시적인 a11y 테스트를 하지 않습니다.

예시

test ID와 함께 가장 많이 등장하는 예시는 이런 식입니다:

function WidgetDialogTrigger({ onClick }: Props) {
  return (
    <button data-testid="widget-dialog-trigger" onClick={onClick}>
      Open Widget
    </button>
  );
}

이제 테스트에서 이 버튼을 클릭하려면 이렇게 하게 됩니다:

screen.getByTestId("widget-dialog-trigger").click();

이건 “작동”하고 충분히 쉬워 보이지만, 우리가 실제 사용자와 상호작용하는 방식으로 요소를 다루고 있지 않습니다. 버튼을 클릭 가능한 div로 바꿔도 (끔찍하지만) 테스트는 여전히 통과합니다:

function WidgetDialogTrigger({ onClick }: Props) {
  return (
    <div data-testid="widget-dialog-trigger" onClick={onClick}>
      Open Widget
    </div>
  );
}

이건 나쁩니다. 이제 그 “버튼”은 키보드로 접근할 수 없고, 올바른 시맨틱 역할도 없어서 스크린 리더가 제대로 읽지 못할 수도 있습니다. 그냥 div 수프 속의 또 다른 div일 뿐이죠.

이건 실제 이야기입니다

이 예시가 꾸며낸 것이라고 생각한다면 — 아닙니다. 실제로 sentry 코드베이스에서 이런 코드가 있었고, compactSelect 컴포넌트의 트리거로 실제 버튼을 사용하도록 강제하기 전의 이야기입니다.

다른 예시를 원한다면: 올바르게 연결된 label이 없는 input을 생각해보세요. 안타깝게도 이런 일은 너무 흔하게 일어납니다.

역할 기반 셀렉터

여기서 역할 기반 셀렉터가 도움이 됩니다. 테스트를 이렇게 작성했다면, 클릭 핸들러만 달린 div는 통과하지 못했을 겁니다:

screen.getByRole("button", { name: "Open Widget" }).click();

역할 기반 셀렉터를 테스트에서 사용하면 몇 가지 것들을 거의 공짜로 얻을 수 있습니다:

  • 어느 순간 어느 정도의 a11y 테스트를 하게 됩니다. 이것이 axe 같은 도구를 사용한 전문적인 a11y 테스트를 대체하지는 않지만, 실수로 접근 불가능한 마크업을 만들기가 훨씬 어려워집니다.
  • 테스트가 더 읽기 좋아집니다. “Open Widget 버튼 클릭”이라고 읽히는 건 정말 자연스럽고, 버튼 텍스트와 연결되어 있다는 점도 문제가 아닙니다. 버튼 텍스트는 생각보다 자주 바뀌지 않으니까요!

단도직입적으로 말하면: 역할 기반 셀렉터로 앱의 요소를 찾을 수 없다면, 마크업에 뭔가 잘못된 게 있는 겁니다. 시맨틱 HTML은 생각보다 훨씬 많은 걸 해결해줍니다.

예시

역할 기반 셀렉터로 테스트를 작성할 때 제가 즐겨 쓰는 방법은, 내가 직접 단계를 클릭할 때 하는 행동을 말로 표현한 다음, 그것을 셀렉터로 옮기는 것입니다. 예를 들어:

사이드바의 Dashboards 링크를 클릭한다

within(screen.getByRole("navigation"))
  .getByRole("link", { name: "Dashboards" })
  .click();

Save 다이얼로그에서 Confirm 버튼을 클릭한다

within(screen.getByRole("dialog", { name: "Save" }))
  .findByRole("button", { name: "Confirm" })
  .click();

회원가입 폼에서 이메일 필드를 채운다

userEvent.type(
  within(screen.getByRole("form", { name: "Registration" })).findByRole(
    "textbox",
    { name: "Email" },
  ),
  "user@example.com",
);

실천하기

위 셀렉터들이 작동하지 않는다면, 앱이 충분히 접근성 있게 만들어져 있지 않다는 신호이므로 앱을 수정해야 합니다. 어떻게 해야 할지 막막하다면, 제가 과거에 사용했던 몇 가지 도움이 될 만한 팁들을 공유합니다:

  • 시맨틱 HTML을 사용하세요. 시맨틱 HTML에는 암묵적 ARIA 역할이 포함되어 있어서, 수동으로 role 속성을 추가할 일이 거의 없습니다.
  • 인터랙티브 요소에는 눈에 보이는 텍스트나 적절한 레이블처럼 접근 가능한 이름이 있는지 확인하고, 사용자가 상호작용할 수 있는 것에만 인터랙티브 요소를 사용하세요!
  • 키보드로 앱을 탐색해보세요. 사용할 수 없는 것이 있다면 (특히 툴팁, 당신 얘기입니다), 수정이 필요합니다.
  • 제목, 랜드마크, 그룹 영역을 사용해서 UI에 명확한 구조를 부여하세요. 이렇게 하면 역할 기반 셀렉터로 페이지의 특정 부분에서 특정 텍스트를 찾기가 훨씬 쉬워집니다.
  • 폼 컨트롤에는 항상 레이블을 연결하세요. 눈에 보이는 레이블을 원하지 않더라도, <VisuallyHidden> 컴포넌트나 sr-only 클래스를 사용해서 UI에서는 보이지 않으면서도 접근 가능한 레이블을 유지할 수 있습니다.
  • Testing Playground는 마크업에서 가장 좋은 접근 가능한 셀렉터를 찾는 데 훌륭한 도구입니다.
  • 브라우저 개발자 도구에도 Accessibility 탭이 있고, 요소를 검사할 때 DOM 트리 뷰와 Accessibility 트리 뷰를 전환할 수 있어서 최적의 셀렉터를 찾기 쉽습니다.
  • 즐겨 쓰는 AI 에이전트에게 물어보세요. 진짜로요, AI는 a11y에 대해 아주 많이 알고 있습니다. 원샷으로 답변할 때 빠뜨리는 이유는, 아마 지난 10년간 우리 인간이 작성한 코드로 훈련되었기 때문일 겁니다. 그 코드들은 a11y가 부족한 경우가 많으니까요.

결론은 이렇습니다: 역할 기반 셀렉터로 테스트에서 찾을 수 없다면, 일부 사용자들도 마찬가지일 겁니다. 그런 경우에는 data-testid를 추가해서 테스트에서 우회하는 것보다, UI 자체를 수정하는 것이 훨씬 낫습니다.