문서 읽는 데 83분 · F3

F-3: 화면을 확인한다 — 글자가 없는 버튼을 어떻게 찾을까

목차 61
전체 59강 중 41강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구입니다.

지난 시간 마지막에 질문을 하나 남겼죠. 버튼을 어떻게 찾을 것인가.

이유까지 붙였는데, 사용자는 우리 코드를 모른다고, 사용자는 "좋아요 라고 적힌 버튼"을 누른다고 했습니다.

오늘은 그 문장을 우리 앱에 그대로 들고 가봅니다. 그런데 시작하자마자 이상한 일이 생겨요.

우리 좋아요 버튼에는 글자가 하나도 없습니다.

하트 그림 하나뿐이라 "좋아요"라고 적혀 있지 않은데, 지난 시간에 제가 한 말이 틀린 걸까요, 아니면 우리 버튼이 잘못 만들어진 걸까요?

둘 다 아니고, 그 답을 찾는 과정이 오늘 배울 것의 전부예요.

텍스트
오늘의 여정

  글자로 찾아봅니다      ──  render · screen, 그리고 두 번의 실패
  역할과 이름으로        ──  getByRole 과 접근성 이름
  찾았다고 어떻게 말하나  ──  jest-dom 매처
  다른 방법들과 그 순서   ──  네 가지 중 무엇을 먼저
  있어야 · 없어야 · 곧   ──  getBy · queryBy · findBy
  누릅니다              ──  userEvent
  댓글 폼을 사용자처럼    ──  입력하고 제출하기
  바꿔도 안 깨지는 판     ──  구현을 붙잡지 않는다

💡 오늘 수업의 핵심 — "판은 사용자가 인지하는 것을 붙잡는다. 화면이 어떻게 만들어졌는지가 아니라"

🎯 학습 목표

  • renderscreen으로 컴포넌트를 그려놓고, 역할과 이름으로 원하는 요소를 찾을 수 있다.
  • 네 가지 찾는 방법의 우선순위를 이해하고, getBy·queryBy·findBy를 상황에 맞게 고를 수 있다.
  • userEvent로 사용자처럼 누르고 입력해서, 구현을 바꿔도 안 깨지는 판을 쓸 수 있다.

Step 1: "화면을 그려놓고 글자로 찾아봅니다"

지난 시간에 우리가 쓴 판을 떠올려보세요. toggleLikeInPlace에 배열을 넣고 배열을 받았고, fetchConversations에 주소를 물어 데이터를 받았습니다.

전부 화면이 없는 코드였고, 넣으면 나오는 것들이었죠.

그런데 우리 앱의 대부분은 화면입니다. 하트를 누르면 빨개지고, 댓글을 쓰면 목록에 붙어요. 이런 걸 확인하려면 먼저 화면을 그려놓아야 합니다.

그런데 브라우저가 없는데요

여기서 바로 걸립니다. 테스트는 터미널에서 도는데, 화면을 그리려면 브라우저가 필요하잖아요.

지난 시간에 우리가 열어본 설정 파일을 다시 보세요.

TypeScript
// apps/web-spa/vitest.config.ts
export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      environment: 'jsdom',
      globals: true,
      setupFiles: ['./src/test-setup.ts'],

environment: 'jsdom'이 그 답입니다. jsdom은 자바스크립트로 만든 가짜 DOM이에요. 진짜 브라우저는 아니지만 document도 있고 <button>도 있고 클릭 이벤트도 받습니다. 이 위에 화면을 그리는 거예요.

그림도 안 뜨고 창도 안 열리지만, 대신 구조와 동작은 그대로 있어요. 우리가 확인하려는 게 그거니까 충분합니다.

그려봅시다

두 가지만 새로 들여옵니다.

tsx
import { render, screen } from '@testing-library/react';

render는 컴포넌트를 jsdom 위에 그립니다. screen은 그려진 화면 전체를 가리키는 손잡이예요. 화면에서 무언가를 찾을 때는 screen에게 묻습니다.

이제 지난 시간에 남긴 문장대로 해봅시다. 사용자는 "좋아요 라고 적힌 버튼"을 누른다고 했으니까요.

tsx
// apps/web-spa/src/components/f3-like-button.test.tsx
render(<LikeButton liked={false} likeCount={1240} onToggle={() => {}} />);

screen.getByText('좋아요');

돌려보면 이렇게 나옵니다.

텍스트
Unable to find an element with the text: 좋아요. This could be because the
text is broken up by multiple elements. In this case, you can provide a
function for your text matcher to make your matcher more flexible.

못 찾았어요. 왜일까요?

버튼 안을 열어보면

LikeButton을 열어보면 이유가 바로 보입니다.

tsx
// apps/web-spa/src/components/LikeButton.tsx
<IconButton
  className="cursor-pointer p-2 transition duration-150 ..."
  aria-label="좋아요"
  aria-pressed={liked}
  onClick={onToggle}
>
  <Heart className={...} />
</IconButton>

버튼 안에 들어 있는 것은 <Heart /> 하나뿐이라, 하트 그림이지 글자가 아니에요.

실제로 버튼의 textContent를 찍어보면 빈 문자열이 나옵니다. 읽어 낼 글자가 하나도 없어요.

E-6에서 이 버튼은 원래 ♡ 좋아요라고 적힌 글자 버튼이었는데, E-7에서 인스타그램처럼 하트 아이콘으로 바꾸면서 글자가 사라졌어요.

더 위험한 두 번째 시도

자, 그러면 화면에 보이는 글자로 찾아보면 어떨까요. 개수 줄에는 "좋아요 1240개"라고 적혀 있으니까요.

tsx
const found = screen.getByText('좋아요 1240개');

이번엔 찾아지는데, 판이 초록으로 통과해요.

그런데 찾아진 게 뭔지 확인해보면 이렇습니다.

텍스트
found.tagName    'P'

버튼이 아니라 개수를 적어둔 문단이라서, 이걸 눌러도 좋아요가 눌리지 않습니다.

텍스트
  첫 번째 시도            두 번째 시도
  ─────────────────      ─────────────────
  못 찾는다               찾아진다
  판이 빨개진다            판이 초록이다
  바로 알아챈다            아무도 안 알려준다

앞의 것보다 뒤의 것이 훨씬 위험합니다. 실패는 즉시 보이지만, 엉뚱한 것을 잡은 채 통과하는 판은 계속 초록으로 남아 있어요. 그러다 어느 날 개수 줄의 문구가 바뀌면 그때야 빨개지고 우리는 엉뚱한 곳을 들여다보게 되니까, 지난 시간 문장을 고쳐야 합니다. 사용자가 누르는 것은 "좋아요 라고 적힌" 버튼이 아니에요. "좋아요 라는 뜻으로 읽히는" 버튼입니다. 눈으로 보는 사람에게는 하트 모양이 그 뜻이고, 화면을 못 보는 사람에게는 낭독기가 읽어주는 이름이 그 뜻이죠.

그 "읽히는 이름"을 찾는 방법이 다음 단계입니다.

💡 한 줄 정리

render로 jsdom 위에 화면을 그리고 screen으로 찾는데, 눈에 보이는 글자로 찾으면 안 되는 경우가 있다. 우리 좋아요 버튼처럼 글자가 없는 버튼이 그렇고, 더 나쁜 것은 비슷한 글자를 가진 엉뚱한 요소가 조용히 잡히는 것이다.

🙋 학생 질문 — "튜터님, 그러면 아이콘 버튼에 글자를 넣으면 되는 거 아닌가요?"

화면을 그렇게 만들자는 얘기라면 그건 디자인 결정이라 우리가 여기서 정할 게 아니에요. 인스타그램도, 우리가 따라 만든 이 앱도 하트만 보여줍니다.

질문을 조금 바꿔보면 훨씬 중요한 게 나오는데, 글자가 없는 버튼을 화면을 못 보는 사람은 어떻게 누를까요?

낭독기를 쓰는 사람에게 하트 그림은 아무 정보가 아니라서, 그런 버튼에는 읽어줄 이름을 따로 붙여야 해요. 그게 다음 단계에서 볼 aria-label입니다.

그러니까 순서가 반대예요. "테스트를 위해 이름을 붙이자"가 아니라, 접근성 때문에 이미 붙어 있어야 하는 이름을 테스트가 그대로 쓰는 것입니다. 테스트가 사용자 관점을 따라가면 접근성도 같이 따라오고, 반대로 접근성이 무너진 화면은 테스트도 쓰기 어려워져요.


Step 2: "역할과 이름으로 찾습니다"

버튼을 찾는 올바른 방법은 이겁니다.

tsx
// apps/web-spa/src/components/f3-like-button.test.tsx
const button = screen.getByRole('button', { name: '좋아요' });

두 조각으로 되어 있어요. 역할(role)과 이름(name)입니다.

역할이란

역할은 그 요소가 화면에서 무엇 노릇을 하는가입니다. <button>은 button 역할, <input type="text">는 textbox 역할, <h1>은 heading 역할이에요.

중요한 건 이게 우리가 붙이는 게 아니라 태그를 쓰면 자동으로 정해진다는 겁니다. <button>을 쓰면 그냥 button 역할이 돼요.

낭독기가 화면을 읽을 때 쓰는 게 바로 이 역할입니다. "버튼, 좋아요" 하고 읽어주죠. 우리가 판에서 getByRole('button', { name: '좋아요' })라고 쓰는 것은 낭독기가 읽는 방식 그대로 찾는 것이에요.

이름은 어디서 오나

button 역할을 가진 요소는 이 화면에 여럿일 수 있으니, 어느 것인지 이름으로 좁히는데, 우리 버튼에는 글자가 없다고 했으니 이름이 어디서 올까요?

LikeButton에 있던 이 한 줄입니다.

tsx
aria-label="좋아요"

aria-label눈에는 안 보이지만 낭독기에는 읽히는 이름을 붙이는 속성이에요. 글자가 없는 버튼에 이름을 주는 표준 방법입니다.

이렇게 정해지는 이름을 접근성 이름(accessible name)이라고 불러요. 글자가 있는 버튼은 그 글자가 접근성 이름이 되고, 글자가 없으면 aria-label이 대신합니다.

첫 판을 씁니다

판을 하나 만들어봅시다.

tsx
// apps/web-spa/src/components/f3-like-button.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { LikeButton } from './LikeButton';

describe('좋아요 버튼', () => {
  it('역할과 이름으로 찾을 수 있다', () => {
    render(<LikeButton liked={false} likeCount={1240} onToggle={() => {}} />);

    const button = screen.getByRole('button', { name: '좋아요' });

    expect(button.tagName).toBe('BUTTON');
  });

  it('그 이름은 눈에 보이는 글자가 아니다', () => {
    render(<LikeButton liked={false} likeCount={1240} onToggle={() => {}} />);

    const button = screen.getByRole('button', { name: '좋아요' });

    // 버튼 안에는 하트 그림뿐이라 읽어 낼 글자가 없다
    expect(button.textContent).toBe('');
  });
});

두 번째 판이 재밌습니다. 이름으로 찾았는데 그 이름이 글자로는 없다는 것을 확인하고 있어요. Step 1에서 본 것을 그대로 판에 적어둔 겁니다.

이름을 빼먹을 수는 없나

여기서 재밌는 걸 하나 확인해봅시다. LikeButton에서 aria-label 한 줄을 지우면 어떻게 될까요?

판은 물론 깨지는데, 판보다 먼저 막히는 게 있어요.

텍스트
error TS2741: Property ''aria-label'' is missing in type
  '{ children: Element; className: string; "aria-pressed": boolean; onClick: () => void; }'
  but required in type '{ 'aria-label': string; }'

타입 검사가 막습니다. A-4에서 만든 IconButton을 열어보면 이유가 보여요.

tsx
// apps/web-spa/src/components/IconButton.tsx
type IconButtonProps = ComponentProps<typeof Button> & {
  'aria-label': string;
};

A-4에서 우리는 이 타입을 만들면서 이렇게 적어뒀습니다. "글자 대신 기호만 보이는 버튼이라, 읽어줄 이름은 있으면 좋은 게 아니라 필수다."

그때는 접근성을 위한 결정이었는데, 오늘 그 결정이 한 번 더 값을 합니다. getByRole('button', { name })이 통한다는 것이 타입 수준에서 보장되어 있는 거예요. 아이콘 버튼을 새로 만드는 사람은 이름을 빼먹을 수가 없습니다.

화면 전체의 역할을 한눈에

버튼 하나 말고 카드 전체는 어떻게 생겼을까요. RTL에는 화면의 역할을 통째로 찍어주는 도구가 있습니다.

tsx
import { logRoles } from '@testing-library/react';

const { container } = render(<PostCard {...feedPosts[0]} onToggleLike={() => {}} />);
logRoles(container);

PostCard 하나를 그려놓고 찍으면 이렇게 나옵니다.

텍스트
article    Name ""
img        Name "jaehoon 프로필 사진"
           Name "jaehoon 의 게시물"
button     Name "게시물 메뉴"
           Name "좋아요"
           Name "더 보기"
           Name "댓글 32개 모두 보기"
           Name "게시"
paragraph  Name "" x3
list       Name ""
textbox    Name "댓글 입력"

이게 낭독기가 보는 우리 카드입니다. 버튼이 다섯 개 있고 전부 이름이 달라서, 이름만으로 다섯을 전부 구분할 수 있습니다.

맨 위의 article도 반가운 자국이에요. E-6에서 들여온 카드가 <div>였는데 "여기부터 하나의 글"이라는 뜻이 사라진다고 해서 <article>로 되돌렸죠. 그 결정이 여기 그대로 남아 있습니다.

💡 한 줄 정리

getByRole(역할, { name })낭독기가 화면을 읽는 방식 그대로 요소를 찾는다. 역할은 태그가 정하고, 이름은 글자나 aria-label이 정한다. logRoles로 화면 전체의 역할 목록을 볼 수 있다.

🙋 학생 질문 — "튜터님, 역할 이름을 다 외워야 하나요?"

아니에요. 외우려고 하면 오히려 헤매기만 합니다.

방금 본 logRoles가 그래서 있는 거예요. 화면을 그려놓고 찍어보면 우리 화면에 실제로 있는 역할만 딱 나옵니다. 필요할 때 찍어보는 게 목록을 외우는 것보다 빠르고 정확해요.

실무에서 자주 쓰는 것은 몇 개 안 되는데, button, textbox, link, heading, list, img 정도로 대부분이 끝나요.

한 가지만 기억해두면 좋습니다. 역할이 안 나오는 요소도 있어요. 방금 목록에서 <div><span>은 안 보이죠? 그것들은 아무 뜻이 없는 상자라 역할이 없어서 역할로 못 찾는데, 이건 Step 4에서 다시 만나게 됩니다.


Step 3: "찾았다는 것을 어떻게 말하나"

찾는 방법은 알았는데, 찾은 다음에 무엇을 확인해야 할까요?

Step 2의 판에서는 expect(button.tagName).toBe('BUTTON')이라고 썼습니다. 지난 시간에 배운 expecttoBe 그대로인데, 화면을 확인할 때는 훨씬 편한 방법이 있습니다.

매처가 이미 깔려 있었습니다

지난 시간에 설정 파일에서 본 setupFiles 기억나세요? 그 파일 첫 줄이 이겁니다.

TypeScript
// apps/web-spa/src/test-setup.ts
import '@testing-library/jest-dom/vitest';

이 한 줄이 화면 전용 매처 수십 개expect에 붙여줍니다. 우리가 A-1부터 계속 쓰고 있었는데 오늘에서야 이름을 부르는 거예요.

몇 개만 보면 이렇습니다.

tsx
expect(button).toBeInTheDocument();          // 화면에 있는가
expect(button).toBeDisabled();               // 잠겨 있는가
expect(button).toHaveAccessibleName('좋아요'); // 읽히는 이름이 이건가
expect(button).toHaveAttribute('aria-pressed', 'true');
expect(input).toHaveValue('');               // 입력칸이 비었는가

그냥 expect로도 되는데 왜

toBeDisabled() 대신 expect(button.disabled).toBe(true)라고 써도 확인은 됩니다. 그러면 왜 매처를 쓸까요?

틀렸을 때 알려주는 양이 다릅니다. 같은 상황에서 두 방식의 실패 메시지를 나란히 놓아볼게요.

텍스트
toBeDisabled()          expect(element).toBeDisabled()
                        Received element is not disabled:
                        <button aria-label="좋아요" aria-pressed="false" ...>

.disabled toBe(true)    expected false to be true

아래쪽은 false가 나왔다는 것만 알려줍니다. 어느 요소인지, 그 요소가 지금 어떤 상태인지는 안 알려줘요. 위쪽은 문제의 요소를 통째로 찍어줍니다.

판은 통과할 때보다 깨질 때 일을 합니다. 깨진 판 앞에서 원인을 얼마나 빨리 찾느냐가 매처를 고르는 이유예요.

판을 두 개 더 씁니다

tsx
// apps/web-spa/src/components/f3-like-button.test.tsx
it('그래도 낭독기에는 이름이 있다', () => {
  render(<LikeButton liked={false} likeCount={1240} onToggle={() => {}} />);

  // 이름을 조건에 넣지 않고 찾은 다음, 그 이름이 무엇인지를 확인한다
  expect(screen.getByRole('button')).toHaveAccessibleName('좋아요');
});

it('눌렸다는 것은 이름이 아니라 눌림 표시로 드러난다', () => {
  render(<LikeButton liked={true} likeCount={1241} onToggle={() => {}} />);

  const button = screen.getByRole('button', { name: '좋아요' });

  expect(button).toBeInTheDocument();
  expect(button).toHaveAttribute('aria-pressed', 'true');
});

첫 번째 판에 요령이 하나 있습니다. getByRole('button')으로 이름 없이 찾고 나서 이름을 확인해요. 이름을 조건에 넣어놓고 그 이름이 맞는지 확인하면 당연한 소리가 되니까요.

두 번째 판은 E-7의 결정을 지킵니다. 하트 버튼은 눌러도 이름이 안 바뀌고 계속 "좋아요"인데, 눌렸는지는 aria-pressed가 알립니다. 눈으로 보는 사람에게는 하트 속이 빨갛게 차는 것으로 알리고요.

💡 한 줄 정리

setupFiles의 한 줄이 화면 전용 매처를 깔아둔다. 매처는 편해서가 아니라 깨졌을 때 문제의 요소를 통째로 찍어주기 때문에 쓴다.

🙋 학생 질문 — "튜터님, toBeInTheDocument()는 왜 필요한가요? 못 찾으면 어차피 그 앞에서 던지잖아요."

정확한 지적이에요. getByRole은 못 찾으면 스스로 던지기 때문에, 바로 뒤에 toBeInTheDocument()를 붙이는 것은 사실 아무 일도 안 하는데, 이건 습관처럼 붙이는 게 아니라 읽는 사람을 위한 문장으로 생각하는 게 좋아요. 판을 읽는 사람에게 "이 판은 이게 화면에 있다는 걸 확인하는 판이다"라고 말해주는 겁니다.

진짜로 일을 하는 자리는 따로 있습니다. queryBy와 짝지어 「없다」를 확인할 때예요.

tsx
expect(screen.queryByText(/좋아요 \d+개/)).not.toBeInTheDocument();

이건 없어야 할 것이 정말 없는지 확인하는 판이고, Step 5에서 실제로 쓰게 됩니다. 그때는 toBeInTheDocument가 확실히 일을 해요.


Step 4: "다른 방법들과 그 순서"

지금까지 두 가지 방법을 봤습니다. 글자로 찾기와 역할로 찾기요. 사실 방법은 더 있어요.

같은 좋아요 버튼을 이렇게도 찾을 수 있습니다.

tsx
screen.getByLabelText('좋아요');

이건 getByRole('button', { name: '좋아요' })완전히 같은 요소를 돌려주는데, 둘을 ===로 비교해보면 참이에요. 둘 다 aria-label을 읽으니까요.

그러면 어느 걸 써야 할까요? 여기서 우선순위가 나옵니다.

네 가지와 그 순서

텍스트
  1. getByRole        역할 + 이름           먼저 이걸 시도한다
  2. getByLabelText   이름표가 붙은 입력칸
  3. getByText        눈에 보이는 글자
  4. getByTestId      우리가 심어둔 표시     마지막 수단

순서의 기준은 하나입니다. 사용자에게 가까운 순서예요.

getByRole이 첫째인 이유는 그게 낭독기가 실제로 화면을 읽는 방식이기 때문입니다. 역할과 이름은 사용자에게 도달하는 정보고, 우리가 판에서 그걸 그대로 쓰면 판이 사용자와 같은 것을 본다는 뜻이에요.

getByLabelText는 입력칸에서 특히 좋습니다. 사람이 폼을 채울 때 "이름"이라고 적힌 칸을 찾아 쓰잖아요. 그 이름표를 그대로 쓰는 겁니다.

getByText는 글자 그대로 찾습니다. Step 1에서 봤듯 역할을 안 보기 때문에 문단이든 버튼이든 글자만 맞으면 잡혀요. 그래서 셋째입니다.

마지막 수단이 마지막인 이유

getByTestId는 이렇게 씁니다. 컴포넌트에 표시를 심어두고 그걸로 찾는 거예요.

tsx
<p data-testid="count">좋아요 {likeCount}개</p>

screen.getByTestId('count');

확실하게 찾힙니다. 안 헷갈려요. 그런데 사용자는 data-testid를 볼 수 없습니다. 화면에도 안 나오고 낭독기도 안 읽어요. 오직 판을 위해서만 존재하는 표시라서, 이걸로 찾는 판은 사용자가 아니라 우리가 심어둔 것을 보고 있는 거예요.

우리 저장소는 어떻게 쓰고 있었나

말로만 하면 규칙처럼 들리니까 저장소를 직접 세어봅시다.

텍스트
  getByRole          369
  getByText          177
  getByLabelText     122
  getByTestId         12

우선순위 그대로인데, 재밌는 건 마지막 줄이에요.

getByTestId 12건이 어디 있는지 하나씩 열어보면, 전부 판이 직접 그렸거나 확인용으로 따로 둔 화면에 붙어 있습니다. 앱 코드에는 data-testid하나도 없어요.

tsx
// apps/web-spa/src/components/b4-answer.test.tsx — 판이 자기 화면을 직접 그린다
<p data-testid="count">좋아요 {likedCount}개</p>
<button onClick={() => toggle(posts[0].id)}>토글</button>

붙어 있는 것들은 전부 <p>인데, 아까 Step 2에서 <div><span>은 역할이 없다고 했죠. <p>도 이름을 가질 수 없어서, 역할로는 원리적으로 못 찾는 것들입니다.

이게 정당한 예외의 모양이에요. "역할로 못 찾는 것을 셀 때만 쓴다"이고, 그마저도 앱이 아니라 판이 만든 화면에서만 씁니다.

그럼 우리는 어떻게 골라야 하나

간단합니다. 위에서부터 내려오면 돼요.

텍스트
  역할로 찾히나?            된다    getByRole
         안 된다
  이름표가 붙어 있나?        된다    getByLabelText
         안 된다
  고유한 글자가 있나?        된다    getByText
         안 된다
  왜 못 찾는지 먼저 생각해본다

마지막 줄이 핵심이에요. 역할로도 이름으로도 글자로도 못 찾는다면, 그건 판의 문제가 아니라 화면의 문제일 가능성이 큽니다. 사용자도 그걸 못 찾고 있을지 몰라요. data-testid를 붙이기 전에 그 질문을 먼저 해야 합니다.

💡 한 줄 정리

찾는 방법의 순서는 사용자에게 가까운 순서다. 우리 저장소도 369 / 177 / 122 / 12로 정확히 그 순서로 쓰고 있고, 마지막 12건은 전부 역할이 없는 요소를 판이 직접 그린 경우다.

🙋 학생 질문 — "튜터님, 그럼 getByTestId는 나쁜 건가요? 다른 팀에서는 많이 쓰던데요."

나쁘다기보다 값을 치르고 얻는 것이라고 보는 게 정확해요.

얻는 것은 확실함입니다. data-testid는 우리가 붙인 거라 안 헷갈리고, 글자가 바뀌어도 안 깨져요. 판이 안정적으로 돕니다.

값은 두 가지예요. 하나는 판이 사용자 관점을 잃는 것입니다. 화면에서 버튼 이름이 사라져도 data-testid는 그대로라 판은 계속 초록이에요. 오늘 Step 8에서 이 상황을 직접 만들어봅니다.

다른 하나는 화면이 접근성을 잃어도 안 드러나는 것이에요. 역할과 이름으로 찾을 수 없는 화면은 낭독기 사용자도 쓸 수 없는 화면인데, data-testid로 찾으면 그 사실이 판에서 안 보이니까, "쓰지 마세요"가 아니라 "먼저 위쪽을 다 시도해보고, 그래도 안 되면 왜 안 되는지 답할 수 있을 때 쓰세요"가 맞습니다.


Step 5: "있어야 · 없어야 · 곧 올 것"

지금까지 쓴 것은 전부 getBy로 시작했는데, 앞에 붙는 말이 세 가지예요.

텍스트
  getBy    있어야 한다        없으면 던진다
  queryBy  없을 수도 있다      없으면 null 을 준다
  findBy   지금은 없고 곧 온다  올 때까지 기다린다

셋은 찾는 방법이 아니라 못 찾았을 때 어떻게 할지가 다릅니다. getByRole·queryByRole·findByRole은 전부 역할로 찾아요.

없어야 하는 것을 확인할 때

getBy는 못 찾으면 던집니다. 이렇게요.

텍스트
Unable to find an accessible element with the role "button" and name "없는버튼"

그래서 없다는 것을 확인하는 데는 못 씁니다. 없으면 판이 그 자리에서 죽어버리니까요.

그럴 때 queryBy를 씁니다. 못 찾으면 null을 돌려줘요.

우리 앱에 딱 맞는 자리가 있습니다. LikeButton을 다시 보세요.

tsx
// apps/web-spa/src/components/LikeButton.tsx
{likeCount > 0 && <p className="px-2 pt-1 pb-1 text-sm font-semibold">좋아요 {likeCount}개</p>}

좋아요가 0개면 개수 줄 자체가 안 그려집니다. "좋아요 0개"라고 쓰는 게 아니라 아예 없어요.

이건 확인할 값어치가 있는 동작입니다. 판으로 적어둡시다.

tsx
// apps/web-spa/src/components/f3-like-button.test.tsx
it('좋아요가 0개면 개수 줄이 아예 없다', () => {
  render(<LikeButton liked={false} likeCount={0} onToggle={() => {}} />);

  // 없어야 하는 것을 확인할 때는 queryBy 로 묻는다 — getBy 는 여기서 던진다
  expect(screen.queryByText(/좋아요 \d+개/)).not.toBeInTheDocument();
});

Step 3에서 예고한 대로, 여기서 toBeInTheDocument가 확실히 일을 합니다.

여럿이 잡힐 때

getBy에는 반대 방향의 함정도 있습니다. 둘 이상 찾히면 그것도 던져요.

피드에는 카드가 여러 장이니 하트도 여러 개죠. 그 상황을 만들어보면 이렇게 나옵니다.

텍스트
Found multiple elements with the role "button" and name "좋아요"

이럴 때는 getAllBy로 전부 받는데, 우리 버튼에는 갈라 볼 방법이 하나 더 있어요.

tsx
it('하트가 여럿이면 눌림 여부로 가른다', () => {
  render(
    <>
      <LikeButton liked={false} likeCount={10} onToggle={() => {}} />
      <LikeButton liked={true} likeCount={20} onToggle={() => {}} />
    </>,
  );

  expect(screen.getAllByRole('button', { name: '좋아요' })).toHaveLength(2);
  expect(screen.getAllByRole('button', { name: '좋아요', pressed: true })).toHaveLength(1);
});

{ pressed: true }를 보세요. 이건 Step 3에서 본 aria-pressed를 조건으로 쓰는 겁니다. 이름이 같은 버튼들을 상태로 갈라내는 거예요.

이름·역할·상태가 전부 사용자에게 도달하는 정보라는 게 여기서 다시 보입니다. 우리는 사용자가 구분할 수 있는 방식으로만 구분하고 있어요.

곧 오는 것을 기다릴 때

세 번째 findBy기다립니다. 지금은 화면에 없지만 곧 나타날 것을 찾을 때 써요.

우리 저장소에 이미 쓰고 있는 곳이 있습니다. 피드는 서버에서 오니까 처음엔 카드가 없죠.

tsx
// apps/web-spa/src/queries/c6-optimistic.test.tsx (읽기만 합니다)
const hearts = await screen.findAllByRole('button', { name: '좋아요' });

await가 붙어 있는 게 보이시죠. 데이터가 도착해서 하트가 그려질 때까지 기다렸다가 잡는 겁니다. 못 찾은 채 시간이 지나면 그때 던져요.

오늘 우리가 직접 쓰는 판은 전부 화면이 즉시 완성되는 것들이라 findBy를 쓸 자리가 없습니다. 서버가 끼는 화면을 제대로 확인하는 건 다음 시간에 도구를 하나 더 갖춘 다음에 할게요.

💡 한 줄 정리

getBy·queryBy·findBy는 못 찾았을 때의 태도가 다르다. 없어야 하는 것은 queryBy, 곧 올 것은 findBy, 나머지는 getBy 다. 여럿이면 getAllBy로 받고 상태로 가른다.

🙋 학생 질문 — "튜터님, queryBy로 다 쓰면 안 되나요? 던지지 않으니까 더 안전해 보이는데요."

그렇게 하면 오히려 위험해집니다. 이유가 좀 재밌어요.

queryBy는 못 찾으면 null을 주는데, 이런 판이 생깁니다.

tsx
const button = screen.queryByRole('button', { name: '좋아요' });
expect(button).toBeInTheDocument();

여기까지는 괜찮은데, 확인을 빼먹으면요?

tsx
const button = screen.queryByRole('button', { name: '좋아요' });
// 확인 없이 그냥 쓴다

buttonnull인 채로 다음 줄로 넘어가고, 한참 뒤에 null을 건드리는 순간 엉뚱한 오류가 나면서 문제가 생긴 곳과 드러나는 곳이 멀어집니다.

getBy는 못 찾은 그 줄에서 바로 던지고 무엇을 못 찾았는지도 알려주기 때문에, 기본은 getBy이고 queryBy는 「없음」이 답인 판에서만 씁니다.


Step 6: "누릅니다"

찾았으니 이제 눌러볼 차례예요.

tsx
import userEvent from '@testing-library/user-event';

await userEvent.click(screen.getByRole('button', { name: '좋아요' }));

user-event는 RTL과 짝을 이루는 도구입니다. 이름 그대로 사용자가 하는 일을 흉내 내요. 클릭, 타이핑, 탭 이동 같은 것들이죠.

판을 씁니다

지난 시간에 배운 vi.fn()을 여기서 다시 씁니다. 버튼이 정말 알려주는지 확인해야 하니까요.

tsx
// apps/web-spa/src/components/f3-like-button.test.tsx
it('누르면 알려준다', async () => {
  const onToggle = vi.fn();
  render(<LikeButton liked={false} likeCount={1240} onToggle={onToggle} />);

  await userEvent.click(screen.getByRole('button', { name: '좋아요' }));

  expect(onToggle).toHaveBeenCalledTimes(1);
});

it('눌러도 스스로 숫자를 바꾸지는 않는다', async () => {
  render(<LikeButton liked={false} likeCount={1240} onToggle={() => {}} />);

  await userEvent.click(screen.getByRole('button', { name: '좋아요' }));

  // 이 버튼은 받은 것을 그릴 뿐이다. 숫자를 바꾸는 쪽은 위에 있다.
  expect(screen.getByText('좋아요 1240개')).toBeInTheDocument();
  expect(screen.getByRole('button', { name: '좋아요' })).toHaveAttribute('aria-pressed', 'false');
});

두 번째 판을 눈여겨보세요. 눌렀는데 아무것도 안 바뀌는 것을 확인하고 있습니다.

이상해 보이지만 맞는 판이에요. LikeButtonlikedlikeCount를 받아서 그리기만 할 뿐 상태를 안 들고 있는데, B-2에서 상태를 위로 끌어올린 결과죠. 누르면 위에 알리고, 숫자를 바꾸는 것은 위의 몫입니다.

이 판은 그 설계가 유지되는지를 지킵니다. 누군가 LikeButton 안에 useState를 넣는 순간 빨개져요.

await를 빼면

userEvent 앞에 붙은 await가 눈에 걸리셨을 겁니다. 클릭 한 번 하는데 왜 기다릴까요?

빼보면 압니다.

텍스트
await 전 호출 횟수    0
await 후 호출 횟수    1

await를 빼면 클릭이 아예 안 일어납니다. 판은 그다음 줄로 넘어가고, onToggle은 안 불린 상태예요.

userEvent가 사용자를 제대로 흉내 내려고 여러 단계를 밟기 때문입니다. 사람이 버튼을 누를 때도 마우스가 위로 오고, 눌리고, 떼어지죠. 그 단계들이 순서대로 처리되기를 기다려야 해요.

그래서 규칙은 단순합니다. userEvent는 항상 await를 붙인다.

fireEvent는 뭔가요

검색하다 보면 fireEvent라는 것도 나옵니다. 이것도 클릭을 일으켜요.

tsx
fireEvent.click(button);   // await 가 없다

우리 하트 버튼에서는 결과가 같아서 둘 다 onToggle을 한 번 부르지만, 입력칸에서 갈립니다.

같은 abc를 넣는데 onChange가 불리는 횟수가 이렇게 달라요.

텍스트
  userEvent.type(input, 'abc')                onChange 3회   "a"  "ab"  "abc"
  fireEvent.change(input, { value: 'abc' })   onChange 1회   "abc"

userEvent는 사람이 하는 대로 한 글자씩 치고, fireEvent는 값을 통째로 바꿔 끼웁니다.

이 차이가 중요한 이유는, 한 글자씩 칠 때만 드러나는 버그가 있기 때문이에요. 두 글자까지는 괜찮은데 세 글자째에 뭔가 잘못되는 코드가 있다면 fireEvent로는 영영 못 잡습니다.

우리 저장소는 무엇을 쓰고 있나

세어보면 답이 명확합니다.

텍스트
  user-event 를 쓰는 파일    75개
  fireEvent 를 쓰는 파일      1개  (3건)

그 1개는 C-8에서 렌더 횟수를 세던 판이에요. 거기엔 fireEvent를 써야 하는 특별한 이유가 있었고 그 이유가 파일에 적혀 있습니다.

75 대 1입니다. 기본은 userEvent이고, fireEvent는 이유를 댈 수 있을 때만 쓰는 예외예요.

💡 한 줄 정리

userEvent는 사용자가 하는 일을 흉내 낸다. 항상 await를 붙인다 — 빼면 아무 일도 안 일어난다. fireEvent는 값을 통째로 바꿔 끼우기 때문에 한 글자씩 칠 때만 드러나는 문제를 놓친다.

🙋 학생 질문 — "튜터님, userEvent.click을 여러 번 하면 그때마다 await를 붙여야 하나요?"

네, 매번 붙입니다. 하나라도 빠지면 그 클릭만 조용히 사라지는데, 여러 번 누를 일이 있다면 판을 한 번 의심해보는 것도 좋습니다. 판 하나가 클릭 다섯 번을 하고 있다면, 그건 대개 한 판이 너무 많은 일을 확인하려 하는 신호예요.

지난 시간에 판 하나가 하나의 사실을 확인해야 한다고 했죠. 화면 판에서도 같습니다. "누르면 알려준다"와 "두 번 누르면 두 번 알려준다"는 다른 사실이니 판을 나누는 게 나아요.

깨졌을 때 어느 사실이 깨졌는지 바로 알 수 있으니까요.


Step 7: "댓글 폼을 사용자처럼 씁니다"

버튼은 눌러봤으니 이번엔 글자를 넣어봅시다. 댓글 폼이 좋은 대상이에요. 입력칸과 버튼이 함께 있고, 둘이 서로 반응하거든요.

먼저 폼을 열어봅시다.

tsx
// apps/web-spa/src/components/CommentForm.tsx
<form className="..." onSubmit={handleSubmit}>
  <CommentInput ref={inputRef} value={content} onChange={handleChange} />
  <Button type="submit" disabled={isEmpty} ...>
    게시
  </Button>
</form>

빈칸이면 게시 버튼이 잠기는데, B-5에서 만든 동작이죠.

폼은 어떻게 찾나

여기서 함정을 하나 만납니다. <form> 자체를 찾으려고 하면 안 찾아져요.

tsx
screen.queryByRole('form');   //  null

<form>이름이 없으면 역할도 없습니다. 조금 이상하게 들리지만 규칙이 그런데, 사실 찾을 필요는 없습니다. 사용자도 폼이라는 상자를 인식하지 않고, 사용자가 보는 것은 입력칸과 버튼이라 그것들로 찾으면 됩니다.

tsx
screen.getByLabelText('댓글 입력');            // 입력칸
screen.getByRole('button', { name: '게시' });  // 버튼

입력칸에 getByLabelText를 쓴 게 Step 4의 우선순위 그대로예요. CommentInputaria-label="댓글 입력"이 붙어 있습니다.

사용자가 하는 순서대로

판을 씁니다. 사람이 댓글을 다는 순서를 그대로 따라가요.

tsx
// apps/web-spa/src/components/f3-comment-form.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CommentForm } from './CommentForm';

describe('댓글 폼', () => {
  it('빈칸이면 게시 버튼이 잠겨 있다', () => {
    render(<CommentForm onSubmit={() => {}} />);

    expect(screen.getByRole('button', { name: '게시' })).toBeDisabled();
  });

  it('글자를 넣으면 풀린다', async () => {
    render(<CommentForm onSubmit={() => {}} />);

    await userEvent.type(screen.getByLabelText('댓글 입력'), '노을 예쁘네요');

    expect(screen.getByRole('button', { name: '게시' })).toBeEnabled();
  });

  it('게시하면 내용이 넘어가고 입력칸이 비워진다', async () => {
    const onSubmit = vi.fn();
    render(<CommentForm onSubmit={onSubmit} />);

    await userEvent.type(screen.getByLabelText('댓글 입력'), '노을 예쁘네요');
    await userEvent.click(screen.getByRole('button', { name: '게시' }));

    expect(onSubmit).toHaveBeenCalledWith('노을 예쁘네요');
    expect(screen.getByLabelText('댓글 입력')).toHaveValue('');
  });
});

세 번째 판이 두 가지를 한 번에 확인합니다. 내용이 제대로 넘어갔는지, 그리고 입력칸이 비워졌는지요.

두 번째 것을 빠뜨리기 쉬운데 사용자에게는 꽤 중요합니다. 댓글을 달았는데 입력칸에 방금 쓴 글이 그대로 남아 있으면, 실수로 한 번 더 보내게 되거든요.

사용자처럼 해야만 나오는 것 둘

여기까지는 예상 가능한 판입니다. 그런데 사용자를 제대로 흉내 내니까 생각 못 한 두 가지가 나왔어요.

첫째, 공백만 넣으면 어떻게 될까요?

tsx
it('공백만 넣으면 잠긴 채로 있다', async () => {
  render(<CommentForm onSubmit={() => {}} />);

  await userEvent.type(screen.getByLabelText('댓글 입력'), '   ');

  expect(screen.getByRole('button', { name: '게시' })).toBeDisabled();
});

잠긴 채로 있습니다. 스페이스 세 번은 글자를 넣은 게 아니라고 보는 거예요. useCommentInput을 열어보면 이유가 있습니다.

TypeScript
// apps/web-spa/src/hooks/useCommentInput.ts
const isEmpty = content.trim() === '';

B-5에서 넣어둔 trim() 한 번이 이 동작을 만듭니다. 그때는 그냥 지나갔을 텐데, 오늘 판으로 적어두면 누가 나중에 이 줄을 지웠을 때 알려줄 수 있어요.

둘째, 엔터를 치면요?

tsx
it('엔터로도 게시된다', async () => {
  const onSubmit = vi.fn();
  render(<CommentForm onSubmit={onSubmit} />);

  await userEvent.type(screen.getByLabelText('댓글 입력'), '노을 예쁘네요{enter}');

  expect(onSubmit).toHaveBeenCalledWith('노을 예쁘네요');
});

됩니다. 버튼을 안 눌렀는데 게시돼요.

{enter}라는 표기가 보이시죠. userEvent.type은 중괄호로 특수 키를 표현합니다. 사람이 글을 치다가 엔터를 누르는 것 그대로예요.

이게 되는 이유는 우리가 <form> 안에 type="submit" 버튼을 뒀기 때문인데, 브라우저가 원래 그렇게 동작해요. 우리가 만든 게 아니라 폼을 제대로 썼더니 딸려온 것입니다.

만약 <form> 없이 <div> 안에 버튼만 뒀다면 엔터는 아무 일도 안 했을 텐데, 그때는 키보드로만 쓰는 사람이 매번 탭을 눌러 버튼까지 이동해야 합니다.

클릭만 확인하는 판이었다면 이 사실을 영영 몰랐을 거예요. 사용자처럼 해봤기 때문에 나온 겁니다.

💡 한 줄 정리

폼은 상자가 아니라 안에 있는 입력칸과 버튼으로 찾는다. 사용자가 하는 순서대로 따라가면 공백만 넣었을 때나 엔터를 쳤을 때처럼 클릭만 확인해서는 안 나오는 동작이 드러난다.

🙋 학생 질문 — "튜터님, {enter} 말고 다른 키도 되나요?"

네, 많이 됩니다. {backspace}, {escape}, {arrowdown}, {tab} 같은 것들이 있는데, 키 이름을 외우기보다 언제 이런 게 필요한가를 잡아두는 게 낫습니다.

우리 앱에서 지금 당장 떠오르는 건 E-6의 사진 모달이에요. 거기서 Esc를 누르면 닫히죠. 그건 우리가 만든 게 아니라 들여온 컴포넌트가 해주는 것이었고요.

그게 정말 되는지 판으로 확인해두면 어떨까요? 나중에 다른 모달로 갈아 끼웠을 때 Esc가 안 되면 판이 알려줄 겁니다. 키보드로만 쓰는 사람에게는 모달이 안 닫히는 게 큰 문제예요.

이런 자리를 찾는 요령이 하나 있습니다. 마우스 없이 이 화면을 쓸 수 있나를 물어보는 거예요. 그 답이 안 나오는 곳이 판을 써둘 곳입니다.


Step 8: "바꿔도 안 깨지는 판"

오늘 우리는 판을 열세 개 썼는데, 이 판들이 정말 좋은 판인지 어떻게 알까요?

지난 시간 마지막에 이런 질문을 했었죠. "어떻게 만들었는지"를 판에 박으면 안 된다고 했고, 화면에서는 그게 훨씬 크게 문제가 된다고 예고했습니다.

오늘 그걸 실제로 재봅시다.

나쁜 판을 일부러 만들어봅니다

비교하려면 대조군이 필요합니다. 일부러 구현을 붙잡는 판을 세 개 써볼게요.

tsx
// 일부러 나쁘게 쓴 판입니다. 저장소에 두지 않아요.
it('안쪽 여백 클래스로 버튼을 찾는다', () => {
  const { container } = render(<LikeButton liked={false} likeCount={1240} onToggle={() => {}} />);

  expect(container.querySelector('button.p-2')).not.toBeNull();
});

it('바깥이 div 이고 그 안에 button 과 p 가 순서대로 있다', () => {
  const { container } = render(<LikeButton liked={false} likeCount={1240} onToggle={() => {}} />);

  const root = container.firstElementChild;
  expect(root?.tagName).toBe('DIV');
  expect(root?.children[0].tagName).toBe('BUTTON');
  expect(root?.children[1].tagName).toBe('P');
});

셋 다 클래스도 맞고 구조도 맞으니까 지금은 통과합니다.

겉보기에는 나쁘지 않아 보이죠. "버튼이 있는지 확인한다"는 목적도 같고요.

구현을 다섯 가지로 바꿔봅니다

개발자가 실제로 할 법한 변경을 하나씩 해보고, 양쪽 판이 어떻게 되는지 봅시다.

텍스트
  M1  클래스 이름을 바꾼다        p-2    px-2 py-2   (보이는 결과는 같다)
  M2  IconButton 을 안 쓰고 button 을 직접 쓴다
  M3  하트를 별 모양으로 바꾼다
  M4  바깥 div 를 span 으로 바꾼다
  M5  이름을 바꾼다              좋아요    하트

결과입니다.

텍스트
  구현 변경                      우리 판 (8건)      구현 붙잡는 판 (3건)
  ─────────────────────────────────────────────────────────────────
  변경 없음                      8 통과             3 통과
  M1  클래스 이름 교체            8 통과             1 실패
  M2  IconButton 안 씀           8 통과             3 통과
  M3  하트  별                  8 통과             1 실패
  M4  div  span                8 통과             1 실패
  M5  이름 좋아요  하트          7 실패            3 통과

마지막 줄이 전부를 말합니다

위쪽 세 줄부터 보면, M1, M3, M4에서 우리 판은 조용하고 구현을 붙잡는 판만 우는데, M1과 M4에서 사용자에게 일어난 일은 아무것도 없습니다. 여백을 같은 값으로 다르게 적고 상자 태그를 바꿨을 뿐, 화면도 동작도 그대로입니다.

그런데 판이 빨개졌어요. 이건 거짓 경보입니다. 개발자는 멀쩡한 변경을 해놓고 판을 고치느라 시간을 씁니다. 이런 일이 반복되면 사람들은 판을 안 믿게 되고, 빨간 판을 보고도 "또 그거겠지" 하며 넘기게 돼요.

마지막 줄을 보세요.

M5는 사용자가 실제로 알아채는 유일한 변경입니다. 낭독기가 "버튼, 좋아요"라고 읽던 것을 "버튼, 하트"라고 읽게 돼요. 화면을 못 보는 사람에게는 이 버튼이 무슨 버튼인지가 바뀐 것인데, 구현을 붙잡는 판 셋은 전부 초록입니다. 클래스도 그대로고 구조도 그대로니까요. 아무도 안 알려줘요.

우리 판은 8건 중 7건이 빨개집니다.

텍스트
  사용자에게 아무 일도 안 일어난 변경     우리 판 조용,  구현 판 운다
  사용자가 알아채는 변경                우리 판 운다,  구현 판 조용

정확히 반대로 동작하고 있어요. 이게 오늘 배운 것의 값입니다.

살아남은 한 건

M5에서 우리 판 8건 중 하나는 통과했습니다. 어느 것일까요?

텍스트
  ×  역할과 이름으로 찾을 수 있다
  ×  그 이름은 눈에 보이는 글자가 아니다
  ×  그래도 낭독기에는 이름이 있다
  ×  눌렸다는 것은 이름이 아니라 눌림 표시로 드러난다
  ✓  좋아요가 0개면 개수 줄이 아예 없다
  ×  하트가 여럿이면 눌림 여부로 가른다
  ×  누르면 알려준다
  ×  눌러도 스스로 숫자를 바꾸지는 않는다

버튼을 이름으로 부르지 않는 유일한 판입니다. 개수 줄만 보고 있어서 버튼 이름이 바뀌든 말든 상관이 없어요.

이건 결함이 아니에요. 그 판은 다른 사실을 지키고 있는데, 판이 무엇을 붙잡고 있으면 무엇이 바뀔 때 우는지가 이렇게 정확히 대응한다는 게 잘 보이는 자리예요.

그런데 M3는 좀 걸리지 않나요

하트를 별로 바꿨는데 우리 판이 조용한 게 마음에 걸릴 수 있습니다. 사용자 눈에는 분명히 달라 보이잖아요.

맞습니다. 그리고 이건 경계예요.

RTL이 지키는 것은 무엇을 뜻하고 무엇을 하는가입니다. 별 모양이 됐어도 그 버튼은 여전히 "좋아요"라는 이름을 갖고, 누르면 여전히 좋아요가 눌려요. 뜻과 동작은 안 바뀌었습니다.

어떻게 보이는가는 다른 종류의 도구가 봅니다. 화면을 그림으로 찍어두고 달라진 곳을 찾아내는 방식인데, 그건 나중에 따로 다룹니다. 오늘 배운 판이 화면의 모든 것을 지켜주지는 않는 셈이라, 판이 안 지키는 것이 무엇인지 아는 것도 판을 쓰는 일의 일부입니다.

💡 한 줄 정리

판이 구현(클래스·태그·구조)을 붙잡으면 사용자에게 아무 일도 안 일어난 변경에 울고, 정작 사용자가 알아채는 변경은 놓친다. 역할·이름·상태를 붙잡으면 정확히 반대가 된다.

🙋 학생 질문 — "튜터님, 그러면 판이 깨지면 항상 코드가 잘못된 건가요?"

아니고, 이게 오늘의 마지막 요령입니다.

판이 깨지면 두 가지 중 하나예요. 코드가 틀렸거나, 사실이 바뀌었거나.

M5를 다시 봅시다. 판 7건이 빨개졌는데, 만약 우리가 정말로 그 버튼 이름을 "하트"로 바꾸기로 정했다면요? 그건 코드가 틀린 게 아니라 사실이 바뀐 것입니다. 그때는 판을 고치는 게 맞아요.

중요한 것은 판이 그 결정을 우리 앞에 가져다 놓았다는 겁니다. 7건이 빨개지면 "잠깐, 이 이름에 기대는 곳이 이렇게 많았나" 하고 한 번 멈추게 되죠. 그 멈춤이 판의 값입니다.

반대로 구현을 붙잡는 판은 이 결정을 안 가져다줘서, 조용히 통과하니까 아무도 안 멈춥니다. 판을 고치는 것 자체는 잘못이 아니라, 왜 깨졌는지 보지 않고 고치는 것이 잘못이에요.


마무리

오늘 우리는 지난 시간의 마지막 문장을 들고 시작했습니다. 사용자는 "좋아요 라고 적힌 버튼"을 누른다고 했었죠.

그리고 첫 Step에서 그 문장이 우리 앱에서 그대로는 성립하지 않는다는 걸 봤습니다. 우리 버튼에는 글자가 없었어요.

텍스트
  적힌 글자로 찾는다       못 찾거나, 엉뚱한 것을 잡는다
  읽히는 이름으로 찾는다    사용자가 보는 그것을 잡는다

그 "읽히는 이름"을 따라간 것이 오늘 하루입니다. 이름을 찾는 방법(getByRole), 이름이 어디서 오는지(aria-label), 이름으로 못 찾을 때의 순서, 그리고 그 이름을 사용자처럼 눌러보는 것까지요.

마지막 Step에서 그 선택의 값을 숫자로 확인했습니다. 사용자가 알아채는 변경 하나에 우리 판은 일곱 개가 울었고, 구현을 붙잡는 판은 하나도 안 울었어요.

오늘 배운 핵심 세 가지

💡 하나 — 화면에서 찾는 순서는 사용자에게 가까운 순서다. getByRole이 첫째인 이유는 그게 낭독기가 화면을 읽는 방식이기 때문이고, getByTestId가 마지막인 이유는 사용자가 그걸 볼 수 없기 때문이다. 우리 저장소의 369 대 12가 그 순서를 그대로 보여준다.

💡 둘 — 못 찾았을 때의 태도로 셋이 갈린다. 있어야 하는 것은 getBy, 없어야 하는 것은 queryBy, 곧 올 것은 findBy. 그리고 userEvent에는 항상 await를 붙인다 — 빼면 클릭이 아예 안 일어난다.

💡 셋 — 판이 붙잡아야 하는 것은 사용자가 인지하는 것이다. 역할·이름·상태를 붙잡으면 멀쩡한 변경에는 조용하고 진짜 변경에는 운다. 클래스·태그·구조를 붙잡으면 정확히 반대가 된다.

다음 시간 예고

오늘 우리가 확인한 화면들에는 공통점이 하나 있습니다. 전부 데이터를 이미 손에 쥔 채로 그려졌어요.

LikeButton에는 likeCount={1240}을 직접 넘겼고, CommentForm은 애초에 데이터가 필요 없었죠. 그래서 그리자마자 완성된 화면이 나왔습니다.

그런데 우리 앱의 진짜 피드는 그렇지 않아요. 서버에 물어보고, 기다리고, 도착하면 그때 그려집니다. Step 5에서 findBy를 소개하면서 "서버가 끼는 화면은 다음 시간에"라고 미뤄둔 게 이것 때문이에요.

그러면 판을 돌릴 때마다 진짜 서버를 띄워야 할까요? 그건 곤란합니다. 서버가 꺼져 있으면 판이 깨지고, 서버 데이터가 바뀌면 판이 또 깨지니까요.

지난 시간에 우리는 vi.mock으로 모듈을 통째로 갈아 끼워봤습니다. 그 방법을 화면에도 쓸 수 있을까요? 쓸 수는 있는데, 화면에서는 더 나은 방법이 있어요.

다음 시간에는 우리 코드를 한 줄도 안 고치고 서버 응답만 가로채는 방법을 배웁니다. 그러면 피드가 도착하는 화면도, 도착하다 실패하는 화면도 판으로 확인할 수 있게 돼요.


과제

[구현] 게시물 메뉴 버튼에 판을 써보세요

Step 2의 logRoles 출력을 다시 보면 PostCard에 버튼이 다섯 개 있었습니다. 그중 "게시물 메뉴"는 오늘 한 번도 안 다뤘어요.

PostHeader를 열어보고 그 버튼에 대한 판을 src/components/f3-post-header.test.tsx에 써보세요.

  1. 그 버튼을 오늘 배운 우선순위대로 찾으세요. 가장 위쪽 방법으로 찾히나요?
  2. 버튼의 접근성 이름이 어디서 오는지 소스에서 확인하세요. 좋아요 버튼과 같은 방식인가요?
  3. 그 버튼 안에 들어 있는 것을 확인하는 판을 하나 쓰세요. textContent는 무엇이 나올까요?
  4. 같은 화면에 PostHeader를 두 번 그려보세요. getByRole이 어떻게 되나요? Step 5에서는 { pressed: true }로 갈랐는데, 이 버튼에는 그렇게 쓸 것이 있나요? 없다면 좋아요 버튼과 무엇이 다른 걸까요?

마지막으로 답해보세요. 이 버튼은 지금 눌러도 아무 일이 안 일어납니다. 소스를 열어보면 알 수 있어요. 그런 버튼에도 판을 쓸 값어치가 있을까요?

[탐구] 우선순위를 어긴 판을 찾아보세요

Step 4에서 저장소의 쿼리 분포를 봤습니다. getByText가 177건이었죠.

대부분은 글자로 찾는 게 맞는 것들인데, 역할로 찾을 수 있는데 글자로 찾고 있는 곳이 섞여 있어요. 찾아봅시다.

  1. getByText가 무엇을 찾고 있는지 훑어보고 몇 갈래로 분류해보세요. 개수·내용·안내 문구처럼 묶일 겁니다.
  2. 그중 역할을 가진 요소를 글자로 찾는 곳이 두 군데 있습니다. 찾으세요. 하나는 오류 화면의 제목이고, 다른 하나는 카드 안에 있는 버튼이에요.
  3. 그 둘을 getByRole로 바꿔 쓸 수 있는지 소스에서 확인하고, 실제로 바꿔서 판을 돌려보세요.
  4. 1번에서 분류한 나머지들의 공통점을 한 문장으로 적으세요. 왜 그것들은 글자로 찾는 게 맞을까요?

답해보세요. 177건을 전부 getByRole로 바꾸는 것이 목표일까요? 지난 시간에 커버리지 숫자를 목표로 삼으면 안 된다고 했던 것과 이어서 생각해보세요.

[탐구] 판을 깨뜨려서 무엇을 지키는지 확인하세요

Step 8에서 우리는 구현을 다섯 가지로 바꿔봤습니다. 이번엔 반대로 해보세요.

CommentFormuseCommentInput을 아래 세 가지로 각각 망가뜨리고, 매번 몇 개의 판이 빨개지는지 적으세요. 한 번에 하나씩만 바꾸고 확인한 뒤 되돌립니다.

  1. useCommentInputcontent.trim() === ''content === ''로 바꾼다
  2. CommentForm에서 disabled={isEmpty}를 지운다
  3. CommentInputaria-label="댓글 입력"aria-label="댓글"로 바꾼다

답해보세요. 3번에서 빨개진 판을 고치는 올바른 방법은 무엇일까요? 판을 고쳐야 할까요, 코드를 되돌려야 할까요? 그 판단을 무엇을 근거로 하시겠어요?

[구현] 마우스 없이 쓸 수 있는지 확인하세요

Step 7의 🙋 에서 "마우스 없이 이 화면을 쓸 수 있나"를 물어보라고 했습니다. 댓글 폼으로 직접 확인해보세요.

  1. userEvent.tab()으로 탭 이동을 흉내 낼 수 있습니다. CommentForm을 그려놓고 탭을 눌렀을 때 어디에 초점이 가는지 확인하세요.
  2. 입력칸에 글자를 넣고 탭을 한 번 더 누르면 어디로 가나요?
  3. 게시 버튼이 잠겨 있을 때와 풀려 있을 때, 탭 순서가 달라지나요?
  4. 확인한 것을 판으로 적으세요. toHaveFocus() 매처를 쓸 수 있습니다.

답해보세요. 3번의 결과는 사용자에게 좋은 걸까요, 나쁜 걸까요? 잠긴 버튼을 탭 순서에서 빼는 것과 남겨두는 것 중 어느 쪽이 나을지 근거를 들어 답해보세요.


생각해볼 주제

1. 접근성이 먼저일까, 테스트가 먼저일까

오늘 우리는 aria-label 덕분에 버튼을 이름으로 찾을 수 있었는데, 그 aria-label은 테스트를 위해 붙인 게 아니라 A-4에서 접근성을 위해 붙인 것이었죠.

순서를 뒤집어 생각해볼 수도 있는데, 어떤 팀은 테스트를 쓰다가 접근성 문제를 발견합니다. "이 버튼을 역할로 못 찾겠는데?" 하고 열어보니 <div onClick> 이었던 거죠.

여러분의 팀에서 화면 하나를 새로 만든다고 해봅시다. 접근성을 챙기는 일과 테스트를 쓰는 일 중 무엇을 먼저 하시겠어요? 그 둘을 따로 하는 일이 아니라 한 가지 일로 만들려면 팀에 무엇이 있어야 할까요?

2. 판이 안 깨지는 것이 항상 좋을까

Step 8에서 우리는 구현을 바꿔도 안 깨지는 판이 좋은 판이라고 결론 냈습니다. 그런데 이 말을 끝까지 밀고 가면 이상한 곳에 도착해요.

아무것도 안 깨지는 판이 가장 좋은 판일까요? 예를 들어 expect(true).toBe(true)는 무엇을 해도 안 깨집니다.

그건 물론 아무것도 안 지키니까 나쁜 판인데, 좋은 판은 "안 깨지는 판"이 아니라 "깨져야 할 때만 깨지는 판"입니다.

문제는 그걸 어떻게 아느냐예요. 우리는 Step 8에서 구현을 다섯 가지로 직접 바꿔보고 확인했는데, 판이 수백 개인 저장소에서 이걸 매번 손으로 할 수는 없죠.

지난 시간 마지막에도 비슷한 질문을 했었습니다. 코드를 일부러 망가뜨렸을 때 안 빨개지는 판을 어떻게 찾을까 하고요. 오늘 화면 판에서도 같은 질문이 돌아왔어요. 이 두 질문은 같은 질문일까요, 다른 질문일까요?

3. 화면이 안 바뀌었는데 판이 깨질 때

M1을 다시 생각해봅시다. p-2px-2 py-2로 바꿨더니 구현을 붙잡는 판이 깨졌죠. 화면은 한 픽셀도 안 변했는데요.

이런 일이 팀에서 반복되면 어떻게 될까요? 사람들은 판을 고치는 데 지쳐서 두 가지 중 하나를 하게 됩니다. 판을 지우거나, 판을 안 보고 통과할 때까지 고치거나요.

둘 다 나쁜 결말인데, 흥미로운 건 이게 개인의 게으름 때문이 아니라는 점이에요. 거짓 경보가 반복되면 사람이 그 신호를 무시하게 되는 건 자연스러운 반응입니다.

여러분이 팀에 새로 들어갔는데 이런 판이 이미 수백 개 있다고 해봅시다. 전부 다시 쓸 시간은 없어요. 무엇부터 손대시겠어요? 새로 들어오는 판이 같은 문제를 안 만들게 하려면 팀에 어떤 약속이 필요할까요?

✅ 예시 답안정답 보기

과제 4개(각 25점, 합 100점)와 생각해볼 주제 3개의 예시답안입니다.


🎯 [과제 1 예시답안] 게시물 메뉴 버튼에 판을 써보세요

채점 포인트

항목 배점 확인
getByRole('button', { name: '게시물 메뉴' })로 찾았다 5 가장 위쪽 방법으로 찾힌다
이름이 aria-label에서 온다는 것을 소스로 확인했다 5 좋아요 버튼과 같은 방식
textContent가 빈 문자열임을 판으로 적었다 5 안에는 점 세 개 그림뿐
둘을 그리면 getByRole이 던지는 것을 확인했다 5 getAllByRole로 2개
갈릴 상태가 없다는 것을 밝혔다 5 "pressed로 가른다" 고 답하면 0점
합계 25

풀이 예시

PostHeader를 열어보면 좋아요 버튼과 판박이입니다.

tsx
// apps/web-spa/src/components/PostHeader.tsx
<IconButton
  className="cursor-pointer p-3 text-lg leading-none focus-visible:outline-2 ..."
  aria-label="게시물 메뉴"
>
  <Ellipsis className="size-5 @lg:size-6" />
</IconButton>

IconButton을 쓰고 있으니 aria-label은 빼먹을 수가 없어요. 타입이 요구하는데, 안에 든 것은 점 세 개 그림 하나입니다.

그러니 찾는 방법도 좋아요 버튼과 같습니다.

tsx
// apps/web-spa/src/components/f3-post-header.test.tsx
const props = { username: 'jaehoon', profileImageUrl: 'https://example.com/a.jpg' };

it('메뉴 버튼은 역할과 이름으로 찾힌다', () => {
  render(<PostHeader {...props} />);

  expect(screen.getByRole('button', { name: '게시물 메뉴' })).toBeInTheDocument();
});

it('그 이름도 눈에 보이는 글자가 아니다', () => {
  render(<PostHeader {...props} />);

  const button = screen.getByRole('button', { name: '게시물 메뉴' });

  expect(button.textContent).toBe('');
  expect(button).toHaveAccessibleName('게시물 메뉴');
});

사용자 이름은 반대라서, 이건 그냥 글자로 찾는 게 맞습니다.

tsx
it('사용자 이름은 그대로 글자로 보인다', () => {
  render(<PostHeader {...props} />);

  // 이쪽은 역할이 없는 span 이라 글자로 찾는 게 맞다
  expect(screen.getByText('jaehoon')).toBeInTheDocument();
});

4번이 이 과제의 본체입니다

둘을 나란히 그려보면 getByRole이 던집니다. 여기까지는 Step 5와 같아요.

tsx
it('머리 구역이 둘이면 이름만으로는 못 가른다', () => {
  render(
    <>
      <PostHeader username="jaehoon" profileImageUrl="https://example.com/a.jpg" />
      <PostHeader username="minji" profileImageUrl="https://example.com/b.jpg" />
    </>,
  );

  // 좋아요 버튼은 aria-pressed 로 갈렸지만 이 버튼에는 갈릴 상태가 없다
  expect(() => screen.getByRole('button', { name: '게시물 메뉴' })).toThrow();
  expect(screen.getAllByRole('button', { name: '게시물 메뉴' })).toHaveLength(2);
});

여기서 막히는데, Step 5에서는 { pressed: true }로 갈랐죠. 이 버튼에는 그렇게 쓸 게 없어요.

왜 없을까요? aria-pressed는 눌린 상태가 사용자에게 도달하는 정보라서 붙어 있는 겁니다. 좋아요는 켜짐과 꺼짐이 있는 버튼이니까요. 메뉴 버튼은 그런 상태가 없는데, 두 카드의 메뉴 버튼은 사용자가 보기에도 정말로 똑같아요.

그러니 갈라야 한다면 버튼 자체가 아니라 어느 카드에 속했는지로 갈라야 합니다. 이름이 같은 것이 잘못이 아니라, 이름만으로는 부족한 상황인 거예요.

마지막 질문 — 아무 일도 안 하는 버튼에 판을 쓸 값어치가 있나

소스를 보면 이 버튼에는 onClick이 아예 없어서, 눌러도 정말 아무 일도 안 일어나요.

그래도 쓸 값어치는 있는데, 무엇을 지키는 판인지를 갈라야 해요.

  • 지금 쓸 수 있는 것: 이 버튼이 화면에 있고, 낭독기에 "게시물 메뉴"로 읽힌다는 것
  • 지금 쓸 수 없는 것: 누르면 무엇이 열린다는 것. 아직 안 만들었으니까요

앞의 것은 지킬 값어치가 충분합니다. 나중에 누가 아이콘을 바꾸면서 aria-label을 빠뜨리면, 화면은 멀쩡해 보이는데 낭독기 사용자에게는 이름 없는 버튼이 됩니다. 그때 판이 알려줘요.

💡 튜터의 한마디

이 과제에서 가장 흔한 답이 "pressed로 가른다"입니다. Step 5의 모양을 그대로 가져온 것인데, aria-pressed는 아무 버튼에나 붙이는 게 아니라 켜짐과 꺼짐이 있는 버튼에만 붙습니다. 메뉴 버튼에 억지로 붙이면 낭독기가 "눌리지 않음"이라고 읽어서 사용자를 헷갈리게 해요.

판을 쓰기 편하게 만들려고 화면에 없던 속성을 붙이는 순간, 그건 data-testid를 붙이는 것과 같은 일이 됩니다. 판을 위해 화면을 바꾸는 게 아니라, 화면에 이미 있는 것으로 판을 쓰는 것이 순서예요.


🎯 [과제 2 예시답안] 우선순위를 어긴 판을 찾아보세요

채점 포인트

항목 배점 확인
getByText 대상을 갈래로 분류했다 5 개수·내용·안내 문구 등
오류 화면 제목이 <h1> 임을 찾아냈다 5 RootErrorBoundary
카드 안의 버튼을 찾아냈다 5 "댓글 N개 모두 보기"
둘을 getByRole로 바꿔 돌려봤다 5 실제로 통과한다
나머지는 역할이 없는 요소임을 밝혔다 5 공통점 한 문장
합계 25

1번 — 분류해보면

getByText가 찾는 것을 훑어보면 네 갈래로 묶입니다.

텍스트
  개수와 상태      "좋아요 1240개"  "저장한 게시물 0개"  "연결 상태: 열림"
  본문 내용        "오늘 한강 노을이 미쳤다"  "와 여기 어디예요?"
  안내와 오류      "이메일 모양이 아니에요"  "화면을 불러오는 중이에요…"
  사용자 이름      "jaehoon"

전부 읽히기만 하고 눌리지 않는 글자예요.

2번 — 섞여 있는 두 곳

하나는 오류 화면의 제목입니다.

tsx
// apps/web-spa/src/routes/RootErrorBoundary.tsx
<h1 className="mb-2 text-2xl font-bold">{title}</h1>

<h1>이니까 heading 역할을 갖는데, 판은 글자로 찾고 있었어요.

tsx
// apps/web-spa/src/routes/c6-throw-on-error.test.tsx (고치기 전)
expect(await screen.findByText('문제가 생겼어요')).toBeInTheDocument();

다른 하나는 카드 안의 버튼입니다. Step 2의 logRoles 목록에 이미 나와 있었죠.

텍스트
button     Name "댓글 32개 모두 보기"

이것도 글자로 찾고 있었습니다.

tsx
// apps/web-spa/src/components/b3-composition.test.tsx (고치기 전)
expect(screen.getByText('댓글 33개 모두 보기')).toBeInTheDocument();

3번 — 바꿔보면 그대로 통과합니다

tsx
expect(await screen.findByRole('heading', { name: '문제가 생겼어요' })).toBeInTheDocument();

expect(screen.getByRole('button', { name: '댓글 33개 모두 보기' })).toBeInTheDocument();

둘 다 통과하는데, 바꾸고 나면 판이 더 정확해집니다. 앞의 것은 "이 글자가 화면 어딘가에 있다"에서 "이게 이 화면의 제목이다"로 바뀌고, 뒤의 것은 "이건 누를 수 있는 것이다"를 함께 말하게 되니까요.

4번 — 나머지의 공통점

한 문장으로 적으면 이렇습니다.

역할이 없는 요소 안에 있는 글자다.

<p>·<span>은 그냥 상자예요. 역할이 없으니 getByRole로 찾을 방법이 없고, 그래서 글자로 찾는 게 맞습니다. 우선순위를 어긴 게 아니라 위쪽 방법이 애초에 안 되는 것이죠.

마지막 질문 — 177건을 전부 바꾸는 게 목표인가

아닌데, 이유가 지난 시간과 정확히 같아요.

커버리지 100%를 목표로 삼으면 사람이 판단하는 대신 숫자를 올리게 된다고 했는데, 여기서도 마찬가지예요. "getByText를 0으로 만들자"를 목표로 삼으면, 역할이 없는 요소를 찾으려고 없던 역할을 억지로 붙이게 돼요.

<p>role="status"를 달면 getByRole로 찾을 수 있게 되는데, 그 문단이 정말 상태 알림이 아니라면 낭독기가 엉뚱한 순간에 그걸 읽어버려요. 판의 숫자를 위해 사용자 경험을 나쁘게 만든 겁니다.

목표는 숫자가 아니라 "이 요소를 사용자는 어떻게 인식하는가"를 매번 묻는 것입니다.

💡 튜터의 한마디

이 과제에서 재밌는 건 위반이 딱 두 곳뿐이라는 겁니다. 177건 중에서요.

이건 우연이 아니라, 이 저장소가 A-1부터 계속 그렇게 쓰여 왔기 때문입니다. 아무도 "우선순위 규칙"을 문서로 걸어두지 않았는데도요.

왜 그럴 수 있었을까요? 화면을 제대로 만들었기 때문입니다. 버튼은 <button>으로, 제목은 <h1>으로, 아이콘 버튼에는 이름을 붙이면서요. 판을 쓰는 사람은 자연스럽게 역할로 찾게 되는데, 그게 가장 편한 길이니까요.

반대로 <div onClick>이 널린 화면에서는 아무리 규칙을 걸어도 getByTestId로 흘러갑니다. 좋은 판을 쓰게 만드는 것은 규칙이 아니라 화면의 품질이에요.


🎯 [과제 3 예시답안] 판을 깨뜨려서 무엇을 지키는지 확인하세요

채점 포인트

항목 배점 확인
세 변경의 실패 수를 각각 적었다 6 3 / 10 / 43 (저장소 전체 기준)
세 숫자가 커지는 순서임을 관찰했다 6 3 → 10 → 43
③이 왜 가장 크게 번지는지 설명했다 6 사용자가 인식하는 이름이라서
판을 고칠지 코드를 되돌릴지 판단 근거를 댔다 7 "화면에서 무엇이 달라졌나"
합계 25

실측 결과

저장소 전체를 돌린 기준입니다.

텍스트
  ①  trim 제거            3 파일 /  3 건
  ②  disabled 제거        6 파일 / 10 건
  ③  aria-label 변경     14 파일 / 43 건

한 번에 하나씩만 바꾸고 확인한 뒤 되돌립니다.

① trim 제거 — 3건

TypeScript
// apps/web-spa/src/hooks/useCommentInput.ts
const isEmpty = content.trim() === '';   →   const isEmpty = content === '';

공백만 넣었을 때 버튼이 풀립니다. 잡히는 곳은 셋이에요. 오늘 쓴 판 하나, 그리고 B-3에서 이 훅을 확인하던 판과 B-2에서 폼을 확인하던 판입니다.

적은 숫자인데 이게 정상이에요. 아주 좁은 동작 하나를 바꿨으니까요.

② disabled 제거 — 10건

tsx
// apps/web-spa/src/components/CommentForm.tsx
<Button type="submit" disabled={isEmpty} ...>   →   disabled 를 지운다

셋에서 열로 늘었습니다. 잠김 여부는 화면에서 눈에 보이는 상태라 이걸 확인하는 판이 더 많았어요. 스냅샷을 비교하던 판, 버튼 모양을 확인하던 판까지 걸립니다.

③ aria-label 변경 — 43건

tsx
// apps/web-spa/src/components/CommentInput.tsx
aria-label="댓글 입력"   →   aria-label="댓글"

열네 개 파일에서 마흔세 건이 깨집니다. 깨진 곳을 보면 놀라운 게 있는데, 댓글과 아무 상관없어 보이는 파일까지 걸립니다. 전역 상태를 확인하던 판, 폴링을 확인하던 판까지요.

이유는 간단한데, 그 판들이 화면을 통째로 그리기 때문이에요. 화면을 그리면 댓글 폼도 함께 그려지고, 그 입력칸을 찾아 무언가를 하던 판이 전부 걸립니다.

세 숫자가 말하는 것

텍스트
  바꾼 것                          잡은 판
  ─────────────────────────────    ────────
  안에 숨은 계산 (trim)              3
  눈에 보이는 상태 (잠김)             10
  사용자를 부르는 이름 (aria-label)   43

사용자에게 가까울수록 더 많은 판이 붙잡고 있습니다. 이건 우연이 아니라, 오늘 배운 대로 판을 쓰면 자연히 그렇게 됩니다. 역할과 이름으로 찾으니까 이름이 바뀌면 전부 걸리는 거죠.

지난 시간 과제에서도 비슷한 것을 봤습니다. toggleLike를 통째로 망가뜨렸을 때 35건이 깨졌고, 그건 "널리 쓰이는 코드는 이미 여러 겹이 지키고 있다"는 뜻이었어요. 여기서는 널리 쓰이는 이름이 같은 일을 합니다.

마지막 질문 — 판을 고칠까, 코드를 되돌릴까

판단 근거는 하나입니다. 화면에서 사용자에게 무엇이 달라졌나.

③의 경우 낭독기가 "댓글 입력, 편집"이라고 읽던 것을 "댓글, 편집"이라고 읽게 됩니다. 사용자에게 도달하는 정보가 바뀐 것이라, 두 갈래입니다.

  • 실수였다면 → 코드를 되돌린다. 43건은 실수를 잡아낸 겁니다
  • 정말 그 이름으로 바꾸기로 정했다면 → 판을 고친다. 사실이 바뀌었으니까요

중요한 건 43건이 그 결정을 우리 앞에 가져다 놓았다는 겁니다. 이름 하나에 이만큼이 걸려 있다는 걸 알고 나면, 바꿀지 말지를 한 번 더 생각하게 되죠.

반대로 판이 하나도 안 깨졌다면 어땠을까요? 아무도 모르게 이름이 바뀌고, 낭독기 사용자만 조용히 불편해집니다.

자주 나오는 실수

"③이 43건이니까 이 판들은 너무 예민하다" 고 답하는 경우가 있습니다.

반대로 봐야 해요. Step 8에서 봤듯 거짓 경보는 사용자에게 아무 일도 안 일어났는데 우는 것입니다. ③은 사용자에게 실제로 일이 일어난 것이라, 43건은 예민한 게 아니라 정확한 겁니다.

예민한지 정확한지를 가르는 기준은 숫자가 아니라 사용자에게 무엇이 달라졌는가입니다.

💡 튜터의 한마디

이 과제를 하다 보면 "그럼 aria-label을 함부로 못 바꾸겠네" 하는 생각이 들 수 있어요.

맞고, 그게 맞는 상태예요.

aria-label은 사용자 인터페이스의 일부입니다. 버튼에 적힌 글자를 바꾸는 것과 같은 무게를 가져요. 화면에 "게시"라고 적힌 버튼을 "등록"으로 바꾸는 건 팀에서 상의할 일이잖아요. 낭독기에만 읽히는 이름도 똑같은데, 눈에 안 보이니까 아무도 안 보고 바꾸기 쉬워요. 43건이 우는 것은 그래서 값이 있는데, 눈에 안 보이는 것을 눈에 보이게 만들어주니까요.


🎯 [과제 4 예시답안] 마우스 없이 쓸 수 있는지 확인하세요

채점 포인트

항목 배점 확인
빈칸일 때 탭 순서를 확인했다 5 입력칸 다음이 화면 밖
글자를 넣은 뒤 탭 순서를 확인했다 5 게시 버튼으로 간다
잠긴 버튼이 탭에서 빠진다는 것을 밝혔다 6 이것이 3번의 답
확인한 것을 판으로 적었다 5 toHaveFocus() 사용
좋은지 나쁜지 근거를 들어 답했다 4 양쪽 다 인정하면 만점
합계 25

실측 결과

텍스트
  빈칸일 때      탭1  댓글 입력칸      탭2  화면 밖
  글자를 넣은 뒤  탭1  게시 버튼
  잠긴 버튼에 초점이 갔나  아니오
  제출한 뒤 초점  댓글 입력칸

판으로 적으면

tsx
// apps/web-spa/src/components/f3-comment-form.test.tsx
it('탭을 누르면 입력칸으로 간다', async () => {
  render(<CommentForm onSubmit={() => {}} />);

  await userEvent.tab();

  expect(screen.getByLabelText('댓글 입력')).toHaveFocus();
});

it('잠긴 버튼은 탭 순서에서 빠진다', async () => {
  render(<CommentForm onSubmit={() => {}} />);

  await userEvent.tab();
  await userEvent.tab();

  // 갈 곳이 없어서 화면 밖으로 나간다
  expect(screen.getByRole('button', { name: '게시' })).not.toHaveFocus();
});

it('글자를 넣으면 버튼이 탭 순서에 들어온다', async () => {
  render(<CommentForm onSubmit={() => {}} />);

  await userEvent.type(screen.getByLabelText('댓글 입력'), '노을 예쁘네요');
  await userEvent.tab();

  expect(screen.getByRole('button', { name: '게시' })).toHaveFocus();
});

덤으로 발견되는 것 — 제출 뒤 초점

제출한 뒤 초점이 어디로 가는지 재보면 입력칸으로 돌아옵니다.

우연이 아니라, B-5에서 만든 훅에 이 한 줄이 있습니다.

TypeScript
// apps/web-spa/src/hooks/useCommentInput.ts
onSubmit(content.trim());
setContent('');
inputRef.current?.focus();

그때는 useRef로 DOM을 직접 만지는 예제였는데, 오늘 보니 키보드로만 쓰는 사람을 위한 배려였던 겁니다. 이게 없으면 댓글 하나 달 때마다 탭을 눌러 입력칸으로 돌아와야 해요.

마지막 질문 — 잠긴 버튼을 탭에서 빼는 게 좋은가

양쪽 다 근거가 있습니다.

빼는 쪽 (지금 우리 화면)

탭으로 이동하다 아무 반응 없는 버튼에 멈추는 건 답답합니다. 눌러도 안 되는데 왜 여기 멈추나 싶죠. 화면에 잠긴 버튼이 많을수록 이 불편이 커집니다.

남기는 쪽

키보드로만 쓰는 사람은 버튼이 있다는 것 자체를 모를 수 있습니다. 탭으로 훑는 게 화면을 파악하는 방법인데, 잠긴 버튼은 그 훑기에서 사라지니까요. "게시 버튼이 어디 갔지?" 하고 헤매게 돼요.

남기려면 disabled 대신 aria-disabled="true"를 쓰고, 눌렀을 때 왜 안 되는지 알려주는 방법이 있습니다.

우리 화면에서는 빼는 쪽이 낫습니다. 입력칸 바로 옆에 버튼이 하나뿐이고, 글자를 안 써서 왜 잠겼는지가 명백해요. 헤맬 여지가 거의 없습니다.

반대로 회원가입 폼처럼 칸이 여러 개고 어느 것 때문에 잠겼는지 모를 때는 남기는 쪽이 나아요.

💡 튜터의 한마디

이 과제의 진짜 목적은 탭 순서를 외우는 게 아닙니다. 판이 새로운 질문을 하게 만드는 도구라는 걸 느껴보는 거예요.

여러분은 마우스가 있으니 이 앱을 만들면서 한 번도 탭 키만으로 써본 적이 없을 겁니다. 그런데 userEvent.tab() 한 줄을 써보는 순간 "어? 잠긴 버튼은 건너뛰네?" 하는 질문이 생깁니다.

판은 이미 아는 것을 확인하는 도구이기도 하지만, 안 해본 방식으로 화면을 써보게 만드는 도구이기도 해요. 오늘 배운 것 중에 이게 가장 오래 남았으면 좋겠습니다.


🤔 [생각해볼 주제 1] 접근성이 먼저일까, 테스트가 먼저일까

문제 상황 요약

우리는 aria-label 덕분에 버튼을 이름으로 찾을 수 있었습니다. 그 이름은 테스트를 위해 붙인 게 아니라 A-4에서 접근성을 위해 붙인 것이었어요.

그런데 반대 순서도 있습니다. 테스트를 쓰다가 "이 버튼을 역할로 못 찾겠는데?" 하고 열어보니 <div onClick> 이었던 경우요.

새 화면을 만들 때 둘 중 무엇을 먼저 해야 할까요? 그리고 둘을 한 가지 일로 만들려면 팀에 무엇이 있어야 할까요?

튜터의 가이드 및 해설

순서를 정하려는 것 자체가 함정입니다.

먼저 왜 이 질문이 나오는지 보죠. 많은 팀에서 접근성은 "나중에 시간 나면" 하는 일로 밀리는데, 지금 당장 화면이 돌아가는 데 지장이 없으니까요. 테스트도 비슷하게 밀리고요.

그래서 둘 다 밀리다가, 어느 날 접근성 감사를 받거나 버그가 터지면 그때 몰아서 합니다. 그때는 화면이 이미 <div onClick>으로 가득해서 고칠 곳이 수백 군데예요.

이 둘이 같은 것을 요구한다는 게 핵심입니다.

접근성이 요구하는 것: 버튼은 버튼이어야 하고, 이름이 있어야 하고, 상태가 드러나야 한다. 테스트가 요구하는 것: 버튼을 역할로 찾을 수 있어야 하고, 이름으로 좁힐 수 있어야 하고, 상태로 가를 수 있어야 한다.

같은 문장입니다. 표현만 다를 뿐이에요.

그래서 순서를 정할 게 아니라 하나로 묶어야 합니다. 오늘 우리가 쓴 판이 그 예예요. getByRole('button', { name: '좋아요' })라고 쓰는 순간, 그 판은 동작을 확인하는 동시에 "이 버튼이 낭독기에 제대로 읽힌다"를 확인하고 있습니다. 따로 접근성 검사를 돌린 게 아닌데요.

팀에 무엇이 있어야 하나

실무에서 통하는 것 세 가지를 순서대로 말씀드릴게요.

첫째, getByTestId를 쓰려면 이유를 적게 하는 것. 금지가 아니라 이유를 요구하는 겁니다. 대부분은 이유를 적으려다 "어? 그냥 버튼으로 만들면 되네" 하고 화면을 고쳐요.

둘째, 화면을 만드는 사람과 판을 쓰는 사람을 나누지 않는 것. 나누면 판 쓰는 사람은 주어진 화면에서 어떻게든 찾아내야 하니 data-testid를 요구하게 됩니다. 같은 사람이 하면 화면을 고치는 쪽이 더 쉬워요.

셋째, 자동 검사를 붙이는 것. 접근성 규칙을 검사해주는 도구가 있어서 판을 돌릴 때 함께 돌릴 수 있습니다. 다만 이건 세 번째인데, 앞의 둘이 없으면 경고만 쌓이고 아무도 안 봅니다.

🎯 면접관을 홀리는 핵심 멘트

"저는 접근성과 테스트를 따로 하는 일로 안 봅니다. getByRole('button', { name })로 판을 쓰면 그 한 줄이 동작과 접근성을 동시에 지켜요. 반대로 getByTestId를 쓰기 시작하면 둘 다 조용히 무너집니다. 그래서 팀에는 'testid를 쓰려면 왜 역할로 못 찾는지 적어라'라는 약속 하나만 뒀는데, 대부분은 이유를 적으려다 화면을 고치더군요. 규칙을 늘리는 것보다 이게 효과가 좋았습니다."


🤔 [생각해볼 주제 2] 판이 안 깨지는 것이 항상 좋을까

문제 상황 요약

Step 8에서 우리는 구현을 바꿔도 안 깨지는 판이 좋은 판이라고 결론 냈습니다. 그런데 이 말을 끝까지 밀면 expect(true).toBe(true)가 가장 좋은 판이 돼버려요.

그러니 좋은 판은 "안 깨지는 판"이 아니라 "깨져야 할 때만 깨지는 판"입니다. 문제는 그걸 어떻게 아느냐예요.

우리는 Step 8에서 구현을 다섯 가지로 직접 바꿔보고 확인했습니다. 판이 수백 개인 저장소에서 이걸 매번 손으로 할 수는 없죠.

튜터의 가이드 및 해설

먼저 지난 시간 질문과 같은 질문인지 보죠.

지난 시간에는 이렇게 물었습니다. "코드를 일부러 망가뜨렸을 때 안 빨개지는 판을 어떻게 찾을까?"

오늘은 이렇게 물었어요. "구현을 바꿨을 때 우는 판과 안 우는 판을 어떻게 가릴까?"

둘은 같은 질문입니다. 방향만 다를 뿐이에요. 지난 시간은 "안 우는 것"을 찾았고, 오늘은 "울면 안 되는데 우는 것"을 찾았습니다. 둘 다 코드를 바꿔보고 판의 반응을 관찰하는 방법으로 답합니다.

이걸 자동으로 해주는 도구가 실제로 있습니다. 코드를 조금씩 자동으로 망가뜨려보고, 그때 판이 우는지 세는 방식이에요. 안 우는 자리가 나오면 "여기는 아무도 안 지키고 있다"를 알려줍니다.

그런데 오늘의 문제는 그 도구로도 못 풉니다.

Step 8의 M1을 떠올려보세요. p-2px-2 py-2로 바꿨죠. 자동 도구는 이런 변경을 만들어내지 않습니다. 도구가 만드는 것은 논리를 바꾸는 변경이에요. >>=로 바꾸거나, truefalse로 바꾸거나요.

M1은 논리가 안 바뀌었고, 결과도 똑같아요. 그런데 판이 깨졌죠. 자동 도구가 다루는 축과 다른 축의 문제입니다.

그래서 무엇을 해야 하나

두 축을 갈라서 봐야 합니다.

텍스트
  안 우는데 울어야 한다     자동으로 찾을 수 있다 (코드를 망가뜨려본다)
  우는데 울면 안 된다       자동으로 못 찾는다

아래쪽은 자동화가 어렵습니다. "이 변경이 사용자에게 도달하는가"를 판단해야 하는데, 그건 도구가 모르니까요.

대신 신호는 잡을 수 있어요. 판이 깨질 때마다 기록해두고, 화면이 안 바뀌었는데 깨진 경우를 세는 겁니다. 어떤 판이 그 목록에 자꾸 오르면 그 판은 구현을 붙잡고 있는 거예요.

더 간단한 방법도 있습니다. 판에서 container.querySelector와 클래스 이름을 찾는 것. 오늘 우리가 나쁜 판을 만들 때 쓴 것들이죠. 이 두 가지가 보이면 대개 구현을 붙잡고 있습니다.

🎯 면접관을 홀리는 핵심 멘트

"테스트 품질을 재는 축이 두 개라고 봅니다. '안 우는데 울어야 하는 것'과 '우는데 울면 안 되는 것' 이요. 앞의 것은 코드를 자동으로 망가뜨려보는 도구로 잡을 수 있는데, 뒤의 것은 자동화가 안 됩니다. 사용자에게 도달하는 변경인지를 판단해야 하니까요. 그래서 저는 판에서 클래스 선택자나 DOM 구조를 붙잡는 코드를 신호로 봅니다. 그게 보이면 거짓 경보 후보예요."


🤔 [생각해볼 주제 3] 화면이 안 바뀌었는데 판이 깨질 때

문제 상황 요약

M1에서 p-2px-2 py-2로 바꿨더니 구현을 붙잡는 판이 깨졌습니다. 화면은 한 픽셀도 안 변했는데요.

이런 일이 반복되면 사람들은 판을 지우거나, 판을 안 보고 통과할 때까지 고칩니다. 둘 다 나쁜 결말인데 개인의 게으름 때문이 아니에요.

이미 이런 판이 수백 개 있는 팀에 들어갔다면 무엇부터 손대야 할까요? 그리고 새로 들어오는 판이 같은 문제를 안 만들게 하려면요?

튜터의 가이드 및 해설

먼저 왜 개인의 문제가 아닌지부터.

거짓 경보가 반복되면 사람이 그 신호를 무시하게 되는 건 자연스러운 반응입니다. 화재경보기가 한 달에 열 번 오작동하면 누구든 열한 번째에는 안 나가요. 그 사람을 탓하는 건 소용이 없고, 경보기를 고쳐야 합니다.

판도 같습니다. "판을 잘 보자"는 구호로는 안 고쳐져요.

무엇부터 손대나 — 전부 고치려 하면 실패합니다

수백 개를 다시 쓸 시간은 없습니다. 그리고 다시 쓰는 동안에도 새 판이 계속 들어와요.

제가 권하는 순서는 이렇습니다.

첫째, 새로 들어오는 것부터 막습니다. 기존 것은 그대로 두고요. 이게 반직관적인데, 이유가 있어요. 기존 판을 고치는 동안 새 판이 같은 문제를 계속 만들면 영영 못 따라잡습니다. 출혈부터 멈춰야 해요.

둘째, 자주 깨지는 것부터 고칩니다. 전부가 아니라요. 판이 깨진 기록을 모아보면 대개 몇 개가 반복해서 나옵니다. 그 몇 개가 팀의 시간을 거의 다 먹고 있어요. 상위 열 개만 고쳐도 체감이 크게 달라집니다.

셋째, 나머지는 건드릴 때 고칩니다. 그 파일을 다른 이유로 열게 됐을 때 같이 고치는 거예요. 따로 시간을 내지 않아도 몇 달이면 대부분 정리됩니다.

새 판을 막는 방법

가장 효과가 좋은 것은 판을 리뷰할 때 묻는 질문 하나입니다.

"이 판은 사용자가 알아채는 변경에만 깨지나요?"

이 질문에 답하려면 판이 무엇을 붙잡고 있는지 봐야 해요. 그 과정에서 대부분 스스로 알아챕니다.

그리고 오늘 배운 것을 팀의 말로 만들어두면 좋습니다. "역할·이름·상태를 붙잡는다" 한 문장이면 충분해요. 규칙 목록보다 이런 한 문장이 오래갑니다.

마지막으로 하나 더

거짓 경보를 완전히 없앨 수는 없고, 목표도 0이 아니에요.

목표는 판이 깨졌을 때 팀이 그것을 진지하게 보는 상태를 유지하는 겁니다. 열 번 중 아홉 번이 진짜라면 사람들은 봅니다. 열 번 중 두 번만 진짜라면 아무도 안 봐요. 그 비율을 관리하는 것이 실제로 하는 일입니다.

🎯 면접관을 홀리는 핵심 멘트

"깨지는 테스트를 사람들이 무시하는 건 게으름이 아니라 거짓 경보에 대한 합리적인 반응이라고 봅니다. 그래서 저는 전부 다시 쓰는 대신 세 단계로 갑니다. 새로 들어오는 것부터 막아 출혈을 멈추고, 깨진 기록을 모아 자주 우는 상위 몇 개만 고치고, 나머지는 그 파일을 열 때 같이 고칩니다. 목표는 거짓 경보 0이 아니라 '깨지면 진짜일 확률'을 사람들이 신뢰할 만큼 유지하는 거예요."

전체 목록 리액트