- 발행일
실무에 바로 적용하는 프런트엔드 테스트 1부 — 강의 정리와 실무 결산
프런트엔드 테스트 강의를 수강하며 정리한 노트입니다. 처음 작성했을 때는 챕터를 순서대로 옮겨 적은 1700줄짜리 필기였는데, 시간이 지나 다시 읽어보니 정작 필요한 건 API 목록이 아니라 "무엇을 왜 테스트하는가"에 대한 판단 기준이었습니다. 그래서 주제별 핵심만 남기고, 마지막에 이 중 실제로 제 프로젝트에서 쓴 것과 쓰지 못한 것을 결산하는 절을 새로 붙였습니다.
테스트가 코드에 미치는 영향
테스트는 품질과 안정성을 높이기 위해 사전에 결함을 찾아내는 행위입니다. 여기까지는 누구나 아는 정의고, 강의에서 인상 깊었던 건 테스트 코드가 프로덕션 코드의 설계를 압박한다는 지점이었습니다.
테스트하기 어려운 코드는 대개 결합도가 높습니다. 어떤 모듈이 다른 모듈에 깊게 의존하고 있으면 한 곳을 고칠 때 다른 곳이 깨지고, 특정 기능만 떼어내서 검증하기가 어렵습니다. 반대로 말하면 "이 함수를 테스트하려니 준비해야 할 게 너무 많다"는 감각은 설계가 잘못됐다는 신호입니다. 테스트를 쓰다가 불편함을 느끼면 테스트를 탓하기 전에 대상 코드를 의심하는 게 맞습니다.
나머지 두 효과는 리팩터링의 안전망이 되어준다는 것, 그리고 잘 쓴 테스트가 그 자체로 동작 명세 문서가 된다는 것입니다. 다만 이 두 가지는 테스트가 살아 있을 때만 성립합니다. 이 조건이 얼마나 잘 깨지는지는 뒤에서 다시 이야기하겠습니다.
인터페이스를 기준으로 테스트한다
강의 전체를 관통하는 원칙 하나를 꼽으라면 이것입니다. 내부 구현이 아니라 인터페이스, 즉 외부에 드러나는 동작을 기준으로 검증하라는 것입니다.
내부 상태를 직접 들여다보는 테스트는 세 가지 방식으로 무너집니다. 첫째, 상태를 테스트 코드에서 일일이 세팅해야 해서 무엇을 왜 검증하는지 드러나지 않습니다. 둘째, 변수명 하나만 바뀌어도 테스트를 전면 수정해야 합니다. 셋째, 애초에 캡슐화를 깨뜨리는 행위입니다. 결과적으로 "리팩터링을 안전하게 하려고 쓴 테스트가 리팩터링을 막는" 역설이 생깁니다.
이 원칙은 뒤에 나오는 Testing Library의 쿼리 우선순위와 정확히 같은 이야기입니다. 사용자가 화면에서 인식하는 방식(역할, 라벨, 텍스트)으로 요소를 찾으라는 것도 결국 인터페이스 기준 검증이니까요.
단위 테스트: 무엇을, 어떻게
단위 테스트는 테스트 가능한 가장 작은 단위(함수, 클래스, 컴포넌트 하나)를 실행해 예상대로 동작하는지 확인합니다. 검증 대상은 결과값, 상태, 행위 세 가지로 나뉩니다.
작성 패턴은 Arrange-Act-Assert입니다. 환경을 준비하고(Arrange), 대상 동작을 실행하고(Act), 예상과 실제를 비교합니다(Assert). 이 세 구간을 눈으로 구분할 수 있게 쓰는 것만으로도 테스트 가독성이 크게 올라갑니다.
더 중요한 건 무엇을 단위 테스트할지 고르는 기준입니다. 강의에서 제시한 기준은 이렇습니다.
- 상태나 로직 없이 UI만 그리는 컴포넌트는 단위 테스트하지 않습니다. 이런 건 스토리북 같은 도구로 검증하는 편이 낫습니다.
- 간단한 로직만 처리하는 컴포넌트는 상위 컴포넌트의 통합 테스트에서 함께 검증합니다.
- 공통 유틸 함수는 단위 테스트로 검증합니다. 다른 모듈에 대한 의존이 없어 테스트하기 쉽고, 여러 곳에서 쓰이므로 깨졌을 때 파급이 큽니다.
커버리지 숫자를 올리는 게 목적이 아니라면, 이 세 줄이 강의 전체에서 가장 실용적인 부분입니다. 실제로 나중에 방치된 테스트 스위트를 정리할 때 무엇을 살리고 무엇을 버릴지 판단하는 기준으로 그대로 썼습니다.
테스트 환경: Vitest와 생명주기
강의는 Vitest를 기본 러너로 씁니다. Vite의 설정과 플러그인을 테스트 환경에서 그대로 재사용할 수 있고, ESM과 TypeScript를 별도 설정 없이 지원하며, Jest 호환 API를 제공한다는 점이 채택 이유입니다. 개발 중에는 워치 모드로, CI에서는 vitest run으로 전체를 실행하는 게 기본 흐름입니다.
여기서 지켜야 할 대원칙은 모든 테스트는 독립적으로 실행되어야 한다는 것입니다. 이를 위해 생명주기 훅이 있습니다.
import { beforeEach, afterEach } from 'vitest'
beforeEach(async () => {
await prepareSomething()
// 정리 함수를 반환하면 각 테스트 후 자동 실행됩니다
return async () => {
await resetSomething()
}
})
afterEach(async () => {
await clearTestingData()
})
beforeEach / afterEach는 각 테스트마다, beforeAll / afterAll은 컨텍스트당 한 번 실행됩니다. 컨텍스트는 파일 전체이거나 describe 블록 내부입니다. 훅에서 정리 함수를 반환하는 형태를 알아두면 준비와 정리를 한곳에 붙여둘 수 있어 훨씬 읽기 좋습니다.
테스트 간 오염은 대부분 이 훅을 빼먹은 데서 생깁니다. 모킹을 초기화하지 않아 이전 테스트의 호출 기록이 남거나, 가짜 타이머를 복원하지 않아 뒤의 테스트가 영문 모르게 멈추는 식입니다.
Testing Library: 사용자처럼 찾는다
Testing Library의 쿼리는 세 계열로 나뉩니다. getBy*는 못 찾으면 에러를 던지고, queryBy*는 null을 반환하며, findBy*는 Promise를 반환하면서 요소가 나타날 때까지 재시도합니다. "없어야 정상"인 상황을 검증할 때만 queryBy*를 쓰고, 비동기로 나타나는 요소에는 findBy*를 쓰는 게 기본입니다.
쿼리 선택 우선순위는 이 라이브러리의 철학 그 자체입니다.
- 접근성 트리 기반:
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - HTML5/ARIA 속성 기반:
getByAltText,getByTitle - 최후의 수단:
getByTestId
getByRole을 맨 위에 두는 이유는 스크린 리더 사용자가 화면을 인식하는 방식과 같기 때문입니다. data-testid를 남발하면 테스트는 쉽게 통과하지만, 그 테스트는 "사용자가 이 버튼을 찾을 수 있는가"에 대해 아무것도 말해주지 않습니다. 접근성 속성이 없어서 getByRole로 못 찾겠다면, 그건 테스트 문제가 아니라 마크업 문제일 가능성이 큽니다.
import { render, screen } from '@testing-library/react'
test('로그인 폼', () => {
render(<Login />)
const input = screen.getByLabelText('Username')
// ...
})
fireEvent 대신 userEvent
DOM 상호작용을 일으키는 방법은 두 가지입니다. fireEvent는 지정한 DOM 이벤트 하나를 그대로 발생시킵니다. userEvent는 실제 사용자 동작을 모방해 연쇄적으로 이벤트를 발생시킵니다.
차이가 드러나는 지점은 엣지 케이스입니다. fireEvent는 비활성화된 버튼에도 클릭 이벤트를 강제로 꽂을 수 있고, 입력 불가능한 필드에도 값을 넣을 수 있습니다. 테스트는 통과하지만 실제 사용자는 절대 만들 수 없는 상황입니다. userEvent는 비활성화된 버튼을 클릭하지 않고, 읽기 전용 필드에 입력하지 않습니다.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('입력 필드에 텍스트 입력', async () => {
render(<input placeholder="Type something" />)
const input = screen.getByPlaceholderText('Type something')
await userEvent.type(input, 'Hello World!')
expect(input).toHaveValue('Hello World!')
})
결론은 간단합니다. 기본은 userEvent, userEvent가 지원하지 않는 동작이 필요할 때만 fireEvent입니다. userEvent는 비동기이므로 await를 빠뜨리지 않도록 주의해야 합니다.
모킹: 필요한 만큼만
모킹은 실제 모듈과 같은 인터페이스를 가진 가짜를 만들어 테스트에서 대체하는 것입니다. 목적은 외부 의존성을 걷어내고 테스트 대상에만 집중하는 것입니다.
import { vi } from 'vitest'
import { fetchData } from './api'
vi.mock('./api', () => ({
fetchData: vi.fn(() => Promise.resolve({ data: 'mocked data' })),
}))
afterEach(() => {
vi.clearAllMocks()
})
test('API 호출', async () => {
const response = await fetchData()
expect(fetchData).toHaveBeenCalled()
expect(response).toEqual({ data: 'mocked data' })
})
초기화 메서드 세 가지의 차이는 꼭 구분해야 합니다. clearAllMocks는 호출 기록만 지우고 구현은 유지합니다. resetAllMocks는 호출 기록을 지우고 구현을 빈 함수로 되돌립니다. restoreAllMocks는 호출 기록을 지우고 spyOn으로 덮은 원래 구현을 복원합니다. spyOn을 썼다면 restoreAllMocks가 정답이고, 나머지 두 개는 원본을 되살려주지 않습니다.
환경 변수는 vi.stubEnv, 전역 값은 vi.stubGlobal로 대체하고 각각 vi.unstubAllEnvs, vi.unstubAllGlobals로 되돌립니다.
주의할 점도 명확합니다. 모킹한 모듈 자체의 동작은 어딘가에서 따로 검증되어야 하고, 모킹에 과하게 의존하면 "테스트는 전부 초록불인데 실제로는 안 돌아가는" 상태에 도달합니다. 모킹은 실제 환경과의 차이를 만들어내는 행위라는 걸 잊지 않는 게 좋습니다.
타이머 테스트
setTimeout, setInterval, Date가 얽힌 코드는 가짜 타이머로 시간을 직접 조작해 검증합니다. 실제로 기다리지 않으니 빠르고, 시스템 시간에 영향받지 않으니 안정적입니다.
import { vi } from 'vitest'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test('setTimeout이 지정 시간 후 실행된다', () => {
const callback = vi.fn()
setTimeout(callback, 2000)
vi.advanceTimersByTime(1000)
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalled()
})
vi.setSystemTime()으로 현재 시각 자체를 고정할 수도 있습니다. 중요한 건 afterEach에서 useRealTimers()로 반드시 복원하는 것입니다. 이걸 빠뜨리면 다음 테스트에서 await가 영원히 안 풀리는, 원인을 찾기 아주 짜증나는 실패가 발생합니다.
단위 테스트의 한계와 통합 테스트
단위 테스트는 모듈 간 상호작용에서 생기는 문제를 잡지 못합니다. 각 조각은 완벽하게 동작하는데 조립하면 안 되는 상황이 실제로 가장 흔한 버그 유형입니다. 게다가 의존성을 모킹으로 걷어냈으니 실제 의존성과의 계약이 어긋난 경우도 놓칩니다. 사용자 경험은 애초에 검증 범위 밖입니다.
통합 테스트는 이 간극을 메웁니다. 실제 환경에 가까운 설정으로 여러 모듈을 연결해 협력을 검증합니다. 대신 느리고, 설정이 복잡하고, 실패했을 때 원인 특정이 어렵습니다.
| 항목 | 단위 테스트 | 통합 테스트 |
|---|---|---|
| 범위 | 개별 함수·모듈 | 모듈 간 상호작용 |
| 의존성 | 주로 모킹 | 실제 의존성 또는 테스트 데이터 |
| 속도 | 빠름 | 느림 |
| 문제 발견 | 개별 모듈 결함 | 통합·의존성 결함 |
강의의 결론은 테스트 피라미드입니다. 단위 테스트를 가장 많이, 통합 테스트를 중간 규모로, E2E를 소수로 두라는 것입니다. 이 권고를 제가 실제로 지켰는지는 마지막 절에서 이야기하겠습니다.
통합 테스트 환경 구성
통합 테스트의 절반은 환경 설정입니다. jsdom 위에 React Testing Library를 얹고, 상태 관리와 네트워크를 각각 다뤄야 합니다.
Zustand처럼 모듈 스코프에 스토어를 만드는 라이브러리는 테스트 간에 상태가 그대로 남습니다. 그래서 __mocks__/zustand.ts에서 create를 감싸 초기 상태 복원 함수를 모아두고, afterEach에서 일괄 리셋하는 패턴을 씁니다.
import { act } from '@testing-library/react'
import { vi } from 'vitest'
import type * as ZustandExportedTypes from 'zustand'
export * from 'zustand'
const { create: actualCreate } = await vi.importActual<typeof ZustandExportedTypes>('zustand')
const storeResetFns = new Set<() => void>()
export const create = <T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getState()
storeResetFns.add(() => store.setState(initialState, true))
return store
}
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => resetFn())
})
})
네트워크는 MSW로 가로챕니다. 애플리케이션 코드를 전혀 건드리지 않고 요청 단계에서 응답을 바꿔치기할 수 있다는 게 핵심 장점입니다. 서버는 beforeAll에서 열고, afterEach에서 핸들러를 리셋하고, afterAll에서 닫습니다.
TanStack Query를 쓴다면 테스트 전용 클라이언트를 따로 만들어야 합니다. 기본값인 재시도가 켜져 있으면 실패 케이스 테스트가 불필요하게 오래 걸리고 타임아웃과 얽힙니다.
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
export const renderWithQueryClient = (ui) => {
const queryClient = createTestQueryClient()
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
비동기 검증은 findBy*(요소가 나타날 때까지 대기), waitFor(조건이 충족될 때까지 폴링), waitForElementToBeRemoved(요소가 사라질 때까지 대기) 세 가지로 커버됩니다. 임의의 setTimeout으로 기다리는 코드는 전부 이 셋 중 하나로 대체할 수 있습니다.
강의 자료에서 걸린 것들
정리를 다시 읽으면서 그대로 옮겨두면 안 되겠다 싶은 부분이 몇 군데 있었습니다.
MSW 예제가 v1 문법입니다. 원본 필기에는 rest.get('/api/data', (req, res, ctx) => res(ctx.status(200), ctx.json(...))) 형태로 적혀 있는데, MSW는 2.x에서 http.get과 HttpResponse.json을 쓰는 표준 Fetch API 기반으로 완전히 갈아엎었습니다. 지금 이 코드를 그대로 붙여넣으면 rest가 없다는 에러부터 만납니다. 그래서 이번 정리에서는 MSW 코드 예제 자체를 뺐습니다.
react-dom/test-utils 챕터는 통째로 유효 기간이 지났습니다. ReactTestUtils.Simulate, findRenderedDOMComponentWithClass, scryRenderedComponentsWithType 같은 API는 React 19에서 제거됐고, act도 react-dom/test-utils가 아니라 react에서 가져와야 합니다. 게다가 강의 안에서도 "React Testing Library를 쓰면 이런 보일러플레이트가 필요 없다"고 스스로 말합니다. 필요 없다고 말한 API를 한 챕터 분량으로 정리해둔 셈이라, 이번엔 들어내는 게 맞다고 판단했습니다.
러너가 중간에 바뀝니다. 앞부분은 Vitest를 전제로 vi.fn, vi.mock을 설명해놓고, Zustand 모킹 챕터에 가면 jest.requireActual과 jest.config.ts가 등장합니다. 두 러너의 설정을 나란히 보여주려던 의도는 알겠지만, 한 문서 안에서 예고 없이 섞이면 그대로 복사한 사람이 반드시 헤맵니다. 위 예제는 Vitest 쪽으로 통일했고, 원본 Vitest 버전에 빠져 있던 vi import도 채워 넣었습니다.
비동기 예제가 타이밍 경계에 걸려 있습니다. 원본의 AsyncComponent는 setTimeout 1000ms 후에 데이터를 반환하는데, findBy*의 기본 타임아웃도 1000ms입니다. 두 값이 정확히 같으면 통과와 실패 사이를 오가는 전형적인 flaky 테스트가 됩니다. 예제라면 지연을 짧게 잡거나 타임아웃을 명시적으로 늘려야 합니다.
옵션 표기 오타. -pool=threads가 아니라 --pool=threads입니다. 사소하지만 그대로 실행하면 안 먹습니다.
이 중 실무에 실제로 쓴 것
강의를 들은 지 시간이 꽤 지났으니, 배운 것 중 무엇이 살아남았는지 결산할 때가 됐습니다. 좋은 이야기만 쓰면 의미가 없으니 못 지킨 쪽도 같이 적습니다.
살아남은 것 1: 테스트 대상 선정 기준
사이드 프로젝트 TripTune(Next.js)에는 Jest 단위 테스트가 있었습니다. 정확히는 있었는데 죽어 있었습니다. 한동안 손을 놓은 사이 테스트가 통째로 깨진 채 방치돼 있었고, 2026년 7월에 프로젝트를 되살리면서 함께 복구했습니다. 그 과정은 2년 묵은 사이드 프로젝트 되살리기에 따로 적었습니다.
이때 가장 요긴했던 게 위의 "무엇을 단위 테스트할지" 기준이었습니다. 깨진 테스트를 전부 고치려 들면 끝이 없으니 선별이 필요했는데, 로직 없이 UI만 그리는 컴포넌트를 붙잡고 있던 테스트는 고치지 않고 정리하고, 공통 유틸 쪽은 살려서 되살렸습니다. "다 고친다"가 아니라 "고칠 가치가 있는 것만 고친다"는 판단을 내리는 데 강의의 세 줄짜리 기준이 그대로 쓰였습니다.
테스트 독립성 원칙도 여기서 체감했습니다. 오래 방치된 스위트는 대개 서로 오염돼 있어서, 개별로 돌리면 통과하는데 전체로 돌리면 깨지거나 그 반대인 상태가 됩니다. setup/teardown을 다시 정리하는 게 개별 단언을 고치는 것보다 먼저였습니다.
살아남은 것 2: 사용자 관점 쿼리 철학
E2E는 원래 Cypress를 썼는데, 결국 걷어내고 Playwright로 옮겼습니다(별도 프로젝트로 분리). 그리고 실무에서는 Playwright로 메뉴 136종을 훑는 QA 스윕을 돌렸습니다. 이 작업은 Playwright 메뉴 136종 QA 스윕에 정리해뒀습니다.
여기서 Testing Library의 쿼리 우선순위 철학이 그대로 이어졌습니다. Playwright의 로케이터도 역할과 접근성 이름으로 요소를 찾는 방식을 1순위로 두고, CSS 선택자나 테스트 전용 속성은 아래에 둡니다. 도구 이름은 달라졌지만 "사용자가 인식하는 방식으로 요소를 지목하라"는 원칙은 동일합니다. 강의에서 배운 게 특정 라이브러리 API가 아니라 이 원칙이었다는 게 나중에 드러난 셈입니다. 앞서 나온 "인터페이스를 기준으로 테스트하라"와도 같은 이야기고요.
유지하지 못한 것: 촘촘한 단위 테스트 문화
강의는 테스트 피라미드를 권합니다. 단위 테스트를 가장 많이 쓰고, 통합 테스트를 중간에, E2E를 소수로. 제 실제 기록은 정확히 그 반대 모양입니다. 가장 많은 시간을 쓴 건 Playwright 스윕이었고, 단위 테스트는 방치됐다가 나중에 복구 대상이 됐습니다.
왜 그랬는지 생각해보면 이유가 있습니다. 혼자 하는 사이드 프로젝트에서 테스트가 깨져도 아무도 알려주지 않습니다. CI가 붉게 뜨는 걸 보고 넘어가는 날이 며칠 쌓이면 그때부터는 그냥 배경 소음이 되고, 그 상태로 몇 달이 지나면 되살리는 비용이 처음 작성한 비용보다 커집니다. 테스트가 "리팩터링의 안전망이자 살아 있는 문서"라는 강의의 주장은 맞지만, 그 앞에 "관리되는 동안에는"이라는 전제가 생략돼 있습니다. 관리되지 않는 테스트는 문서도 안전망도 아니고 그냥 부채입니다.
실무 쪽에서는 사정이 조금 다릅니다. 화면 수가 많은 관리 시스템에서 먼저 필요했던 건 "이 함수가 정확한가"가 아니라 "이 화면이 열리기는 하는가"였습니다. 메뉴 136종을 훑는 작업에서 단위 테스트가 대신해줄 수 있는 일은 거의 없습니다. 투자 대비 회수가 가장 빨랐던 게 E2E 스윕이었고, 그래서 피라미드가 뒤집혔습니다.
그리고 강의에서 꽤 많은 분량을 차지한 모킹 계층 — MSW 핸들러, Zustand 스토어 리셋, TanStack Query 테스트 클라이언트 — 은 제 경우 결국 본격적으로 쓰지 못했습니다. 이 설정들은 통합 테스트를 촘촘히 쌓아 올릴 때 값어치가 생기는데, 저는 그 단계까지 가기 전에 실제 화면을 실제로 열어보는 쪽으로 방향을 틀었기 때문입니다. 배운 게 쓸모없었다는 뜻은 아니고, 도구를 배우는 것과 그 도구가 필요한 상황을 만드는 것은 별개라는 뜻입니다.
결산
정리하면 이렇습니다. 살아남은 건 판단 기준(무엇을 테스트할지)과 철학(사용자 관점으로 검증하라)이고, 흐릿해진 건 구체적인 API와 설정 레시피입니다. MSW가 v2로 바뀌고 react-dom/test-utils가 사라지는 동안에도 앞의 두 가지는 그대로 유효했습니다. 강의 필기를 1700줄에서 이 정도로 줄이면서 남긴 게 결국 그 부분입니다.
E2E와 시각적 회귀 테스트를 다룬 2부 정리도 함께 있습니다.
