발행일

실무에 바로 적용하는 프런트엔드 테스트 2부 — 강의 정리와 실무 결산

1부가 단위 테스트와 통합 테스트를 다뤘다면, 2부는 그 위쪽 계층을 다룹니다. 렌더링 결과를 문자열로 굳혀 두는 스냅샷 테스트, 실제 픽셀을 비교하는 시각적 회귀 테스트, 앱 전체를 띄우는 E2E 테스트 순으로 올라갑니다.

정리는 주제별로 압축했고, 마지막에 "이 중 실무에 실제로 쓴 것" 절을 따로 뒀습니다. 강의를 들은 지 1년 반이 지났으니 이제는 어떤 개념이 살아남았고 어떤 개념이 노트 안에만 남았는지 말할 수 있습니다.

테스트 주도 개발: 방법론보다 목적

TDD는 요구 사항을 테스트 케이스로 먼저 쓰고, 실패 → 성공 → 리팩토링 사이클을 반복하는 개발 방법론입니다. 강의가 든 장점은 네 가지였습니다.

  • 개발 단계에서 버그의 원인을 찾아 수정할 수 있습니다.
  • 지속적으로 검증하므로 안정적으로 작업할 수 있습니다.
  • 효율적인 테스트 단위와 코드 가독성을 계속 고민하게 되어 자연스럽게 좋은 설계에 대한 사고로 이어집니다.
  • 초기 작성 비용은 크지만 앱을 길게 끌고 갈수록 회수됩니다.

적용 대상도 나뉩니다. 공통 컴포넌트나 훅처럼 검증할 기능이 명확하고 범위가 좁은 모듈은 TDD와 잘 맞습니다. 상태 관리, API 호출, 컴포넌트 조합 같은 비즈니스 로직은 통합 테스트 단위로 TDD를 적용하면 리팩토링이 안전해집니다.

다만 강의가 반복해서 강조한 쪽은 오히려 반대 방향이었습니다. 테스트를 작성한다고 반드시 TDD를 도입할 필요는 없습니다. 리소스가 부족하면 중요한 기능의 단위 테스트만 쓰거나, 개발이 끝난 뒤 핵심 워크플로에만 E2E를 붙이는 식으로 앱에 맞는 현실적인 방법을 찾으면 됩니다. 중요한 것은 개발 단계에서 테스트 피드백을 받아 기능의 안정성을 높이는 것이지, 사이클 자체를 지키는 게 아닙니다. 방법론에 몰두하지 말고 테스트의 목적에 집중하라는 것, 그리고 무엇이 됐든 팀 문화로 정착시키라는 것이 이 절의 결론입니다.

스냅샷 테스트: 싸게 얻고 싸게 잃는다

스냅샷 테스트는 컴포넌트의 렌더링 결과나 함수의 실행 결과를 직렬화해서 파일로 기록해 두고, 이후 실행 결과를 그 기록과 비교하는 방식입니다. 스냅샷은 결과를 문자열 형태로 적어 둔 파일이고, 이후 테스트에서 비교 기준이 됩니다. 작성 비용이 거의 없고 코드 변경이 UI에 의도치 않은 영향을 줬는지 빠르게 잡아냅니다.

Vitest는 두 가지를 제공합니다. toMatchSnapshot()은 테스트 디렉터리 하위 __snapshots__ 폴더에 별도 파일로 저장하고, toMatchInlineSnapshot()은 테스트 파일 안에 결과를 직접 박아 넣습니다. 전자는 변경 이력을 관리해야 하는 컴포넌트 테스트에, 후자는 결과가 짧아 파일을 따로 둘 이유가 없는 경우에 씁니다.

import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import MyComponent from './MyComponent'

describe('MyComponent', () => {
  it('renders correctly', () => {
    const { container } = render(<MyComponent />)
    expect(container).toMatchSnapshot()
  })
})

스냅샷이 변경되면 vitest run --update-snapshot으로 갱신합니다. 그리고 바로 여기가 이 테스트의 약점입니다.

  • 업데이트가 너무 쉽습니다. 실패를 보고 원인을 따지는 대신 갱신 플래그를 눌러 버리면 의도치 않은 변경이 그대로 통과합니다.
  • 커질수록 못 읽습니다. 컴포넌트가 복잡해지면 스냅샷 diff는 사람이 판독할 수 있는 물건이 아니게 됩니다. no-large-snapshots 같은 ESLint 규칙으로 크기를 강제할 수는 있지만 근본적인 해결은 아닙니다.
  • 실제로 렌더링하지 않습니다. DOM 구조 문자열만 비교하므로 CSS 변경이나 레이아웃이 깨진 것은 감지하지 못합니다.
  • TDD 사이클과 맞지 않습니다. 먼저 쓸 수 있는 테스트가 아니라, 이미 나온 결과를 사후에 굳히는 테스트이기 때문입니다.

원본 노트의 인라인 스냅샷 예제에는 오류가 있었습니다. { name: 'Shopping Mall', items: 5 }의 인라인 스냅샷을 name, items 순서로 손으로 적어 뒀는데, Vitest와 Jest가 공유하는 pretty-format 직렬화기는 객체 키를 정렬해서 출력합니다. 실제로는 items가 먼저 나오므로 저 코드는 그대로 돌리면 실패합니다. 인라인 스냅샷은 손으로 쓰는 게 아니라 인자를 비워 두고 러너가 채우게 하는 것이 맞습니다.

시각적 회귀 테스트: 스토리북과 크로마틱

스냅샷 테스트가 "실제로 렌더링하지 않는다"는 한계를 가졌으니, 다음 단계는 진짜로 그려서 이미지로 비교하는 것입니다. 이것이 시각적 회귀 테스트입니다. UI 변경이 발생했을 때 기존과 달라진 점이 있는지 렌더링된 결과 이미지로 비교·검증합니다.

스토리북이 그 대상을 만들어 준다

스토리북은 비즈니스 로직이나 컨텍스트의 간섭 없이 컴포넌트를 시나리오별로 렌더링해 주는 개발 도구입니다. 단위·통합 테스트가 기능과 로직을 검증한다면, 스토리북은 스타일과 레이아웃 같은 실제 UI를 검증하는 자리를 맡습니다. 시나리오별로 이미 렌더링되고 있으니 시각적 회귀 테스트의 대상으로 삼기에 자연스럽습니다.

스토리는 CSF(Component Story Format)로 작성하고 .stories.tsx 같은 파일에 둡니다.

  • 메타데이터default export로 정의합니다. 제목, 필드 정보, 매개변수, 데코레이터가 여기 들어갑니다.
  • 스토리named export로 하나씩 내보냅니다.
  • **args**로 스토리별 인자를 동적으로 바꿔 UI에 반영합니다.
  • play 함수로 렌더링 후 사용자 상호작용까지 시뮬레이션할 수 있습니다.
  • 공통 설정은 .storybook 디렉터리에 둡니다. (원본 노트에는 .stroybook으로 오타가 나 있었습니다.)

작성 대상 선정에도 요령이 있습니다. UI만 렌더링하는 컴포넌트는 시나리오를 상세하게 쪼갤수록 좋지만, 비즈니스 로직이 응집된 컴포넌트는 준비 과정이 많아 스토리를 쓰기 어렵습니다. 스토리를 쓰기 어렵다는 신호 자체가 로직과 UI가 덜 분리됐다는 신호이므로, 스토리 작성 대상과 통합 테스트 대상을 나눠 보는 것이 그 자체로 설계 점검이 됩니다.

크로마틱과 워크플로

전문 도구를 쓰면 얻는 것이 분명합니다. 일관된 환경에서 스냅샷을 찍을 수 있고, 고도화된 비교 알고리즘으로 사용자 관점의 차이를 판별하며, 여러 OS와 브라우저를 제공하고, 변경 이력이 전부 남습니다.

크로마틱(Chromatic)은 스토리북 메인테이너들이 만든 도구라 연동이 특히 쉽습니다. 스토리가 이미 있다면 곧바로 시각적 회귀 테스트 대상이 됩니다. 워크플로는 이렇게 이어집니다.

  1. 스토리북 작성
  2. Chromatic CI 연동
  3. UI 회귀 테스트 실행
  4. PR 승인 및 머지
  5. 스토리북 배포

자동화해 두면 예상치 못한 UI 변화가 빠르게 검출되고, 팀원이 크로마틱에서 diff 스냅샷을 보고 무엇이 바뀌었는지 바로 판단할 수 있으며, 별도 환경 구성 없이 배포된 사이트에서 최신 스토리를 확인할 수 있습니다.

한계

유료 도구가 많아 비용 부담이 있고, 직접 구축하면 그 관리 비용을 대신 냅니다. 이미지가 달라졌다는 사실은 알려 주지만 달라졌는지는 알려 주지 않아 원인 추적에 시간이 걸립니다. 실행도 느립니다. 그래서 매 커밋이 아니라 PR 시점처럼 특정 타이밍에만 돌리는 CI 연동이 사실상 필수입니다.

강의 노트에는 Percy, BackstopJS, Applitools, Visual Regression Tracker, Happo, Playwright + Pixelmatch, Loki까지 대안 도구가 나열돼 있었습니다. 갈리는 축은 결국 두 개입니다.

한쪽다른 쪽
비용·운영유료 SaaS (Percy, Applitools, Happo)오픈소스·자체 호스팅 (BackstopJS, Visual Regression Tracker, Loki)
인터페이스전용 대시보드와 리뷰 UI터미널 중심, 직접 구성 (Playwright + Pixelmatch)

돈을 내면 설정과 운영을 사고, 오픈소스를 고르면 그 설정과 운영을 직접 합니다. 도구 이름은 계속 바뀌지만 이 교환 관계는 그대로입니다.

E2E 테스트: 실제 앱을 띄운다

E2E 테스트는 실제 앱을 구동해 시스템 전체 흐름을 검증합니다. 사용자 관점의 시나리오를 그대로 재현하니 프런트엔드부터 백엔드까지 상태를 확인할 수 있고, 변경 사항이 전체 시스템에 미치는 영향을 볼 수 있습니다. 대신 느립니다.

작성 시 전제 조건이 있습니다. 실제 환경을 건드리므로 유관 부서와의 협력이 필요하고, 가능한 한 API는 모킹하지 않으며, 도입을 위한 일정 확보가 선행돼야 합니다.

단위·통합 테스트와 기능이 겹치는 문제도 짚고 갑니다. 전체 앱을 띄웠을 때도 정상 동작하는지 확인하는 값어치는 있지만, 이미 통합 테스트에서 검증된 것을 골라내는 데 시간이 듭니다. 중복을 완전히 없애려 하기보다 어느 정도는 감수하는 편이 현실적입니다.

Cypress의 핵심 개념

Cypress는 브라우저 안팎에서 일어나는 일을 제어할 수 있어 일관된 환경에서 테스트를 돌릴 수 있는 도구입니다. 브라우저 UI를 띄우는 head 모드는 실행 과정 확인과 디버깅에, UI 없이 브라우저 엔진만 명령줄로 제어하는 headless 모드는 속도가 빨라 CI나 클라우드 환경에 씁니다.

문법은 cy. prefix를 붙인 내장 API로 통일돼 있습니다. cy.visit()로 페이지를 방문하고, beforeEach·beforeAll로 준비 과정을 잡고, Cypress Testing Library를 붙이면 Testing Library의 DOM 쿼리를 그대로 쓸 수 있으며, should()로 단언합니다. 단언 라이브러리는 Chai, Chai-jQuery, Sinon-Chai를 확장해서 씁니다. 각각 값 비교, DOM 속성 검증, 스파이·스텁 호출 검증을 맡습니다.

cy.get('[data-testid="link"]')
  .should('have.class', 'active')
  .and('have.attr', 'href')
  .and('include', 'example.com')

주의할 점 하나. Cypress는 단언이 실패하면 자동으로 재시도하므로, 같은 체인에 상반된 단언을 붙이면 안 됩니다. should('be.visible').and('not.be.visible')은 통과할 수 없습니다. 사라지는 것을 확인하려면 문장을 나눠 씁니다.

개념적으로 가장 중요한 것은 subject와 retry-ability입니다. subject는 체이닝의 시작 지점이자 대상이 되는 요소이고, 커맨드의 실행 결과가 다음으로 넘어가는 것을 yield라고 부릅니다. cy.get()이나 단언처럼 "값이 나중에 바뀔 수 있다"고 판단되는 API는 조건이 만족될 때까지 자동으로 재시도합니다. 그래서 E2E에서 늘 골치인 타이밍 문제를 임의의 wait(1000) 없이 넘길 수 있습니다. API는 체인 전체를 재시도하는 Query(단언은 그 특수한 형태)와, 재시도 없이 한 번만 실행되는 Non-Query로 나뉩니다.

이 구분이 그대로 확장 API에도 이어집니다.

구분정의 APIretry-ability동작
커스텀 쿼리Cypress.Commands.addQuery()지원동기, subject를 받아 체이닝을 재시도
커스텀 커맨드Cypress.Commands.add()미지원비동기 가능, 한 번만 실행

쿼리는 재시도되는 만큼 멱등해야 합니다. 여러 번 호출돼도 앱 상태가 변하면 안 됩니다. 반대로 상태를 바꾸거나 비동기 작업이 필요하면 커맨드입니다.

커맨드는 prevSubject 옵션으로 이전 결과를 받을지 결정합니다. false면 새 체인을 시작하는 Parent Command, true면 이어받는 Child Command, optional이면 둘 다 되는 Dual Command입니다.

Cypress.Commands.add('loginViaApi', (userType) => {
  const users = {
    admin: { name: 'Admin User', admin: true },
    user: { name: 'Regular User', admin: false },
  }

  cy.request('POST', '/api/login', users[userType]).then((response) => {
    cy.setCookie('auth_token', response.body.token)
  })
})

cy.loginViaApi('admin')

로그인처럼 매 테스트마다 반복되는 준비 과정은 UI를 거치지 말고 cy.request()로 건너뛰라는 것이 공식 권장입니다. 그 외 가이드는 상식적입니다. 모든 동작을 커스텀 커맨드로 만들지 말고(테스트 파일 안에서만 쓰는 로직은 그냥 함수로 두는 편이 간단합니다), 하나에 기능을 몰아넣지 말고, TypeScript 정의를 붙여 자동완성과 문서화를 살리라는 것입니다.

서버 요청 가로채기

cy.intercept()로 요청을 가로채 응답을 스텁하거나, 요청이 끝날 때까지 기다릴 수 있습니다.

cy.intercept('/activities/*', { fixture: 'activities.json' }).as('getActivities')
cy.visit('/dashboard')
cy.wait('@getActivities')
cy.get('h1').should('contain', 'Dashboard')

fixture로 고정 데이터를 물려 테스트 환경을 일정하게 유지하고, 별칭(as)을 붙여 cy.wait('@alias')로 타이밍을 잡습니다. 요청 본문 자체를 검증할 수도 있습니다.

cy.intercept('POST', '/users').as('newUser')
cy.wait('@newUser').its('request.body').should('deep.equal', {
  id: 123,
  name: 'John Doe',
})

GraphQL처럼 엔드포인트가 하나로 모이는 경우에는 req.body.operationName으로 쿼리와 뮤테이션을 구분해 별칭을 나눠 붙이는 패턴을 씁니다. Command Log에서는 실제 서버 요청이 채워진 원, 스텁된 요청이 빈 원으로 표시되므로 무엇이 진짜로 나갔는지 눈으로 확인할 수 있습니다.

여기서 원본 노트 안의 자기모순이 하나 드러납니다. E2E 테스트 원칙에서는 "가능한 한 API는 모킹하지 않는다"고 못 박아 놓고, 네트워크 전략 절에서는 스텁을 "대부분의 테스트에 사용" 추천으로 적어 뒀습니다. 정리하면 이렇게 갈립니다. 로그인·회원가입 같은 핵심 경로는 실제 서버와 통신해 클라이언트-서버 간 계약이 맞는지까지 확인하고, 경계 케이스나 에러 화면처럼 실제 서버로 재현하기 어려운 것만 스텁합니다. 전부 스텁하면 그건 이름만 E2E인 통합 테스트입니다.

E2E의 한계

느립니다. 실제 앱을 띄우니 렌더링과 API 응답에 시간이 들고, 테스트 실행 시간이 길어지면 개발 생산성이 떨어집니다. 외부 환경 요소 때문에 깨지기도 하고, 온전히 돌게 유지하는 관리 비용이 큽니다. 무엇보다 전체 앱을 구동하므로 실패에 영향을 줄 수 있는 모듈의 범위가 매우 넓어 원인을 찾는 데 오래 걸립니다.

테스트 더블 다섯 가지

마지막으로 용어 정리입니다. 실제 구현체를 대신 세우는 대역들을 통틀어 테스트 더블이라 부르고, 얼마나 진짜에 가까운지에 따라 이름이 다릅니다.

  • 더미(Dummy): 자리만 채웁니다. 모듈이 필요하긴 하지만 실제 구현이나 실행까지는 필요 없을 때 씁니다.
  • 스텁(Stub): 호출되면 정해진 값을 반환합니다. 정해 둔 경우 외에는 대응하지 못합니다.
  • 스파이(Spy): 스텁을 고도화한 형태로, 호출 정보(횟수, 인자)까지 기록합니다.
  • 목(Mock): 실제 모듈과 유사하게 행동하는 모의 객체. 반환값이 아니라 사양대로 동작했는지 행동을 기준으로 검증합니다.
  • 페이크(Fake): 단순화된 테스트 전용 구현체. 동작은 하지만 프로덕션에 쓰면 안 됩니다.

이들의 값어치는 셋입니다. 실제 구현체를 쓸 수 없을 때 대신 검증할 수 있다는 것, 테스트 코드를 외부 의존성에서 떼어낼 수 있다는 것, 그리고 예외 상황을 손쉽게 재현할 수 있다는 것입니다.

이 중 실무에 실제로 쓴 것

강의를 들은 지 1년 반이 지났습니다. 정리 노트를 다시 읽으면 개념은 다 맞는 말인데, 그중 실제로 제 코드에 남은 것은 일부입니다. 정직하게 나눠 보겠습니다.

살아남은 것: Playwright E2E

가장 확실하게 남은 것은 E2E라는 계층 자체입니다. 다만 도구는 강의와 달랐습니다.

사이드 프로젝트 TripTune(Next.js)에서는 강의를 따라 Cypress로 E2E를 시작했습니다. 그런데 지금 그 프로젝트에 Cypress는 없습니다. 걷어내고 Playwright로, 그것도 앱 저장소가 아니라 별도 프로젝트로 분리해서 옮겼습니다. 이 이관 과정과 함께 죽어 있던 Jest 단위 테스트를 2026년 7월에 복구한 이야기는 2년 묵은 사이드 프로젝트 되살리기에 따로 정리했습니다.

실무에서는 SDMS에서 Playwright를 썼습니다. 기능목록 엑셀과 실제 라우팅을 대조하는 메뉴 136종 QA 스윕을 돌렸고, 그 과정은 Playwright 메뉴 136종 QA 스윕에 있습니다. 여기서 배운 것은 강의 노트에 없던 쪽입니다. E2E의 실전 가치는 "사용자 시나리오를 완벽하게 재현한다"는 이상보다, 사람이 손으로 하면 며칠 걸릴 반복 확인을 기계에 넘긴다는 지점에 있었습니다. 136개 메뉴를 손으로 눌러 보는 일과 스크립트로 훑는 일의 차이는 정성적이지 않고 그냥 양의 문제입니다.

강의 개념 중 실제로 쓰인 것을 꼽자면 이렇습니다. 셀렉터로 요소를 잡고 단언을 거는 기본 구조, 조건이 만족될 때까지 자동으로 기다리는 재시도 개념(Playwright의 auto-waiting은 이름만 다를 뿐 같은 문제를 푸는 장치입니다), 그리고 "핵심 경로는 모킹하지 않는다"는 원칙. 이 셋은 도구를 바꿔도 그대로 따라왔습니다.

못 쓴 것과 이유

스냅샷 테스트. 한 번도 실무에 넣지 않았습니다. 이유는 강의가 스스로 지적한 한계 그대로입니다. 업데이트가 너무 쉬워서 실패를 진지하게 읽지 않게 되고, 실제 렌더링을 하지 않으니 정작 UI가 깨졌는지는 모릅니다. 도입해서 얻을 신뢰도가 "테스트가 있다"는 착시의 비용보다 크다고 판단되지 않았습니다.

스토리북과 크로마틱 기반 시각적 회귀. 개념적으로 가장 매력적이었지만 가장 멀리 있는 항목입니다. 스토리를 유지할 인력과 크로마틱 비용, 그리고 CI 실행 시간이 전부 팀 차원의 결정이라 개인이 혼자 붙일 수 있는 물건이 아니었습니다. 다만 강의에서 얻은 부산물은 남았습니다. **"스토리를 쓰기 어려운 컴포넌트는 로직과 UI가 덜 분리된 컴포넌트"**라는 기준은 스토리북을 도입하지 않아도 컴포넌트를 쪼갤 때 쓸 수 있는 판단선입니다.

TDD 사이클. 테스트를 먼저 쓰는 방식으로 기능을 만든 적은 사실상 없습니다. TripTune의 단위 테스트도 기능이 다 만들어진 뒤에 붙였고, 그마저 한동안 방치돼 있었습니다. 강의가 "반드시 TDD를 도입할 필요는 없다"고 미리 빠져나갈 구멍을 만들어 준 것이, 결과적으로는 제 실제 작업 방식과 맞았습니다. 다만 그 구멍이 너무 편해서 한 번도 진지하게 시도해 보지 않았다는 점은 남겨 둘 만합니다.

Cypress 커스텀 커맨드와 쿼리, cy.intercept(). 노트에서 가장 많은 분량을 차지한 부분인데 지금은 통째로 쓰지 않습니다. Cypress 자체를 걷어냈기 때문입니다. 다만 여기서 배운 것이 완전히 버려진 것은 아닙니다. Parent/Child 커맨드와 prevSubject 구분은 테스트 헬퍼를 어디까지 추상화할 것인가라는 질문의 한 가지 답안이었고, "테스트 파일 안에서만 쓰는 로직은 그냥 함수로 두라"는 조언은 도구와 무관하게 유효합니다.

테스트 더블 다섯 분류. 실무에서 이 다섯 이름을 구분해서 부른 적은 없습니다. 대부분 "목"이라고 뭉뚱그렸습니다. 그래도 스텁과 목의 차이 — 반환값을 정해 주는 것과 호출 행동을 검증하는 것 — 는 테스트가 무엇을 확인하고 있는지 헷갈릴 때 되짚어 보는 기준이 됩니다.

남는 결론

정리하면 이렇습니다. 강의에서 배운 것 중 실전에 남은 것은 특정 도구의 API가 아니라 계층 구분과 판단 기준이었습니다. 어떤 것을 단위 테스트로 잡고 어떤 것을 E2E로 올릴지, 어디까지 모킹할지, 느린 테스트를 언제 돌릴지 같은 것들입니다. 반대로 노트에서 가장 두꺼웠던 부분(Cypress 커스텀 커맨드 카탈로그, 시각적 회귀 도구 비교표)은 도구를 바꾸는 순간 대부분 휘발됐습니다. 강의를 들을 때 열심히 받아 적은 분량과 나중에 쓸모 있던 분량은 반비례에 가까웠습니다.

정리 방식에 대한 교훈도 하나 있습니다. 원본 노트의 절 번호가 4.5, 5.3, 5.5, 5.6에서 끊겨 있습니다. 그때 필기를 놓친 자리인데, 놓친 내용이 무엇이었는지 지금은 알 방법이 없습니다. 빠짐없이 적거나, 최소한 빠뜨렸다는 사실이라도 적어 두는 편이 낫습니다.

수료증