AI 시대의 프론트엔드 테스트

dhyun2·2026년 5월 17일

front

목록 보기
3/5
post-thumbnail

서론

저는 불과 몇 개월 전 까지만 해도 TDD등 테스트 기법에 대해 회의적인 입장이었습니다.

테스트코드 또한 리소스이며 특히 애자일하게 방향이 계속 바뀌는 제품 개발 환경에서는 TDD를 가져갔을 때, 불확실성으로 인하여 테스트 전체를 삭제하거나 엎는 경험이 많았고, QA에서조차 기획이 변경되어 정작 중요한 작업보다 테스트에 더욱 많은 리소스가 들어간 경험들이 있었습니다.

결제,예약 등 필요한 곳에는 테스트를 정의하지만, 앱의 모든 페이지와 기능에 테스트 코드를 작성하는 방식은 선호하지 않았습니다. 특히 애자일하게 방향이 계속 바뀌는 제품 개발 환경에서는 TDD를 도입할 시 불확실성으로 인하여 정작 중요한 작업에 대한 리소스보다 테스트를 재생산하는 리소스가 더욱 컸습니다.

이후 저는 중요한 로직(결제, 예약 등)을 제외하고는 QA검증이 끝난 후 라이브 런칭이 되기 전 테스트코드를 작성하는 방향으로 전환하였고, 결국 제게 테스트 코드란, 버그를 줄이고 안정적인 서비스를 제공하기 위한 도구가 아닌 순전히 다음 작업자를 위한 안전설계도에 불과하였습니다.

하지만 AI 시대가 오면서 해당 관점은 완전히 바뀌었습니다.

AI시대에서 더 이상 코드 작성 자체가 리소스가 아니게 되었습니다. 이상적으로 짜여진 팀 컨벤션과 룰이 담겨진 하네스 아래에서는 누구나 기획 의도에 맞는 코드를 일관되게 구현할 수 있게 되었습니다. 이러한 시대의 흐름 속에 한땀 한땀 코드를 작성하던 코드 아티스트(?)들도 점점 새로운 방향성으로 나아가고 있으며 해당 부분은 점점 블랙박스화 되고 있고 이로 인해 화두가 되는 것이 바로 '테스트 코드'입니다.

우리는 모든 라인을 직접 작성하지 않았고, 모든 판단 과정을 기억하지 못합니다. 블랙박스 안을 일일이 확인하지 않고 믿음이 가려면 테스트과정이 꼭 포함되어야하며, 이로 인해 AI시대에 가장 중요한 것은 '테스트 코드'입니다.

//before
테스트를 쓸 만큼 이 기능이 중요한가?

//after
AI가 만든 결과물을 신뢰하기 위해 어떤 테스트를 작성할 것인가?
어떤 테스트 하네스를 기본으로 둘 것인가?

AI 시대에서 필요로 하는 역량은 AI가 만든 결과물을 안전하게 검증할 수 있는 시스템을 설계하는 것도 포함됩니다. 해당 아티클에서는 TodoList 예제를 통해 작은 검증 시스템을 설계해보겠습니다

해당 글을 읽으면 다음과 같은 지식을 얻을 수 있습니다.

  • 요구조건을 Gherkin 시나리오로 변환하고, 이를 Playwright 테스트로 옮기는 방법
  • Figma 디자인을 테스트 가능하게 변환하고, 이를 검증하는 방법
  • Playwright의 getByRole, getByText, getByLabel기반 locator로 TodoList 기능 회귀 테스트 작성하는 방법
  • ARIA snapshot으로 시멘틱구조 검증하는 방법
  • 이전 screenshot baseline과 현재 화면을 비교하는 visual regression을 구성하는 방법
  • 데이터 회귀와 런타임 회귀를 추적하는 방법

이제 해당 아티클의 주제인 AI가 만든 블랙박스를 신뢰하기 위해, 검증 하네스를 설계하기를 TodoList 예시를 통해 소개하겠습니다.

1. TodoList 검증 가능한 기준 만들기

우선 개발을 진행하기 전에 먼저 TodoList를 검증 가능한 기준으로 나누어 봅시다.

  • 기획 요구사항: 사용자 행동 기준
  • Figma 디자인: 화면/디자인 기준
  • API 스펙: 데이터 기준
  • 폴더 구조: 구현 경계 기준
  • 브라우저 실행 결과: 회귀 검증 기준

위 기준들을 아래 순서로 만들어보겠습니다.

기획/요구사항을 검증 가능한 기준으로 변경
   ↓
위 기준으로 Playwright 테스트 작성
   ↓
Figma MCP 기반 디자인 검증 기준 작성 
   ↓
기능/접근성/디자인토큰/API/화면/런타임 검증
   ↓
PR 리포트
   ↓
승인 후 baseline 저장
   ↓
이후 개발 시 회귀 테스트

2. 기획/요구사항을 검증 가능한 기준으로 변경하기

[todo List 기획서]

요구되는 기능은 다음과 같습니다.

  • 사용자는 할 일을 추가할 수 있다.
  • 사용자는 할 일을 완료 처리할 수 있다.
  • 사용자는 전체 / 진행 중/ 완료 상태를 필터링할 수 있다.
  • 할 일이 없으면 empty state가 표시된다.
  • API 요청이 실패하면 error state가 표시된다.

우리는 한 장의 기획서를 받게되었습니다. 위 문장은 아직 테스트하기 어렵습니다. '할 일을 추가할 수 있다'라는 설명만 있을 뿐, 구체적으로 어떤 입력을 하고 어떤 결과가 보여야 하는지 드러나지 않습니다.

그래서 먼저 Acceptance Criteria로 변경해보겠습니다.

- 사용자가 입력창에 할 일을 입력하고 추가 버튼을 누르면 목록에 새 할 일이 표시된다.
- 사용자가 체크박스를 클릭하면 해당 할 일은 완료 상태가 된다.
- 사용자가 삭제 버튼을 클릭하면 해당 할 일은 목록에서 사라진다.
- 사용자가 '완료' 필터를 클릭하면 완료된 할 일만 표시된다.
- 할 일이 없으면 '아직 할 일이 없습니다' 문구가 표시된다.
- 할 일 목록을 불러오지 못하면 '할 일을 불러오지 못했습니다' alert이 표시된다.

이제 기획서를 검증 가능한 형태로 바꾸었습니다. 이로 인해 우리는 사용자 행동 기준을 정의하였고, 성공/실패 조건을 분리하였으며, 테스트 가능한상태로 변경하였습니다.

2.1 기획서를 기반으로 Gherkin문법으로 작성

이제 이를 AI와 브라우저가 실행 가능한 명세Gherkin문법으로 변환해보겠습니다.

Feature: TodoList

  Background:
    Given 사용자가 TodoList 페이지에 접근했다

  Scenario: 사용자는 새로운 할 일을 추가할 수 있다
    Given 할 일 목록에는 "우유 사기"가 표시되어 있다
    When 사용자가 "테스트 작성하기"를 입력한다
    And "추가" 버튼을 클릭한다
    Then 할 일 목록에 "테스트 작성하기"가 표시된다

  Scenario: 사용자는 할 일을 완료 처리할 수 있다
    Given 할 일 목록에 "우유 사기"가 표시되어 있다
    When 사용자가 "우유 사기 완료" 체크박스를 클릭한다
    Then "우유 사기"는 완료 상태로 표시된다

  Scenario: 사용자는 완료된 할 일만 필터링할 수 있다
    Given 완료된 할 일 "운동하기"가 있다
    And 진행 중인 할 일 "우유 사기"가 있다
    When 사용자가 "완료" 필터를 클릭한다
    Then 목록에는 "운동하기"만 표시된다
    And "우유 사기"는 표시되지 않는다

  Scenario: 할 일 목록이 비어 있으면 empty state를 보여준다
    Given 할 일 목록이 비어 있다
    Then "아직 할 일이 없습니다" 문구가 표시된다

  Scenario: 할 일 목록을 불러오지 못하면 error state를 보여준다
    Given 할 일 목록 API가 실패한다
    Then "할 일을 불러오지 못했습니다" 메시지가 표시된다

Gherkin은 테스트 코드가 아닙니다. 하지만 요구사항을 실행 가능한 테스트로 옮기기 전의 공통 언어입니다. 이를 통해 개발자는 브라우저에서 실행 가능한 검증 테스트를 구현할 수 있습니다.



2.2 Gherkin을 기반으로 Playwright 테스트 작성

Gherkin을 기반으로 Playwright 테스트 작성

이제 Gherkin으로 작성한 시나리오를 브라우저에서 실행 가능한 Playwright 테스트로 옮겨보겠습니다.

tip. 실제 개발 시에는 AI에게 Gherkin시나리오로 테스트 초안 작성할 수 있습니다. 이때 해당테스트가 정말 요구사항을 검증하는지는 리뷰단계를 꼼꼼히 할수록 신뢰도가 올라갑니다.

Gherkin to Playwright를 간단하게 요약하면 아래와 같습니다.

GherkinPlaywright에서의 역할예시
Background여러 시나리오에서 반복되는 공통 준비beforeEach, 공통 page.goto()
Given테스트 시작 전 상태 구성API mock, 초기 데이터, 로그인 세션
When사용자의 행동 실행입력, 클릭, 체크, 필터 변경
Then기대 결과 검증화면 표시, URL 변화, checkbox 상태, alert
And앞 문장의 연장앞 문장이 Given이면 setup, When이면 action, Then이면 assertion

예를 들어 다음 Gherkin 시나리오를 Playwright 테스트로 옮겨보겠습니다.

Scenario: 사용자는 새로운 할 일을 추가할 수 있다
  Given 할 일 목록에는 "우유 사기"가 표시되어 있다
  When 사용자가 "테스트 작성하기"를 입력한다
  And "추가" 버튼을 클릭한다
  Then 할 일 목록에 "테스트 작성하기"가 표시된다

해당 시나리오는 Playwright에서 다음 구조로 옮겨집니다.

import { test, expect } from '@playwright/test';
import { mockTodosApi } from './todos.fixture';

test('사용자는 기존 할 일 목록에 새로운 할 일을 추가할 수 있다', async ({ page }) => {
  // Given: 테스트를 시작하기 위한 상태를 만든다.
  await mockTodosApi(page, [
    { id: 1, title: '우유 사기', completed: false },
  ]);

  await page.goto('/todos');

  await expect(
    page.getByRole('listitem').filter({ hasText: '우유 사기' })
  ).toBeVisible();

  // When: 사용자의 행동을 브라우저에서 실행한다.
  await page
    .getByRole('textbox', { name: '새 할 일' })
    .fill('테스트 작성하기');

  await page
    .getByRole('button', { name: '추가' })
    .click();

  // Then: 행동 이후 기대 결과를 검증한다.
  await expect(
    page.getByRole('listitem').filter({ hasText: '테스트 작성하기' })
  ).toBeVisible();
});

이 테스트에서 중요한 점은 내부 구현을 검증하지 않는다는 것입니다. useState를 썼는지, Zustand를 썼는지, 서버 상태 라이브러리를 썼는지, 컴포넌트를 몇 개로 쪼갰는지는 이 테스트의 관심사가 아닙니다.

해당 테스트가 검증하는 것은 하나입니다.

사용자가 브라우저에서 할 일을 입력하고 추가 버튼을 클릭했을 때,새 할 일이 목록에 표시되는가?

따라서 이 테스트는 다음과 같은 성격을 가집니다.

분류설명
Functional Test사용자가 기능을 수행할 수 있는지 검증
Acceptance Test기획 요구사항이 만족되는지 검증
Browser-level Integration Test화면, 상태, API mock이 함께 동작하는지 검증
Functional Regression Test이후 반복 실행 시 기존 기능이 깨졌는지 검증

정리하면 다음과 같습니다.

Given
→ 페이지 이동, API mock, 로그인 세션, 초기 데이터 구성

When
→ 사용자의 입력, 클릭, 체크, 필터 변경

Then
→ 화면에 표시되는 결과, URL 변화, 상태 변화, alert, checkbox 상태 검증

여기서 실제 API를 사용하지않고 mock하는 이유는 실제 서버를 테스트하기 위해서가 아닙니다. 지금 검증하려는 것은 백엔드 API가 아닌, 프론트엔드가 정해진 API 응답을 받았을 때 올바르게 화면을 렌더링하는가입니다.

즉 mockTodosApi는 TodoList 화면의 데이터 기준을 고정하기 위한 장치입니다. 데이터가 고정되어야 기능 테스트도 안정적으로 동작하고, 이후에 다룰 visual regression에서도 매번 같은 화면을 비교할 수 있습니다.

이를위해 API가 실패했을때 케이스 또한 정의 해야합니다.

잘못된 처리
API 실패 -> "아직 할 일이 없습니다."

올바른 처리
API 실패 -> "할 일을 불러오지 못했습니다."

두 화면은 모두 “목록이 비어 보인다”는 점에서 비슷해 보일 수 있습니다. 하지만 의미는 완전히 다릅니다.

empty state:
정상적으로 데이터를 불러왔고, 실제로 할 일이 없는 상태

error state:
데이터를 불러오지 못한 장애 상태

이 둘을 구분하지 못하면 장애를 정상 상태처럼 보여주게 됩니다. 그래서 API 실패 케이스도 테스트로 고정해야 합니다.

test('할 일 목록을 불러오지 못하면 error state를 보여준다', async ({ page }) => {
  await page.route('**/api/todos', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'Internal Server Error' }),
    });
  });

  await page.goto('/todos');

  await expect(page.getByRole('alert')).toContainText(
    '할 일을 불러오지 못했습니다'
  );
});

이런 케이스를 안정적으로 찾아내는 방법은 TDD 관점에서 Red-Green 흐름을 만드는 것입니다.

  1. Gherkin 시나리오 작성
  2. Playwright 테스트 작성
  3. 실패 확인
  4. AI에게 구현 요청
  5. 테스트 통과 확인
  6. 리팩터링
  7. 이후 회귀 테스트로 유지

테스트는 구현 전에 먼저 실패해야 합니다.
그래야 이후 구현이 정말로 요구사항을 만족시켜 테스트를 통과한 것인지 확인할 수 있습니다.

즉 이 단계는 단순히 테스트 코드를 작성하는 단계가 아닙니다.

Gherkin
→ 사람이 읽을 수 있는 행동 명세

Playwright Test
→ 브라우저에서 실행 가능한 검증

실패 확인
→ 아직 구현이 요구사항을 만족하지 않음을 증명

테스트 통과
→ 구현이 요구사항을 만족했음을 증명

반복 실행
→ 이후 변경에서 기존 기능이 깨지지 않았음을 검증

AI 시대에는 이 흐름이 더 중요해집니다.
우리는 모든 코드를 직접 작성하지 않고, 모든 판단 과정을 기억하지도 못합니다. 그렇기 때문에 AI가 만든 블랙박스를 신뢰하려면, 구현 결과가 요구사항을 만족한다는 브라우저 기반 증거가 필요합니다.

이 테스트는 그 증거를 만드는 첫 번째 안전 하네스입니다.



3. 디자인 검증하기

3.1 Figma 디자인을 검증 가능한 기준으로 바꾸기

Gherkin을 기반으로 Playwright 테스트 작성

앞선 단계에서 기획서를 Gherkin 시나리오로 바꾸고, 이를 Playwright 테스트로 옮겼습니다.

기획 기반 테스트는 다음 질문에 답을 합니다.

- 사용자가 할 일을 추가할 수 있는가?
- 완료 처리할 수 있는가?
- 필터링할 수 있는가?
- API 실패 시 error state를 볼 수 있는가?

하지만 기능이 동작한다고 해서 화면이 디자인 의도를 따르는 것은 아닙니다.
그래서 이번 파트에서는 Figma 디자인을 검증 가능한 명세로 바꾸어 보겠습니다.

tip. 디자인시스템이 잘 설계되었다면, 해당부분은 진행하지 않아도 됩니다

이제 다음과 같은 디자인 화면을 받았다 가정해봅시다.

Gherkin을 기반으로 Playwright 테스트 작성

TodoList Figma 화면에서 추출해야 할 기준은 크게 네 가지 입니다.

1. 화면 구조 기준
2. 디자인 토큰 기준
3. 상태 기준
4. 접근성/의미 구조 기준

예를 들어 해당 디자인을 검증 가능한 명세로 변경 해보겠습니다.


3.2 검증 가능한 명세 만들기

# TodoList Design Contract

## 1. Screen Structure

- page heading: "오늘 할 일"
- description: "작은 작업부터 하나씩 정리해보세요."
- input label: "새 할 일"
- input placeholder: "할 일을 입력하세요"
- primary action: "추가"
- filter actions:
  - "전체"
  - "진행 중"
  - "완료"
- todo item structure:
  - checkbox
  - todo title
  - delete button

## 2. States

- default:
  - todo 목록이 존재하는 상태
- empty:
  - todo 목록이 비어 있는 상태
  - "아직 할 일이 없습니다" 문구 표시
- error:
  - API 요청 실패 상태
  - "할 일을 불러오지 못했습니다" alert 표시
- completed item:
  - 완료된 todo는 checkbox checked 상태
- active item:
  - 진행 중인 todo는 checkbox unchecked 상태

## 3. Design Tokens

- page background: #F7F8FA
- card background: #FFFFFF
- card padding: 24px
- card radius: 16px
- control radius: 10px
- primary color: #2563EB

## 4. Accessibility Contract

- heading은 h1 또는 role="heading" level 1로 노출된다.
- input은 "새 할 일" label과 연결된다.
- "추가"는 button role을 가진다.
- 완료 토글은 checkbox role을 가진다.
- 삭제 버튼은 "{todo title} 삭제" accessible name을 가진다.

이런식으로 나누면 Figma디자인은 단순한 이미지가 아니라 테스트가 참조할 수 있는 검증 가능한 디자인 문서가 됩니다. 위와 같은 명세는 Figma mcp를 활용하면 아주 간단하게 작성할 수 있지만 고려해야 할 점도 있습니다.

Figma MCP만으로는 보완하기 어려운 부분도 있습니다.

- API 실패 시 어떤 메시지를 보여줄 것인가?
- 긴 todo text는 줄바꿈인가, 말줄임인가?
- 빈 문자열 입력 시 추가 버튼은 disabled인가?
- error state가 Figma에 없으면 어떻게 보완할 것인가?
- 어떤 상태까지 visual regression baseline으로 남길 것인가?

해당 부분들은 기획서, API 스펙, 팀 규칙, 개발자의 판단이 함께 들어가야 합니다.

그래서 핵심은 아래와 같습니다.

Figma MCP로 디자인 컨텍스트를 읽고,
Project Rules /Skils로 그 컨텍스트를 테스트 가능한 기준으로 변환한다.

Figma MCP를 제대로 활용하려면 프로젝트 규칙이 필요합니다.

예를 들어 TodoList에는 다음과 같은 Rules를 둘 수 있습니다.

# TodoList Design-to-Test Rules

1. Figma frame에서 텍스트, 상태, 토큰, 접근성 힌트를 분리한다.
2. Figma에 happy path만 있더라도 empty, error, loading state를 제안한다.
3. input은 반드시 label과 연결한다.
4. button은 실제 button 요소를 사용한다.
5. checkbox는 실제 checkbox 또는 동일한 accessibility role을 가져야 한다.
6. icon-only button은 반드시 accessible name을 가진다.
7. Playwright 테스트는 getByRole, getByLabel, getByText를 우선 사용한다.
8. class selector와 nth-child selector를 피한다.
9. 디자인 토큰은 computed style test로 검증한다.
10. Figma screenshot 비교는 최초 구현 검증에 사용한다.
11. 승인된 browser screenshot을 이후 regression baseline으로 사용한다.
12. network, console, pageerror는 테스트 리포트에 포함한다.

이 규칙이 있으면 AI에게 이렇게 요청할 수 있습니다.

Figma MCP로 TodoList frame을 읽고,
TodoList Design-to-Test Rules에 따라 디자인 계약서를 만들어줘.

다음 항목으로 나눠서 작성해줘.

1. Screen Structure
2. States
3. Design Tokens
4. Accessibility Contract
5. 생성해야 할 테스트 목록

주의:
- Figma와 브라우저의 pixel-perfect 일치를 목표로 하지 말 것.
- 디자인 의도에서 벗어난 차이를 검출하는 기준으로 작성할 것.
- Figma에 없는 empty/error/loading state는 기획 기준을 참고해 보완할 것.
- 승인된 browser screenshot은 이후 visual regression baseline으로 사용할 것.

이제 해당 계약서를 기반으로 테스트코드를 작성해보겠습니다.


3.3 명세를 기반으로 테스트코드 작성

위에서 만든 TodoList Design 테스트 명세를 이제 테스트코드로 변환 해보겠습니다.

디자인 계약이어지는 테스트검증하는 것
Screen Structure화면 구조 테스트Figma의 주요 UI 요소가 브라우저에 존재하는가
Design Tokenstoken / computed style 테스트Figma token이 실제 CSS에 적용됐는가
Accessibility ContractARIA / semantic 테스트브라우저가 UI를 의미적으로 올바르게 이해하는가
Screenshot ReferenceFigma comparison / visual regression디자인 의도와 구현 결과가 시각적으로 어긋나지 않는가

3.3.1 화면 구조 테스트: Figma의 주요 요소가 브라우저에 존재하는가

먼저 Figma에 정의된 주요 텍스트와 UI요소가 브라우저에 존재하는지 확인합니다.

이 테스트는 시각적 픽셀 비교가 아닌, Figma에서 정의된 화면 구조가 브라우저에서 사용자에게 노출되는지 확인하는 테스트입니다.

import { test, expect } from '@playwright/test';
import { mockTodosApi } from './todos.fixture';

test.describe('TodoList Figma 화면 구조 테스트', () => {
  test.beforeEach(async ({ page }) => {
    await mockTodosApi(page);
    await page.goto('/todos');
  });

  test('Figma에 정의된 주요 화면 요소가 브라우저에 노출된다', async ({ page }) => {
    await expect(
      page.getByRole('heading', { name: '오늘 할 일' })
    ).toBeVisible();

    await expect(
      page.getByText('작은 작업부터 하나씩 정리해보세요.')
    ).toBeVisible();

    await expect(
      page.getByRole('textbox', { name: '새 할 일' })
    ).toBeVisible();

    await expect(
      page.getByPlaceholder('할 일을 입력하세요')
    ).toBeVisible();

    await expect(
      page.getByRole('button', { name: '추가' })
    ).toBeVisible();

    await expect(
      page.getByRole('button', { name: '전체' })
    ).toBeVisible();

    await expect(
      page.getByRole('button', { name: '진행 중' })
    ).toBeVisible();

    await expect(
      page.getByRole('button', { name: '완료' })
    ).toBeVisible();
  });
});

해당 테스트를 통해 우리는 Figma에 정의된 화면 구조가 브라우저에 구현되었는가? 를 신뢰할 수 있게 되었습니다.

위 테스트를 통해 우리는 제목, 설명, 입력창, 버튼, 필터처럼 화면의 핵심 요소가 존재하는지 알게 되었습니다.

다음은 디자인 스타일 테스트를 진행하겠습니다.

3.3.2 디자인 토큰 테스트: Figma token이 실제 CSS에 적용되었는가

Figma에서 card padding이 24px이고 radius가 16px이라면, 브라우저에서도 실제 computed style이 그 값을 가져야 합니다.

test.describe('TodoList Figma token 테스트', () => {
  test.beforeEach(async ({ page }) => {
    await mockTodosApi(page);
    await page.goto('/todos');
  });

  test('Todo 카드가 Figma token에 맞는 스타일을 가진다', async ({ page }) => {
    const panel = page.getByTestId('todo-panel');

    const styles = await panel.evaluate((el) => {
      const computed = window.getComputedStyle(el);

      return {
        backgroundColor: computed.backgroundColor,
        padding: computed.padding,
        borderRadius: computed.borderRadius,
      };
    });

    expect(styles.backgroundColor).toBe('rgb(255, 255, 255)');
    expect(styles.padding).toBe('24px');
    expect(styles.borderRadius).toBe('16px');
  });

  test('추가 버튼이 Figma primary token을 사용한다', async ({ page }) => {
    const button = page.getByRole('button', { name: '추가' });

    const styles = await button.evaluate((el) => {
      const computed = window.getComputedStyle(el);

      return {
        backgroundColor: computed.backgroundColor,
        borderRadius: computed.borderRadius,
      };
    });

    expect(styles.backgroundColor).toBe('rgb(37, 99, 235)');
    expect(styles.borderRadius).toBe('10px');
  });
});

해당 테스트를 통해서 우리는 screenshot diff으로만 알기 어려운 왜 달라 졌는지를 알 수 있습니다.

tip. 디자인 시스템이 잘 설계되어 있고, Button/Input/Card 같은 공통 컴포넌트에 이미 토큰 테스트가 있다면, 페이지마다 모든 token test를 반복할 필요는 없습니다.

3.3.3 ARIA snapshot 테스트: 시각적으로 맞아도 의미 구조가 깨질 수 있다.

화면이 시각적으로 맞아 보인다고해서 브라우저의 시멘틱 요소들이 알맞게 적용되어있다.는 보장할 수는 없습니다.

예를 들어 AI가 "추가" 버튼을 다음과 같이 구현할 수 있습니다.

<div class="add-button" onclick="addTodo()">추가</div>

화면 상 버튼처럼 보이지만 접근성 트리에서는 button으로 인식하지 못합니다. 우리가 원하는 구조는 다음과 같습니다.

<button type="button">추가</button>

이를 ARIA snapshot으로 검증할 수 있습니다.

test.describe('TodoList ARIA 구조 테스트', () => {
  test.beforeEach(async ({ page }) => {
    await mockTodosApi(page);
    await page.goto('/todos');
  });

  test('TodoList는 기대한 시멘틱 구조를 가진다', async ({ page }) => {
    await expect(page.locator('main')).toMatchAriaSnapshot(`
      - main:
        - heading "오늘 할 일" [level=1]
        - text: "작은 작업부터 하나씩 정리해보세요."
        - textbox "새 할 일"
        - button "추가"
        - list:
          - listitem:
            - checkbox "우유 사기 완료"
            - text: "우유 사기"
            - button "우유 사기 삭제"
          - listitem:
            - checkbox "운동하기 완료" [checked]
            - text: "운동하기"
            - button "운동하기 삭제"
        - button "전체"
        - button "진행 중"
        - button "완료"
    `);
  });
});

해당 테스트를 통해 우리는 시멘틱 요소가 잘 적용되어있는가?를 신뢰할 수 있게 되었습니다.

이제 디자인 의도와 브라우저의 최종 결과를 비교해보겠습니다.


3.3.4 Figma screenshot 비교: 디자인 의도와 브라우저 결과 비교하기

앞선 테스트들은 Figma 디자인을 구조, 토큰, 접근성 기준으로 나누어 검증했습니다.

하지만 실제 화면에서 중요한 것은 결국 사용자가 보는 최종 렌더링 결과입니다. 구조가 맞고, 토큰도 맞고, 시멘틱 구조도 맞더라도 실제 브라우저 화면에서는 다음과 같은 문제가 발생할 수 있습니다.

- 카드 간격이 어색하게 벌어짐
- 필터 버튼 위치가 Figma와 다르게 정렬
- 텍스트가 줄바꿈되지 않고 overflow됨
- 모바일 viewport에서 영역이 깨짐

이러한 문제는 getByRole, computed style, ARIA snapshot만으로는 충분히 확인하기 어렵습니다.

그래서 Figma screenshot과 Browser screenshot을 비교할 수 있습니다.

단계는 아래와 같습니다.

1. Figma frame을 이미지로 export한다.
2. 브라우저에서 같은 영역 screenshot을 찍는다.
3. 두 이미지를 diff하고 리뷰한다.

개념적으로는 다음과 같습니다.

// scripts/export-figma-frame.ts
import fs from 'node:fs/promises';

const FIGMA_TOKEN = process.env.FIGMA_TOKEN!;
const FIGMA_FILE_KEY = process.env.FIGMA_FILE_KEY!;
const FIGMA_NODE_ID = process.env.FIGMA_NODE_ID!;

async function exportFigmaFrame() {
  const imageApiUrl =
    `https://api.figma.com/v1/images/${FIGMA_FILE_KEY}` +
    `?ids=${encodeURIComponent(FIGMA_NODE_ID)}` +
    `&format=png&scale=2`;

  const response = await fetch(imageApiUrl, {
    headers: {
      'X-Figma-Token': FIGMA_TOKEN,
    },
  });

  if (!response.ok) {
    throw new Error(`Failed to export Figma image: ${response.status}`);
  }

  const data = await response.json();
  const imageUrl = data.images[FIGMA_NODE_ID];

  if (!imageUrl) {
    throw new Error('Figma image URL not found');
  }

  const imageResponse = await fetch(imageUrl);
  const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());

  await fs.mkdir('design-baselines/todos', { recursive: true });
  await fs.writeFile(
    'design-baselines/todos/todo-panel-figma.png',
    imageBuffer
  );
}

exportFigmaFrame();

그리고 playwright로 브라우저 화면을 캡쳐합니다.

// tests/e2e/todos/todos.capture.spec.ts
import { test, expect } from '@playwright/test';
import { mockTodosApi } from './todos.fixture';

test('TodoList browser screenshot을 저장한다', async ({ page }) => {
  await mockTodosApi(page);
  await page.goto('/todos');

  const panel = page.getByTestId('todo-panel');

  await panel.screenshot({
    path: 'artifacts/todos/todo-panel-browser.png',
  });

  await expect(panel).toBeVisible();
});

이후 pixelmatch같은 이미지 비교 도구로 Figma image와 Browser screenshot을 비교합니다.

// scripts/compare-figma-browser.ts
import fs from 'node:fs';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';

const figmaPath = 'design-baselines/todos/todo-panel-figma.png';
const browserPath = 'artifacts/todos/todo-panel-browser.png';
const diffPath = 'artifacts/todos/todo-panel-figma-browser-diff.png';

const figma = PNG.sync.read(fs.readFileSync(figmaPath));
const browser = PNG.sync.read(fs.readFileSync(browserPath));

if (figma.width !== browser.width || figma.height !== browser.height) {
  throw new Error(
    `Image size mismatch: figma ${figma.width}x${figma.height}, browser ${browser.width}x${browser.height}`
  );
}

const diff = new PNG({
  width: figma.width,
  height: figma.height,
});

const diffPixels = pixelmatch(
  figma.data,
  browser.data,
  diff.data,
  figma.width,
  figma.height,
  {
    threshold: 0.1,
  }
);

fs.writeFileSync(diffPath, PNG.sync.write(diff));

console.log({
  diffPixels,
  diffPath,
});

이를 통해 아래와 같은 디자인리뷰가 가능한 PR요청을 받을 수 있습니다.

이제 이후 디자인 건은 baseline에 저장 후 다음 흐름들을 검증하면 됩니다.


3.3.5 Visual regression: 승인된 브라우저 baseline과 현재 화면 비교하기

Figma 비교가 끝나고, 디자이너나 리뷰어가 브라우저 결과를 승인했다면 다음 단계는 baseline 저장입니다.
여기서부터는 Figma와 비교하지 않습니다.

이후 PR에서는 승인된 브라우저 screenshot과 현재 브라우저 screenshot을 비교합니다.

Figma comparison
= Figma → Browser
= 디자인 정합성 검증

Visual regression
= Approved Browser Baseline → Current Browser
= 이전 커밋 대비 시각 회귀 테스트

흐름은 다음과 같습니다.

Figma Frame
   ↓
Design Conformance Review
   ↓
Approved Browser Screenshot
   ↓
Regression Baseline
   ↓
Future PR Visual Diff

Playwright에서는 toHaveScreenshot()으로 visual regression을 구성할 수 있습니다.

// tests/e2e/todos/todos.visual.spec.ts
import { test, expect } from '@playwright/test';
import { mockTodosApi } from './todos.fixture';

test.describe('TodoList visual regression', () => {
  test.beforeEach(async ({ page }) => {
    await mockTodosApi(page);
    await page.goto('/todos');

    await page.setViewportSize({
      width: 1440,
      height: 900,
    });
  });

  test('TodoList 기본 화면은 승인된 baseline과 동일하다', async ({ page }) => {
    await expect(page.getByTestId('todo-panel')).toHaveScreenshot(
      'todo-panel-default.png',
      {
        animations: 'disabled',
        maxDiffPixels: 120,
      }
    );
  });

  test('TodoList empty state는 승인된 baseline과 동일하다', async ({ page }) => {
    await mockTodosApi(page, {
      initialTodos: [],
    });

    await page.goto('/todos');

    await expect(page.getByTestId('todo-panel')).toHaveScreenshot(
      'todo-panel-empty.png',
      {
        animations: 'disabled',
        maxDiffPixels: 120,
      }
    );
  });

  test('TodoList error state는 승인된 baseline과 동일하다', async ({ page }) => {
    await mockTodosApi(page, {
      failGetTodos: true,
    });

    await page.goto('/todos');

    await expect(page.getByTestId('todo-panel')).toHaveScreenshot(
      'todo-panel-error.png',
      {
        animations: 'disabled',
        maxDiffPixels: 120,
      }
    );
  });
});

해당 테스트를 통해 시각적 이슈가 생기지 않았는가 확인할 수 있으며, 이후 업데이트에서도 baseline 기준으로 의도적으로 변경된 화면인지, 실수인지를 확인할 수 있습니다.

3.4 디자인 검증을 CI에서 리뷰하기

앞에서 toHaveScreenshot()을 통해 승인된 브라우저 baseline과 현재 화면을 비교하는 visual regression 테스트를 구성했습니다.

이제 이 테스트가 CI에서 실패했다고 가정해보겠습니다.

예를 들어 TodoList의 전체 필터 버튼 색상이 기존 디자인과 다르게 변경되었습니다.

Expected:
- 전체 버튼은 primary blue 계열이다.

Actual:
- 전체 버튼이 green 계열로 변경되었다.

이 변경은 기능 테스트만으로는 잡기 어렵습니다.

기능적으로는 여전히 전체 버튼을 클릭할 수 있고, 전체 목록도 정상적으로 표시될 수 있습니다. getByRole('button', { name: '전체' }) 테스트도 통과할 수 있습니다.

하지만 디자인 관점에서는 문제가 될 수 있으며, 이런 변경을 잡기 위해 시각적 회귀 테스트가 필요합니다.

아래는 CI에서 visual diff가 발생했을 때의 예시입니다.

ex) `전체` 버튼이 의도된 디자인과 다르게 렌더링된 경우
TodoList visual regression diff TodoList visual regression diff

Diff: 어디가 달라졌는지 강조해서 보여준다.
Actual: 현재 PR에서 렌더링된 화면이다.
Expected: 이전에 승인된 baseline 화면이다.
Side by side: 현재 화면과 기준 화면을 나란히 비교한다.
Slider: 두 화면을 겹쳐 보며 차이를 확인한다.

이제 우리는 해당 화면을 통해 리뷰를 진행하면 됩니다.

Visual diff 발생
   ↓
변경 영역 확인
   ↓
변경 범위가 예상과 일치하는가?
   ↓
의도된 디자인 변경인가?
   ├─ Yes → 리뷰어 승인 후 baseline update
   └─ No → 구현 수정

여기까지 우리는 Figma와 브라우저의 baseline을 기준으로 디자인 회귀 검증까지 완료하였습니다.

하지만 실제 제품의 문제는 화면에서만 발생하지 않습니다.
API요청이 실패했는데 empty state처럼 보일 수도 있고, 화면은 정상처럼 보이지만 브라우저 내부에서는 runtime error가 발생하고 있을수도 있습니다.

다음 파트에서는 TodoList의 API응답과 브라우저 런타임 신호를 함께 검증해보겠습니다.

4. API와 런타임 상태 검증하기

앞선 단계에서는 Figma 디자인을 기준으로 화면 구조, 디자인 토큰, 시맨틱 구조, 시각적 회귀 검증을 진행했습니다.

하지만 실제 제품의 문제는 화면에서만 발생하지 않습니다. 브라우저에서 보이는 화면은 결과일 뿐이고, 그 화면이 만들어지는 과정에서 API 요청, 응답 상태, JavaScript 런타임, console error, page error등이 함께 존재합니다.

예를 들어 TodoList에서 목록이 비어 보인다고 가정해보겠습니다.

화면: "할 일이 없습니다"

이 화면은 두 가지 의미를 가질 수 있습니다.

1. 정상적으로 데이터를 불러왔고, 실제로 할 일이 없는 상태.
2. API 요청이 실패했지만 화면이 empty state처럼 보이는 상태

두 상태는 시각적으로 비슷해 보일 수 있지만 의미는 완전히 다릅니다.

1. empty state - 정상
2. error state - 에러

따라서 AI가 만든 UI를 신뢰하려면 화면이 보이는가? 에서 멈추지 않고, API가 정상적으로 응답했는지, 실패 상태를 올바르게 처리했는지, 사용 중 console error나 page error가 발생하지 않았는지도 함께 확인해야 합니다.

4.1 API 테스트

우리가 검증하려는건 API 자체가 아닙니다. 우리가 검증하려는것은 다음과 같습니다.

프론트엔드가 정해진 API 응답을 받았을 때, 화면을 올바르게 렌더링하는가?

이 장에서의 API 테스트는 다음에 가깝습니다.

API Mock Test
Data Contract Test
Frontend Integration Test
API 상태별 UI 테스트

우선 api를 분석하여 케이스를 정리해보겠습니다.

GET /api/todos 성공
→ todo 목록을 보여준다.

GET /api/todos 성공 + 빈 배열
→ empty state를 보여준다.

GET /api/todos 실패
→ error state를 보여준다.

POST /api/todos 성공
→ 새 todo가 목록에 추가된다.

이제 API 테스트를 진행하겠습니다.

4.1.1 API 응답을 mock으로 고정하기

테스트에서 실제 API를 서버에 의존할 필요는 없습니다. 우리는 API를 검증하는것이 아닌 FE가 정해진 응답을 받았을 때, 올바르게 화면을 렌더링 하는가? 가 주된 목적이기 때문입니다.

이에 TodoList API는 아래와 같다 가정하고 이를 mock하겠습니다.

[todolist api]
GET /api/todos
POST /api/todos
PATCH /api/todos/:id
DELETE /api/todos/:id
//todos.fixture.ts
import type { Page } from '@playwright/test';

export type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

export const defaultTodos: Todo[] = [
  { id: 1, title: '우유 사기', completed: false },
  { id: 2, title: '운동하기', completed: true },
  { id: 3, title: '책 읽기', completed: false },
];

type MockTodosApiOptions = {
  initialTodos?: Todo[];
  failGetTodos?: boolean;
};

export async function mockTodosApi(
  page: Page,
  options: MockTodosApiOptions = {}
) {
  let todos = [...(options.initialTodos ?? defaultTodos)];

  await page.route('**/api/todos**', async (route) => {
    const request = route.request();
    const method = request.method();
    const url = new URL(request.url());

    if (options.failGetTodos && method === 'GET') {
      await route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({
          message: 'Internal Server Error',
        }),
      });
      return;
    }

    if (method === 'GET') {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify(todos),
      });
      return;
    }

    if (method === 'POST') {
      const body = await request.postDataJSON();

      const newTodo: Todo = {
        id: todos.length + 1,
        title: body.title,
        completed: false,
      };

      todos = [...todos, newTodo];

      await route.fulfill({
        status: 201,
        contentType: 'application/json',
        body: JSON.stringify(newTodo),
      });
      return;
    }

    if (method === 'PATCH') {
      const id = Number(url.pathname.split('/').at(-1));
      const body = await request.postDataJSON();

      todos = todos.map((todo) =>
        todo.id === id ? { ...todo, ...body } : todo
      );

      const updatedTodo = todos.find((todo) => todo.id === id);

      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify(updatedTodo),
      });
      return;
    }

    if (method === 'DELETE') {
      const id = Number(url.pathname.split('/').at(-1));

      todos = todos.filter((todo) => todo.id !== id);

      await route.fulfill({
        status: 204,
        body: '',
      });
      return;
    }

    await route.continue();
  });
}

이제 우리는 테스트마다 원하는 API 상태를 만들 수 있습니다.

// 기본 todo 목록
await mockTodosApi(page);

// 빈 목록
await mockTodosApi(page, {
  initialTodos: [],
});

// API 실패
await mockTodosApi(page, {
  failGetTodos: true,
});

이제 우리는 해당 API를 통해 불확실성이 없는 테스트환경을 만들 수 있게 되었습니다.

4.1.2 API 성공 상태 테스트

먼저 API가 정상적으로 응답하는 경우를 확인합니다.

이 테스트는 사용자가 TodoList 페이지에 접근했을 때 /api/todos 요청이 실패하지 않고, 응답 데이터 기반으로 목록이 렌더링 되는지 확인합니다.

// todos.network.spec.ts
import { test, expect } from '@playwright/test';
import { mockTodosApi } from './todos.fixture';

test.describe('TodoList API 상태 테스트', () => {
  test('TodoList는 API 실패 없이 데이터를 불러온다', async ({ page }) => {
    const failedResponses: string[] = [];

    page.on('response', (response) => {
      const isApi = response.url().includes('/api/');

      if (isApi && response.status() >= 400) {
        failedResponses.push(`${response.status()} ${response.url()}`);
      }
    });

    await mockTodosApi(page);
    await page.goto('/todos');

    await expect(
      page.getByRole('heading', { name: '오늘 할 일' })
    ).toBeVisible();

    await expect(
      page.getByRole('listitem').filter({ hasText: '우유 사기' })
    ).toBeVisible();

    await expect(
      page.getByRole('listitem').filter({ hasText: '운동하기' })
    ).toBeVisible();

    expect(failedResponses).toEqual([]);
  });
});

해당 테스트는 다음과 같은 성격을 가집니다.

분류설명
Network Test브라우저에서 발생한 API 응답 상태를 관찰
Data Contract Test정해진 응답을 받았을 때 프론트엔드가 올바르게 동작하는지 검증
API Regression Test이후 변경으로 API 처리 흐름이 깨지지 않았는지 검증
Browser-level Integration TestAPI mock, 상태 처리, UI 렌더링이 함께 동작하는지 확인

4.1.3 API 실패 상태 테스트

다음은 API 실패 상태입니다.

test('API 실패 시 empty state가 아니라 error state를 보여준다', async ({ page }) => {
  await mockTodosApi(page, {
    failGetTodos: true,
  });

  await page.goto('/todos');

  await expect(page.getByRole('alert')).toContainText(
    '할 일을 불러오지 못했습니다'
  );

  await expect(
    page.getByText('아직 할 일이 없습니다')
  ).not.toBeVisible();
});

test('할 일 목록이 비어 있으면 empty state를 보여준다', async ({ page }) => {
  await mockTodosApi(page, {
    initialTodos: [],
  });

  await page.goto('/todos');

  await expect(
    page.getByText('아직 할 일이 없습니다')
  ).toBeVisible();

  await expect(
    page.getByRole('alert')
  ).not.toBeVisible();
});

해당 테스트는 다음과 같은 성격을 가집니다.

분류설명
Error State TestAPI 실패 상태에서 올바른 UI가 표시되는지 검증
API Mock Test실제 서버 대신 고정된 실패 응답 사용
Data Contract Test프론트엔드가 응답 상태에 맞는 UI를 렌더링하는지 검증
Frontend Integration TestAPI 응답, 상태 처리, UI 렌더링이 함께 동작하는지 검증
API Regression Test이후 변경으로 error state 처리가 깨지지 않았는지 검증

4.2 런타임 테스트

API상태를 확인했다면, 다음은 브라우저 내부 런타임 신호를 확인해야 합니다.

겉으로는 정상처럼 보이지만, 실제 내부에서는 아래와 같은 런타임 에러가 발생하고 있을 수 있습니다.

- Hydration failed
- Cannot read properties of undefined
- ChunkLoadError
- CORS error
- Unhandled Promise Rejection

문제는 이런 에러가 화면에서 즉시 티가 나지 않을 수 있습니다. 예를 들어 hydration mismatch가 발생해도 화면은 일단 보일 수 있습니다. 하지만 내부적으로는 이벤트 바인딩 이슈나 특정 interaction 실패 가능성이 생길 수 있습니다.

또 Todo 추가 버튼을 눌렀을 때 내부 state에서 undefined 접근이 발생할 수도 있습니다.

그래서 블랙박스를 유지한 채 AI 코드에 신뢰를 가지려면 "화면이 보이는가?"만 확인하면 부족합니다.

브라우저 내부에서 어떤 runtime signal이 발생했는지도 함께 검증해야합니다.

4.2.1 console error와 page error 수집하기

TodoList 사용 중 console error와 page error가 발생하지 않는지 확인합니다.

//todos.runtime.spec.ts
import { test, expect } from '@playwright/test';
import { mockTodosApi } from './todos.fixture';

test('TodoList 사용 중 console error와 page error가 없어야 한다', async ({ page }) => {
  const consoleErrors: string[] = [];
  const pageErrors: string[] = [];

  page.on('console', (msg) => {
    if (msg.type() === 'error') {
      consoleErrors.push(msg.text());
    }
  });

  page.on('pageerror', (error) => {
    pageErrors.push(error.message);
  });

  await mockTodosApi(page);
  await page.goto('/todos');

  await page
    .getByRole('textbox', { name: '새 할 일' })
    .fill('런타임 확인하기');

  await page
    .getByRole('button', { name: '추가' })
    .click();

  await expect(
    page.getByRole('listitem').filter({ hasText: '런타임 확인하기' })
  ).toBeVisible();

  expect(consoleErrors).toEqual([]);
  expect(pageErrors).toEqual([]);
});

이 테스트는 다음과 같은 성격을 가집니다.

분류설명
Runtime Regression Test이전에는 없던 런타임 오류가 새로 생겼는지 검증
Console Error Test브라우저 console error를 수집
Page Error Test처리되지 않은 런타임 예외를 수집
Browser Stability Test화면 사용 중 브라우저 내부 상태가 안정적인지 검증

실무에서는 console error를 무조건 실패처리 하기 전, 외부 script나 다양한 noise를 예측해서 필터링이 필요함

  • 무시해도 되는 noise -> 팀 규칙 명시
  • 애플리케이션 런타임 에러 -> 테스트 실패 처리

ex) 필터링 예시

const ignoredConsolePatterns = [
  /ResizeObserver loop limit exceeded/,
  /third-party-analytics/,
];

page.on('console', (msg) => {
  if (msg.type() !== 'error') return;

  const text = msg.text();

  const shouldIgnore = ignoredConsolePatterns.some((pattern) =>
    pattern.test(text)
  );

  if (!shouldIgnore) {
    consoleErrors.push(text);
  }
});

이로 인해 실제 브라우저에서 runtime error없이 안정적으로 동작하는지 검증할 수 있게 되었습니다.

Trace: 테스트 실패를 디버깅 가능한 증거로 바꾸기

마지막으로 Trace를 남깁니다.

Trace는 테스트 자체라기보다. 실패 분석 아티펙트입니다.

기능 테스트나 시각적 회귀 테스트는 '무언가 실패했다'를 알려준다면, Trace는 **"언제, 어떤 액션 이후에, 어떤 브라우저 상태에서 실패했는가" 를. 보여줍니다.

Playwright 설정은 다음과 같이 할 수 있습니다.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: 1,
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});

Trace는 이런 질문에 답을 얻을 수 있습니다.

- 어떤 locator를 클릭했는가?
- 클릭 전 DOM은 어땠는가?
- 클릭 후 DOM은 어떻게 바뀌었는가?
- 그 시점의 screenshot은 어땠는가?
- console error가 있었는가?
- network request가 실패했는가?

즉 Trace는 CI에서 실패한 테스트를 다시 디버깅 가능한 상태로 되돌려주는 장치입니다.

여기까지 TodoList를 다음 기준으로 검증해 보았습니다.

기준검증 방식테스트 성격
기획 요구사항Gherkin → PlaywrightFunctional / Acceptance Test
화면 구조getByRole, getByTextScreen Contract Test
디자인 토큰computed styleToken Test
시맨틱 구조ARIA snapshotSemantic / Accessibility Test
디자인 정합성Figma screenshot vs Browser screenshotDesign Conformance
시각 회귀Browser baseline vs Current screenshotVisual Regression
API 상태mock API, response statusNetwork / Data Contract Test
런타임 안정성console, pageerrorRuntime Regression Test
실패 분석traceDebug Artifact

이제 지금까지 배웠던 테스트를 통해서 AI 하네스를 구축하고, 운영하는 법을 알아보겠습니다.

5. AI 구현 및 운영

앞선 단계까지 우리는 TodoList를 여러 검증 기준으로 나누었습니다.

기획 요구사항
→ Gherkin / Playwright 기능 테스트

Figma 디자인
→ 화면 구조 / 토큰 / ARIA / visual regression

API 상태
→ mock API / success / empty / error state

브라우저 런타임
→ console error / pageerror / trace

이제 남은 것은 구현입니다.

하지만 AI시대의 구현은 예전처럼 "개발자가 모든 코드를 직접 작성한다"는 의미와 조금 다릅니다. 우리는 AI에게 구현을 맡길 수 있습니다. 하지만 '검증 기준'이 없다면 해당 구현은 사용할 수 없으며, 개발자가 직접 모든 코드와 플로우를 리뷰하고, 검증해야합니다. 이는 실제로 개발하는것과 크게 다르지 않습니다.

나쁜 요청은 아래와 같습니다.

todoList 만들어줘

이후 모든 코드 품질과 검증 및 유지보수는 개발자 몫입니다.

좋은 요청은 아래와 같습니다.

아래 Gherkin, Design Contract, API mock, Playwright 테스트를 만족하는 TodoList를 구현해줘.

즉 AI에게 구현을 맡기기 전에, 먼저 AI가 통과해야 할 하네스를 만들어야 합니다.

이번 파트는 세 가지를 다뤄보겠습니다.

1. 하네스 기반으로 AI에게 구현을 맡기는 방법
2. PR에서 검증 기준 리포트를 작성하는 방법
3. 테스트 복잡성을 줄이는 운영 방식

5.1 하네스 기반으로 AI 구현하기

AI에게 구현을 맡길 때 가장 중요한 것은 작업 기준을 명확하게 주는 것입니다. 지금까지 만든 검증 기준을 함께 전달해보겠습니다.

TodoList 구현을 맡기기 전에 AI에게 전달할 기준은 다음과 같습니다.

기준역할
Gherkin사용자가 어떤 행동을 할 수 있어야 하는지 정의
Design Contract화면 구조, 상태, 토큰, 접근성 기준 정의
API mock프론트엔드가 받을 데이터 기준 고정
FSD 구조AI가 코드를 어디에 작성해야 하는지 제한
Playwright 테스트브라우저에서 실제 검증할 기준
금지 규칙AI가 자주 만드는 위험한 구현을 방지
검증 명령어AI가 구현 후 반드시 실행해야 할 확인 절차

이렇게 기준을 주면 AI의 역할이 달라집니다.

기준 없는 AI
→ “TodoList를 알아서 만들어줘”

기준 있는 AI
→ “이 하네스를 통과하는 TodoList를 구현해줘”

하네스 기반 구현 프롬프트 예시를 들어보겠습니다. 여기서 프로젝트 품질, 코드 품질, 컨벤션 등 유용한 skils들을 제외하고 진행하겠습니다.

목표:
TodoList 페이지를 구현한다.
단, 아래 검증 하네스를 반드시 통과해야 한다.

1. 기획 기준
- specs/todos/todos.feature의 Gherkin 시나리오를 만족해야 한다.
- 사용자는 todo를 추가, 완료 처리, 삭제, 필터링할 수 있어야 한다.
- empty state와 error state를 구분해야 한다.

2. 디자인 기준
- TodoList Design Contract를 따른다.
- page heading은 "오늘 할 일"이다.
- description은 "작은 작업부터 하나씩 정리해보세요."이다.
- input label은 "새 할 일"이다.
- placeholder는 "할 일을 입력하세요"이다.
- primary action은 "추가"이다.
- filter action은 "전체", "진행 중", "완료"이다.
- card padding은 24px이다.
- card radius는 16px이다.
- primary color는 #2563EB이다.

3. 접근성 기준
- heading은 h1 또는 role="heading" level 1로 노출한다.
- input은 "새 할 일" label과 연결한다.
- "추가"는 실제 button 요소를 사용한다.
- 완료 토글은 checkbox role을 가진다.
- 삭제 버튼은 "{todo title} 삭제" accessible name을 가진다.
- getByRole 기반 Playwright 테스트가 통과해야 한다.

4. API 기준
- GET /api/todos 응답을 기준으로 목록을 렌더링한다.
- POST /api/todos 성공 시 새 todo를 목록에 반영한다.
- API 실패 시 "할 일을 불러오지 못했습니다" alert을 표시한다.
- API 실패를 empty state로 처리하지 않는다.

5. 구현 구조
- entities/todo: Todo 타입, TodoItem, TodoList
- features/add-todo: AddTodoForm
- features/toggle-todo: ToggleTodoCheckbox
- features/delete-todo: DeleteTodoButton
- features/filter-todos: TodoFilter
- widgets/todo-panel: TodoPanel
- pages/todos: TodosPage

6. 금지 사항
- nth-child selector에 의존하지 않는다.
- button 역할을 div로 구현하지 않는다.
- checkbox 역할을 의미 없는 span/div로 구현하지 않는다.
- API 실패를 empty state로 보여주지 않는다.
- console error를 무시하지 않는다.
- visual regression 실패 시 임의로 baseline을 갱신하지 않는다.

7. 구현 후 실행할 검증
- slice 내부 unit/component test
- tests/e2e/todos/todos.functional.spec.ts
- tests/e2e/todos/todos.aria.spec.ts
- tests/e2e/todos/todos.tokens.spec.ts
- tests/e2e/todos/todos.network.spec.ts
- tests/e2e/todos/todos.runtime.spec.ts
- visual regression은 baseline 생성 전 결과를 보고한다.

해당 프롬프트의 핵심은 구현을 “요청”하는 것이 아니라, 구현이 만족해야 할 검증 기준을 먼저 제시한다는 점입니다.

이 기준이 있으면 AI는 단순히 TodoList를 그럴듯하게 만드는 것이 아니라, 기획 기준, 디자인 기준, 접근성 기준, API 기준, 런타임 기준을 통과하는 방향으로 구현해야 합니다.

실제 작업에서는 이 과정을 하나의 에이전트가 모두 순차적으로 처리하기보다, 역할별 에이전트로 나누어 병렬 처리하는 것이 효율적입니다.

  • Spec Agent는 Gherkin과 Acceptance Criteria를 검토합니다.
  • Design Agent는 Figma MCP로 화면 구조와 토큰을 추출합니다.
  • Data Agent는 API mock과 fixture를 구성합니다.
  • Test Agent는 Playwright 테스트 초안을 생성합니다.
  • Implementation Agent는 FSD slice 단위로 구현합니다.
  • QA Agent는 브라우저에서 테스트를 실행하고 리포트를 생성합니다.

다만 모든 작업을 병렬로 처리하는 것이 항상 좋은 것은 아닙니다. 기준 추출과 테스트 초안 생성은 병렬화하기 좋지만, 실제 구현은 같은 파일을 동시에 수정할 수 있기 때문에 충돌이 발생할 수 있습니다.

따라서 추천 흐름은 다음과 같습니다.

Spec / Design / Data 기준은 병렬로 추출한다.
   ↓
Orchestrator가 하나의 Testable Contract로 합친다.
   ↓
Test Agent가 테스트 초안을 생성한다.
   ↓
Implementation Agent가 FSD slice 단위로 구현한다.
   ↓
Browser Verification으로 전체 하네스를 검증한다.
   ↓
AI QA Report를 생성하고 사람이 승인한다.
                 ┌───────────────┐
                 │ Spec Agent     │
                 │ Gherkin / AC   │
                 └───────┬───────┘
                         │
┌───────────────┐        │        ┌───────────────┐
│ Design Agent  │        │        │ Data Agent    │
│ Figma MCP     │        │        │ Mock/Fixture  │
└───────┬───────┘        │        └───────┬───────┘
        │                │                │
        └────────────────┼────────────────┘
                         ↓
                  Testable Contract
                         ↓
                 ┌───────────────┐
                 │ Test Agent     │
                 │ Playwright     │
                 └───────┬───────┘
                         ↓
                 Implementation Agent
                  FSD slice 구현
                         ↓
                 Browser Verification
                         ↓
                   AI QA Report
                         ↓
                   Human Review

Gherkin기준으로 AI에게 테스트를 만들어달라 부탁할 때는 해당 테스트를 필수로 리뷰해야합니다. AI는 구현 세부사항에 과하게 의존하는 테스트를 만들 수도 있으며, 요구사항의 핵심을 놓친 테스트도 만들 수 있습니다.

//bad
await expect(page.locator('.todo-item:nth-child(1)')).toBeVisible();
//good
await expect(page.getByRole('listitem').filter({ hasText: '우유 사기' })).toBeVisible();

AI에게 테스트를 작성하게 하더라도, 사람이 확인해야 할 질문은 다음과 같습니다.

- 이 테스트는 요구사항을 검증하는가?
- 사용자 행동과 결과를 기준으로 작성되었는가?
- 내부 구현에 과하게 의존하지 않는가?
- 실패했을 때 실제 문제를 알려주는가?
- 너무 넓거나 너무 좁은 테스트는 아닌가?

5.2 검증 기준 PR리포트 작성하기

AI가 구현도 마쳤고, 테스트도 실행했습니다.

이제 PR리포트는 단순한 테스트 결과가 아니라, 스펙 검증 리포트여야 합니다.

리뷰어가 봐야 하는 것은 아래와 같습니다.

- 어떤 요구사항을 검증했는가?
- 어떤 테스트가 통과했는가?
- 어떤 기준이 깨졌는가?
- visual diff가 있다면 어디가 달라졌는가?
- API 실패는 없는가?
- console/pageerror는 없는가?
- 실패가 있다면 어떤 액션 이후 발생했는가?
- baseline update가 필요한가?

예를 들어 TodoList PR 리포트는 다음 구조로 작성할 수 있습니다.

[Specification]
- 연결된 Gherkin: specs/todos/todos.feature
- 검증된 시나리오:
  - 새로운 할 일 추가
  - 완료 처리
  - 완료 필터
  - empty state
  - error state

[Functional]
- Todo 추가: Pass
- Todo 완료 토글: Pass
- Todo 삭제: Pass
- 완료 필터: Pass

[Design Contract]
- Screen Structure: Pass
- Design Tokens: Pass
- Accessibility Contract: Pass

[Visual Regression]
- Default state: Pass
- Empty state: Pass
- Error state: Review Required
  - error alert 영역의 spacing이 baseline과 다름

[Network]
- GET /api/todos: 200
- POST /api/todos: 201
- Failed API responses: none

[Runtime]
- Console errors: none
- Page errors: none

[Trace]
//성공 예시 케이스
- 실패 테스트 없음
- trace artifact 없음
//실패 예시 케이스
- 실패 시점: "완료" 필터 클릭 직후
- 관찰: URL 변화 없음, DOM list 갱신 없음

[Decision]
- baseline update 필요 여부: No
- 리뷰 필요 항목: error state spacing diff

해당 리포트가 있으면 리뷰어는 직관적으로 판단할 수 있습니다.

즉 리포트가 단순 테스트 결과가 아니라 디버깅 가능한 리뷰 자료가 됩니다.

기능 실패:
→ 구현 수정 필요

시각 diff:
→ 의도된 디자인 변경인지 확인 필요

runtime:
→ 문제 없음

network:
→ 문제 없음

PR리뷰 또한 다음과 같은 시각으로 진행하면 됩니다.

이 PR은 어떤 Gherkin 시나리오를 만족하는가?
기능 테스트는 어떤 사용자 행동을 검증하는가?
Figma Design Contract와 맞는가?
ARIA 구조가 깨지지 않았는가?
API 실패를 정상 상태처럼 보여주지 않는가?
console/pageerror가 없는가?
visual diff는 의도된 변경인가?
baseline update가 승인되어야 하는 변경인가?

즉 리뷰 기준이 코드 리뷰에서 검증 기준 확인으로 이동됩니다.

5.3 테스트 복잡성 줄이기

여기까지 읽으면 이런 생각이 들 수 있습니다.

이걸 매번 다하나?

해당 아티클에서는 TodoList라는 작은 예제에 의도적으로 많은 테스트를 적용했습니다. 목적은 모든 기능에 모든 테스트를 강제하자는것이 아니고, AI코드를 신뢰할 수 있는 하네스는 어떤 레이어로 구성될 수 있는지 보여주는 것입니다.

실무에서는 리스크 기반으로 테스트를 선택하시면 됩니다.

5.3.1 모든 Gherkin을 E2E로 만들지 않는다.

Gherkin 시나리오를 만들었다고 해서 모든 시나리오를 E2E로 만들 필요는 없습니다.
신뢰도가 높지만 느리고 유지보수 비용도 큽니다. 아래와 같은 테스트 레벨을 나누어 E2E가 과도하게 늘어나는 것을 방지할 수 있습니다.

검증 대상추천 테스트
순수 정책/계산 로직Unit Test
단일 UI 컴포넌트Component Test
slice 내부 상태/행동Unit / Component Test
API 응답에 따른 화면 상태Integration / E2E
핵심 사용자 플로우E2E
디자인 시스템 컴포넌트Component + Visual
페이지 전체 시각 회귀Visual Regression

5.3.2 테스트 배치도

모든 테스트를 tests/에 몰아넣으면 시간이 지나면서 어떤 테스트가 어떤 기능을 검증하는지, 더불어 컨텍스트 관리또한 어려워집니다.

이는 아키텍처를 잘 설계하면 해결할 수 있으며 예를들어 FSD구조를 사용한다면 다음 기준을 추천합니다.

slice 내부 기준은 slice 옆에 둔다.
제품 사용자 흐름은 tests/e2e에 둔다.

예시)

src/features/filter-todos/model/filterTodos.test.ts
→ 필터 로직 검증

src/entities/todo/ui/TodoItem.test.tsx
→ TodoItem 컴포넌트 구조 검증

tests/e2e/todos/todos.functional.spec.ts
→ TodoList 사용자 플로우 검증

tests/e2e/todos/todos.visual.spec.ts
→ 페이지 시각 회귀 검증

이렇게 배치하면 AI에게 작업을 줄 때도 명확해집니다.

features/filter-todos의 로직을 수정하고,
같은 slice 안의 unit test를 통과시켜줘.

그다음 TodoList E2E 필터 시나리오도 통과해야 해.

5.3.3 visual regression은 선택적으로 사용한다.

이를 모든 페이지 붙이면 부담이 커집니다. 단순 문구 변경, 내부 로직 리팩터링, 화면이 필요 없는 API 함수 수정, 일회성 실험 UI 등에는 사용을 지양하는것을 추천합니다.

5.3.4 PR 나누기

PR을 변경 파일경로나 라벨등으로 분기하여 나누어 테스트를 검증하면 좋습니다.

예를들어 다음과 같이 나눌 수 있습니다.

PR마다 실행:
- unit test
- component test
- 핵심 기능 E2E
- API/error state
- runtime error check

디자인 관련 PR에서 실행:
- token test
- visual regression
- ARIA snapshot

main merge 전 실행:
- 전체 E2E
- 전체 visual regression
- trace artifact

nightly 실행:
- 전체 브라우저 matrix
- 전체 visual baseline
- 성능/장기 플로우

즉 테스트를 많이 만든다고 해서 모든 테스트를 모든 PR에서 실행할 필요는 없습니다.

리스크에 맞게 실행 전략을 나누어 보세요!

6. 마무리

이제 TodoList 예제를 통하여 작은 하네스를 설계하는 법을 배워보았습니다.

기획 요구사항
→ Gherkin
→ Playwright 기능 테스트

Figma 디자인
→ Design Contract
→ 화면 구조 / 토큰 / ARIA / visual regression

API 상태
→ mock API
→ success / empty / error state 테스트

브라우저 런타임
→ console / pageerror / trace

AI 구현
→ 하네스 기반 프롬프트
→ PR 기준 검증 리포트

이 흐름은 단순히 테스트를 많이 쓰는 것이 아닙니다.

AI가 만든 코드를 신뢰하기 위해, 무엇을 어떤 기준으로 검증할 것인지 먼저 정의하자는 내용이었습니다.

예전에는 이러한 테스트들을 작성하는것 자체가 리소스로, 비효율적이었지만 이제 '직접 코드를 짠지 오래됐다'라는 이야기가 들려올 정도로 AI가 만든 결과물이 점점 블랙박스화 되고 있습니다.

우리는 모든 라인을 직접 작성하지 않았고, 모든 판단 과정을 기억하지 못합니다.

그렇다면 이를 신뢰할 수 있는 장치를 마련해야하며, 그 장치는 기준과 검증입니다. 해당 장치를 설계한 후에는 기획의 Gherkin화와 기반으로 만들어진 테스트코드를 섬세하게 리뷰하면 됩니다.

긴 글 읽어주셔서 감사합니다.!

박동현 드림

0개의 댓글