좋은 테스트는 어디에 두나
좋은 테스트를 손에 쥐고 나니 다음 질문이 왔다 — 그래서 그걸 어디에 두나? 단위·통합·e2e, 그리고 프론트엔드.
좋은 테스트를 손에 쥐고 나니 다음 질문이 왔다 — 그래서 그걸 어디에 두나? 단위·통합·e2e, 그리고 프론트엔드. 레벨이라는 두 번째 칼날의 기록.
이 글은 “좋은 테스트란 무엇인가” 시리즈의 2편(어디에 두나)이다.
들어가며 — 좋은 테스트를 손에 쥐고 나면
1편에서 “무엇이 좋은 테스트인가”에 답했다. 좋은 테스트는 구현이 아니라 동작을 검증한다. 동작/구현의 판정 기준은 클라이언트(“이 코드를 호출해서 쓰는 쪽”)가 의존하느냐였다.
그 칼날을 손에 쥐고 나니 곧바로 다음 질문이 왔다. 그 좋은 테스트를 어느 레벨에 두나? 단위? 통합? e2e? 같은 동작을 세 군데서 다 검증하면 낭비 같은데, 그럼 무엇을 어디에 둬야 하나?
그리고 한 가지가 더 걸렸다. 1편은 전부 백엔드 예제(OrderService)로 썼다. 나는 프론트엔드를 주로 짠다. 이 프레임 — 클라이언트, managed/unmanaged, 상태 검증 vs 메시지 검증 — 이 프론트엔드에 그대로 적용될까?
이 글은 그 적용의 기록이다. 결론부터 말하면 대부분 그대로 맞다. 딱 한 곳이 어긋나는데, 그 어긋남에서 프론트엔드 테스트의 본질이 드러난다. 그리고 글의 끝은 “그래서 내 코드엔 테스트를 어디에 둘까”의 결정 기준으로, 그리고 많은 팀이 겪는 “회귀 테스트의 무덤” 문제로 수렴한다.
1. 레벨이란 무엇인가 — 그리고 “통합”이라는 말의 함정
테스트는 흔히 단위 · 통합 · e2e 세 레벨로 나눈다. 각 레벨은 검증하려는 영역이 다르다 — 단위는 단일 동작의 로직, 통합은 단위들이 맞물리는 지점, e2e는 사용자 시점의 전체 경로.
그런데 가장 먼저 깔아야 할 게 있다. “통합(integration)“이라는 단어가 두 커뮤니티에서 서로 다른 축을 가리킨다. 이걸 구분하지 않으면 “MSW로 모킹하면 실제 서버를 안 쓰는데 그럼 통합이 아닌가?” 같은 혼란에 빠지기 쉽다.
| Khorikov / 백엔드 전통 | Kent C. Dodds 트로피 / 프론트 전통 | |
|---|---|---|
| 가르는 축 | 프로세스 경계 | 사회성(몇 개 단위가 함께 도나) |
| unit | out-of-process를 real로 안 건드림 | 한 컴포넌트/함수를 격리 |
| integration | real DB·network를 하나라도 건드림 | 여러 컴포넌트+provider가 함께 맞물림 (네트워크는 MSW로 mock해도 integration) |
그래서 “MSW로 mock하면 통합인가?”의 답은 어느 축으로 부르냐에 달렸다:
- 프로세스 축: real network를 안 타니까 → 엄밀히는 unit(Google 용어로 small test)
- 사회성 축: 여러 컴포넌트+provider를 사용자처럼 함께 렌더하니까 → integration이 맞다
라벨 싸움은 본질이 아니다. Google은 “unit”이란 말이 너무 오염돼서 small(단일 프로세스) / medium(단일 머신·DB) / large(여러 머신)로 다시 나눈다 — 리소스 경계 기준이다(SE at Google Ch.11). 라벨에 매달리지 말고 “이 테스트에서 무엇이 real이고 무엇이 경계에서 mock되나”를 명시하라. 그러면 레벨은 자동으로 정리된다.
2. 단위 vs 통합을 가르는 것 = 검증 대상 (입력 방식이 아니다)
나도 처음엔 이렇게 생각했다. 입력을 props로 주면 단위, MSW로 주면 통합이라고. 그런데 따져보니 그게 아니었다. 입력 주입 방식은 레벨을 가르는 원인이라기보다 결과에 가깝다. 실제로 가르는 건 검증 대상이라는 게 내가 도달한 결론이다.
| 단위 (컴포넌트 단위 포함) | 통합 | |
|---|---|---|
| 검증 대상 | 단일 단위의 내부 로직·분기 | 여러 단위 사이의 배선·계약 |
| 협력 객체는 | 동작을 성립시키는 배경 | 그 협력 자체가 검증 대상 |
| 잡는 결함 | ”이 단위가 입력→출력을 맞게 만드나" | "조각들이 합쳐질 때만 드러나는 결함” |
통합 테스트의 고유 가치 = “단위 테스트에서 stub으로 가정한 계약이 실제와 맞는지” 검증하는 것.
단위 테스트에서 협력 객체를 stub하면, 그 stub은 내가 가정한 형태로 응답한다. 그래서 단위 테스트는 구조적 맹점이 있다. stub이 거짓말을 해도 모른다.
// 컴포넌트는 user.name 을 기대한다
function Profile({ user }) { return <h1>{user.name}</h1> }
// 단위 테스트 — stub 을 내가 가정한 형태로 짜놓는다
render(<Profile user={{ name: 'Lee' }} />)
expect(screen.getByRole('heading')).toHaveTextContent('Lee') // ✅ 영원히 green
// 그런데 실제 API 는 { fullName: 'Lee' } 를 준다면?
// → 프로덕션에선 user.name === undefined → 화면이 깨진다
// → 하지만 단위 테스트로는 이를 잡지 못한다. stub 이 거짓말을 했으니까.
통합 테스트가 메우는 게 정확히 이 구멍이다 — “내가 가정한 계약이 진짜인가.” 그래서 props/MSW의 진짜 의미는: props 주입은 계약을 테스트 밖으로 밀어내(렌더 로직만 검증 = 단위), MSW는 네트워크→컴포넌트 데이터 흐름을 테스트 안에 포함한다(배선까지 검증 = 통합). 검증 대상이 결정하고, 입력 방식이 따라온다.
3. 통합도 “결과”를 본다 — 호출이 아니라
“통합 = 계약 검증”이라고 하면 “내 단위가 협력 객체를 올바른 순서/형태로 호출하는지”를 검증하는 것처럼 들린다. 그건 1편에서 지적한 mockist interaction 검증, 곧 구현 검증이다.
// ❌ 호출 검증 (mockist = 구현) — 리팩토링에 깨진다
expect(repo.save).toHaveBeenCalledWith(order)
// ✅ 결과 검증 (classicist = 동작) — 협력 객체를 real 로 바꿔놓고, 그래도 결과가 맞나
await repo.save(order)
expect(await repo.findById(order.id)).toEqual(order)
통합 테스트도 검증 방식은 여전히 1편 원칙(상태/결과 검증)을 따른다. 달라지는 건 “협력 객체가 stub이 아니라 real out-of-process”라는 점뿐이다. “통합이니까 호출을 검증한다”가 아니라, “통합 = 협력 객체를 real로 바꿔놓고, 그래도 결과가 맞나”.
정리하면:
- 단위 테스트 = 그 단위 혼자의 로직/분기가 맞나 (협력은 stub으로 가정)
- 통합 테스트 = 그 단위가 real 협력 객체와 함께 맞물려 돌 때, 단위에서 가정했던 계약이 성립해서 관찰 가능한 결과가 올바르게 나오나 (호출이 아니라 결과를 본다)
4. 1편 프레임의 프론트엔드 매핑 — 그리고 어긋나는 한 곳
이제 본론. 1편은 동작 vs 구현을 백엔드(OrderService)로 전개했다. 그 똑같은 프레임이 프론트엔드에 거의 그대로 매핑된다. 거의. 한 칸이 어긋나는데, 거기서 프론트의 본질이 드러난다.
클라이언트는 프론트에서 두 겹이다
| SUT | 클라이언트 | 동작 | 구현 |
|---|---|---|---|
| 컴포넌트 | 사람 사용자 | 화면에 보이는 것·상호작용 결과 | useState 변수·훅 호출·CSS 클래스 |
| 훅/스토어/유틸 | 그걸 부르는 컴포넌트 | 반환값·노출 API | 내부 useState 개수·useEffect 구조 |
RTL 슬로건 “the more your tests resemble the way your software is used, the more confidence they can give you”에서 ‘used’의 주체가 이 둘이다 — 컴포넌트의 사용자는 사람, 훅의 사용자는 소비 컴포넌트.
의존성 매핑 — 그리고 갈라야 하는 칸
1편 백엔드 3종(들어오는 입력=stub / 나가는 managed=상태검증 / 나가는 unmanaged=메시지검증)을 프론트에 매핑하되, “나가는 unmanaged”를 둘로 쪼개야 한다. 이게 이 글의 핵심이다.
| 1편 (백엔드) | 프론트 대응 | 더블 | 검증 |
|---|---|---|---|
| 들어오는 입력 | GET 서버 데이터, props, Date.now | MSW stub / props 주입 | 검증 안 함 — 입력만 공급 |
| 나가는 managed | 컴포넌트 렌더 결과 = 화면, URL | real 렌더 | output 검증 (getByText/getByRole/toHaveURL) |
| 나가는 unmanaged ① | 백엔드 mutation (POST — 결과가 화면에 보임) | MSW (핸들러서 상태 변경) | 화면 결과 (나간 body ❌) |
| 나가는 unmanaged ② | analytics·3rd-party SDK (화면 흔적 없음) | spy / MSW life-cycle | 나간 메시지(body) |
★ 왜 “나가는 unmanaged”가 갈리나 — 백엔드 프레임이 프론트에서 어긋나는 지점.
1편에서 백엔드 unmanaged(이메일)는 “나간 메시지(what)“를 검증하는 게 맞았다. 이메일은 화면에 흔적이 없으니까 — 보낸 내용을 직접 보는 수밖에 없다.
그런데 프론트의 “나가는 mutation(POST)“은 백엔드라는 unmanaged로 나가지만, 대부분 결과가 화면으로 되돌아온다(멤버가 목록에서 사라짐, “성공” 토스트). 그래서 나간 body가 아니라 화면 결과로 검증한다. 나간 요청 body를 직접
expect하면 — MSW 공식이 못박듯 — “implementation detail testing”이 된다.1편의 “unmanaged = 메시지 검증”이 프론트에서 그대로 들어맞는 건 ②(화면 흔적 없는 analytics)뿐이다. 백엔드 프레임을 무비판적으로 옮기면 ①에서 어긋난다 — “프론트는 나가는 효과의 대부분이 화면으로 돌아온다”는 게 백엔드와의 결정적 차이다.
용어 하나: “화면 = 상태 검증”이라 부르기 쉽지만, RTL 철학은 “내부 state가 아니라 사용자가 보는 것을 검증하라”이므로 ‘state’라는 단어를 의도적으로 피한다. 정확히는 “화면 = output(사용자가 관측 가능한 것) 검증”이다.
그리고 managed의 정의는 “내가 전적으로 통제”가 1차 기준이 아니다. Khorikov 본인이 클라우드 DB 예로 정정하듯, 1차 판별자는 “외부에서 관찰되지 않고 다른 시스템과 공유되지 않느냐”다.
5. 프론트 통합의 “계약”은 어떤 것들인가
“계약”이 네트워크 통신만 뜻하는 게 아니다. 계약 = “한 조각이 다른 조각에게 의존하는 모든 seam(이음새)”. 프론트엔 다섯 종류가 있다.
| 계약 종류 | 맞물리는 두 조각 | 깨지면 |
|---|---|---|
| 네트워크 | 쿼리훅/컴포넌트 ↔ 백엔드 응답 shape | user.name 기대 / 실제 fullName |
| props | 부모 ↔ 자식 (넘기는 형태·타이밍) | 부모가 잘못된 형태로 넘김 |
| Context/Provider | provider 값 ↔ 소비 컴포넌트 | 테마/인증/i18n/queryClient 누락 |
| 스토어 | 컴포넌트 ↔ 전역 상태 | dispatch→state→재렌더 안 됨 |
| 라우터 | URL/params ↔ 컴포넌트가 읽는 값 | 네비게이션 후 렌더 전환 깨짐 |
1편의 “경계” 개념과 연결하면 — 네트워크는 앱 경계를 넘는(unmanaged) 계약이고, props·context·store·router는 앱 내부 조각들 사이의 배선이다. 통합 테스트는 둘 다 다룬다. (이 구분은 9절에서 결정적으로 작동한다.)
6. 배선 버그 — 순서가 원인, 검증은 결과로
“배선 버그는 협력 객체의 순서랑 연관 있어 보인다”는 직관은 절반만 맞다.
- 버그의 원인(내부 메커니즘) = 호출 순서 꼬임·props 잘못 넘김 → 구현
- 버그의 증상(관찰 가능한 결과) = 검증 실패한 폼이 제출됨·에러 안 뜸 → 동작
통합 테스트는 증상(결과)을 검증하고, 원인(순서)을 직접 보지 않는다. ① 순서를 직접 단언하면 1편에서 지적한 mockist 함정(구현 결합)에 빠진다. ② 순서가 틀리면 결과도 틀리게 나오므로, 결과만 봐도 순서 버그가 드러난다.
// 시나리오: 유효하지 않은 입력으로 제출 / 버그: validation 전에 submit 발사
// ❌ 순서 검증 (mockist = 구현)
expect(validateSpy).toHaveBeenCalledBefore(submitSpy)
// ✅ 결과 검증 (classicist = 동작) — 전부 화면으로
await user.type(screen.getByLabelText('이메일'), 'invalid')
await user.click(screen.getByRole('button', { name: '제출' }))
expect(await screen.findByText('유효한 이메일을 입력하세요')).toBeVisible() // 에러가 뜬다
expect(screen.queryByText('제출 완료')).not.toBeInTheDocument() // 제출 안 됐다(화면)
배선이 꼬이면 → “에러가 안 뜨고 + 성공 화면이 떴다”는 결과로 드러난다. 순서를 직접 본 적이 없는데도 잡혔다. 레벨이 올라가도 “what을 보되 how를 보지 않는다”는 1편 원칙은 유지된다.
7. 레벨별 표준 패턴 — 단위 / 통합 / e2e
실무에서 보통 어떻게 짜는지, RTL·MSW·Playwright 공식 패턴으로 한 레벨씩.
단위 — 순수 함수 / 훅
// 순수 함수 — 입력 → 출력. 협력객체 0
test('항목 가격이 합산된 총액으로 계산된다', () => {
expect(calcTotal([{ price: 100, qty: 2 }, { price: 50, qty: 1 }])).toBe(250)
})
// 훅 — 반환값(output)을 본다. 내부 useState 구조는 안 봄
import { renderHook, act } from '@testing-library/react'
test('increment 하면 count 가 1 증가한다', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1)
})
컴포넌트 — props 주입 (presentational)
들어오는 입력을 props로 끊으면 MSW조차 필요 없다. 검증은 화면 output.
test('총액을 통화 형식으로 표시한다', () => {
render(<OrderSummary total={250} />)
expect(screen.getByText('$250.00')).toBeVisible()
})
통합 — RTL + MSW
들어오는 데이터를 MSW로 stub(입력 공급, 검증 X), provider와 함께 렌더, 검증은 화면 output.
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer(
http.get('/api/orders', () => // 들어오는 입력 = stub. 검증 안 함
HttpResponse.json([{ id: '1', title: '어금니 크라운' }])),
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('주문 목록을 불러와 화면에 표시한다', async () => {
render(<OrderList />, { wrapper: AppProviders }) // provider+라우터+쿼리클라 함께
expect(await screen.findByText('어금니 크라운')).toBeVisible()
})
나가는 mutation — 나간 body가 아니라 화면 결과로
MSW 핸들러가 핸들러 안에서 상태를 바꾸고, 테스트는 그 결과를 화면으로 본다. 이게 MSW 공식 권장이자 실무 주류다.
let members = [{ id: 'a', name: 'Lee' }, { id: 'b', name: 'Kim' }]
const server = setupServer(
http.get('/api/members', () => HttpResponse.json(members)),
http.delete('/api/members/:id', ({ params }) => { // 검증은 핸들러 '안'에서
members = members.filter((m) => m.id !== params.id) // 상태 변경
return HttpResponse.json({ success: true })
}),
)
test('멤버를 삭제하면 목록에서 사라진다', async () => {
const user = userEvent.setup()
render(<MemberList />, { wrapper: AppProviders })
await user.click(await screen.findByRole('button', { name: 'Lee 삭제' }))
await waitFor(() =>
expect(screen.queryByText('Lee')).not.toBeInTheDocument()) // 화면 결과. 나간 DELETE body ❌
})
잘못된 요청을 막는 검증이 필요하면 — 나간 body를 직접 보지 말고 핸들러가 그 요청에 에러를 돌려주게 해서 화면 검증이 자연히 실패하게 한다.
// ✅ 핸들러가 잘못된 요청에 400 → UI 검증이 자연히 실패
http.post('/api/members', async ({ request }) => {
const body = await request.json()
if (!body.email) return new HttpResponse('Missing email', { status: 400 })
return HttpResponse.json({ success: true })
})
예외 — analytics는 나간 메시지를 직접 본다
화면에 흔적이 없는 one-way 요청만. MSW 공식이 인정하는 유일한 request-assertion 예외다.
const gtag = vi.fn()
vi.stubGlobal('gtag', gtag)
await user.click(screen.getByRole('button', { name: '구매' }))
expect(gtag).toHaveBeenCalledWith('event', 'purchase', { value: 100 }) // 나간 메시지 검증
e2e — Playwright
import { test, expect } from '@playwright/test'
test('주문을 생성하면 대시보드에 나타난다', async ({ page }) => {
await page.goto('/orders/new')
await page.getByLabel('상품').fill('어금니 크라운')
await page.getByRole('button', { name: '주문' }).click()
await expect(page).toHaveURL(/\/dashboard/) // URL(managed)
await expect(page.getByText('어금니 크라운')).toBeVisible() // 화면(managed)
})
세 레벨 모두 assert 대상이 화면 결과 / 반환값(output)이지 나간 요청 body가 아니다 — 4절 ①의 일관된 귀결이다.
8. 단위와 통합이 같아 보이는 이유 — 그리고 네트워크 계약의 진실
여기서 정직한 고백을 해야 한다. 위 7절의 “컴포넌트 테스트”와 “통합 테스트”는 코드 형태가 거의 같다. 같은 RTL, 같은 MSW. 이걸 처음 마주했을 때 나도 “그럼 통합 테스트를 단위와 다르게 어떻게 짜라는 거지?”에서 막혔다.
답은 — 막히는 게 맞다. Dodds 본인도 “the distinction between unit and integration tests is blurry”라고 못박는다. 프론트에서 단위↔통합은 이분법이 아니라 mock을 덜 할수록 통합 쪽으로 가는 연속체다. 다이얼은 딱 하나: “이 협력 조각(자식 컴포넌트/provider/store/router/네트워크)을 가짜로 둘까, real로 둘까.”
// 단위 — 자식을 mock. props를 '가정'으로 고정 (= mockist, 권장 안 함)
vi.mock('./MemberRow', () => ({ MemberRow: (p) => <div>{p.name}</div> }))
// 통합 — 자식을 real 로 포함. props 계약이 화면으로 검증됨
render(<MemberList members={[{ id: 'a', name: 'Lee' }]} />) // MemberRow real
expect(screen.getByText('Lee')).toBeVisible()
props 통합 = “자식을 mock하지 마라.” 사실상 이게 핵심이다. 코드가 부모 단위 테스트와 비슷해 보이는 게 자연스럽고, 차이는 vi.mock 한 줄의 유무다. store·provider·route도 똑같다 — real <StoreProvider> / <QueryClientProvider> / <MemoryRouter>로 감싸 함께 렌더하면 자동으로 그 계약의 통합이 된다.
그런데 네트워크 계약만은 다르다
네트워크 계약 검증이 MSW stub 쓰는 단위 테스트랑 똑같아 보이는 건 — MSW stub으로는 네트워크 계약을 진짜로 검증 못 하기 때문이다. 그건 계약을 가정하는 것이다.
// MSW stub — 내가 정한 응답. 진짜 백엔드와 같다는 보장이 없다
http.get('/api/members', () => HttpResponse.json([{ name: 'Lee' }]))
// → 백엔드가 실제로 { fullName } 을 주면? 이 테스트는 모른다. (2절 stub 의 거짓말)
네트워크 계약을 양쪽으로 쪼개보면 명확해진다:
| 계약의 절반 | 무슨 뜻 | 누가 검증? |
|---|---|---|
| 소비자 측 기대 | ”우리 프론트는 이런 shape를 기대한다” | 컴포넌트 레벨 MSW가 고정 |
| 제공자 측 실재 | ”백엔드가 진짜 그 shape를 준다” | ❌ 컴포넌트 레벨은 못 봄 |
그래서 컴포넌트 레벨 테스트는 네트워크 계약의 소비자 측 절반만 본다. 이건 한계가 아니라 의도된 분업이다. 빠진 제공자 측 절반은 둘 중 하나로 메운다:
- (a) real 백엔드와 맞물림 — 프론트+백 e2e, 또는 백엔드 통합 테스트. 확실하지만 비싸다.
- (b) contract test — 실제 네트워크 없이 계약만 검증. MSW 핸들러를 손으로 적지 말고 백엔드 OpenAPI 스키마에서 생성하거나(스키마가 바뀌면 깨짐), consumer-driven contract(Pact류)로 소비자 기대를 제공자 CI에서 재생해 대조한다.
→ 그래서 “네트워크 계약을 어디서 검증하나”는 push-down으로 3단 분업이 된다:
빠진 절반을 e2e로 모두 떠넘기기보다 contract test로 저렴하게 메우는 편이 낫다고 본다.
9. e2e vs 통합 — 진짜 차이
나도 처음엔 “통합은 부품 몇 개, e2e는 부품 전부”라는 정도(개수) 차이로 봤다. 그런데 그게 아니었다. 두 독립 축으로 갈린다.
| Integration | E2E | |
|---|---|---|
| real 의존성 범위 | 일부만 real (보통 managed=DB만) | 전부 또는 거의 전부 real |
| 진입점 / 관점 | 내부 모듈 경계 / 개발자 | 최외곽 UI(브라우저) / 사용자 여정 |
Khorikov는 e2e를 integration의 부분집합으로 본다(e2e = “all or almost all” out-of-process가 real). 단 e2e가 통합의 일부인 이유는 “네트워크 계약을 검증해서”가 아니라 “out-of-process 의존성을 real로 건드려서”다 — 계약 검증은 거기서 따라오는 결과다.
결정적 판별 칼날: “브라우저·DOM·사용자 여정이 이 테스트의 존재 이유인가?” 아니면 통합으로 내려야 한다. (HTTP 클라이언트만 쓰고 브라우저를 한 번도 안 여는 e2e는 사실 API contract 테스트다.)
e2e가 “더 많이 묶어서” 가치 있는 게 아니다. Google “Just Say No to More End-to-End Tests”가 보여주듯 e2e는 느리고 flaky해서, 이상적으로 설계된 스위트조차 개발을 마비시킨다. 그래서 내가 내린 결론은 critical path만 결정적으로 유지하고 나머지는 push-down이다. (“무용론”이 아니라 “더 만들지 말라”는 것.)
10. 그래서 — 테스트를 어디에 둘까 (결정 트리)
지금까지의 모든 것(동작 검증 / 레벨 정의 / managed-unmanaged / 계약 5종 / 네트워크 계약 분업 / push-down)이 하나의 결정 절차로 수렴한다.
결정의 핵심은 두 칼날인데, 사실 같은 동전의 양면이다:
- “무엇이 깨지면 이 테스트가 fail하나?” → 결함의 거주지
- “그 결함을 재현하려면 최소 무엇을 real로 맞물려야 하나?” → 최소 real 범위
이 둘이 가리키는 레벨이 그 테스트가 살 곳이다. 그 위에 세 원칙: 하나만(가장 낮은 레벨 하나, 셋 다 X), 중복 제거(같은 실패 모드를 하위가 커버하면 상위 제거), fake-green 역신호(“E2E인데 toBeVisible만 검증하면 = 사실 컴포넌트 레벨이다” → 레벨 내려라).
11. 클로징 — 회귀 테스트의 무덤
마지막으로, 이 결정 트리가 실제로 풀어주는 통증 하나. “버그 회귀 테스트가 과도하게 늘어나 관리가 어려워졌다.” 적지 않은 팀이 겪는 문제다.
원인은 비교적 분명하다. 새 기능엔 좋은 테스트 원칙(동작 검증·push-down·중복 제거)을 적용하면서, 버그 회귀 테스트는 “일단 재현해서 박제”하는 식으로 쌓이기 쉽기 때문이다. 그래서 버그 회귀 테스트 더미엔 이 글에서 다룬 안티패턴이 두루 들어가 있곤 한다 — 화면에서 보고됐다는 이유로 e2e로 몰아넣거나(레벨 과잉), 비슷한 버그마다 새 파일을 만들거나(중복), test('BUG-1234 안 남')처럼 왜 존재하는지 드러나지 않는 테스트(characterization 오용).
근본 원인은 회귀 테스트를 “버그 단위”로 쌓는 데 있다. 회귀 테스트는 버그 단위가 아니라 명세(불변식) 단위로 수렴하는 편이 낫다. 1편의 “description = 명세” 원칙을 그대로 적용하면:
// ❌ 버그 단위 — '왜 존재하는지'를 잃는다 → 관리 불능의 시작
test('BUG-1234 재현 안 됨', () => { ... })
// ✅ 명세 단위 — 무엇을 지키는지가 제목에 → 다음 버그가 같은 명세 위반이면 여기 흡수
test('VIP 는 3개 이상 구매 시 단가의 27%를 할인받는다', () => {
expect(discount(100, 'VIP', 3)).toBe(73) // BUG-1234 가 깬 그 불변식
expect(discount(100, 'VIP', 5)).toBe(73) // 비슷한 버그 또 나면 → 새 파일 X, 케이스만 추가
})
처방은 네 가지다. 명세로 재명명(제목을 버그번호가 아니라 지키는 불변식으로), push-down(버그가 e2e에서 보고됐어도 결함이 로직이면 유닛으로), 중복 흡수(같은 명세의 다른 위반이면 케이스 추가, 새 파일 X), characterization 격리(의도를 모른 채 현재값만 고정한 테스트는 명시적으로 격리하고, 의도가 파악되면 spec으로 승격).
그래서 버그 수정의 진입 경로는 새 기능과 다르지만(버그는 재현부터 시작한다), 도착지는 같을 수 있다 — 명세 테스트. 버그를 “버그의 무덤”으로 쌓는 대신 그 버그가 위반한 명세를 찾아 그 레벨의 spec 테스트로 흡수하면, 회귀 테스트가 무한히 늘어나지 않고 명세 테스트 집합으로 수렴한다.
닫으며
1편이 “좋은 테스트는 동작을 검증한다”였다면, 2편은 그 좋은 테스트를 어디에 두느냐였다. 그리고 그 답은 결국 1편과 같은 원칙으로 돌아온다.
레벨은 “무엇을 real로 맞물리나”로 정해지고, 무슨 레벨이든 검증하는 건 언제나 관찰 가능한 결과(동작)이지 내부 호출 방식(구현)이 아니다. 프론트에선 그 “결과”가 대부분 화면으로 돌아오므로, 나간 요청을 직접 보는 일은 화면에 흔적이 없는 analytics에만 남는다.
그리고 테스트를 어디에 둘지는 단 하나를 물으면 된다 — “이 결함을 재현하려면 최소 무엇을 real로 맞물려야 하나?” 그 최소 범위가 곧 그 테스트가 살 곳이다.
시리즈 예고
- 3편 — 내 테스트가 정말 동작을 검증하나: 눈으로 매긴 평가가 실제로 뒤집히는 사례, mutation testing과 red-green 토글로 “명세 반영”을 기계로 측정하기
참고 자료
좋은 테스트의 정의 · 동작 vs 구현
- Vladimir Khorikov, Unit Testing: Principles, Practices, and Patterns (Manning, 2020) — 4 pillars, managed/unmanaged, observable behavior
- Khorikov, Unmanaged Dependencies Explained — managed의 1차 판별자 = 관찰가능성/공유
- Fowler, Mocks Aren’t Stubs · UnitTest — classicist/mockist, sociable/solitary
- Google, SE at Google Ch.12 — public API로 동작 검증, unchanging test
레벨 · 전략 · push-down
- Google, SE at Google Ch.11 — Small/Medium/Large
- Kent C. Dodds, Testing Trophy · Write tests
- Fowler, Test Pyramid · Practical Test Pyramid — “Push your tests as far down…”
- Google, Just Say No to More End-to-End Tests
프론트엔드 실무 코드 패턴
- RTL Guiding Principles · Testing Implementation Details
- MSW: Avoid request assertions — “request body assert = implementation detail testing” (4·7절 핵심 근거)
- Kent C. Dodds: Stop mocking fetch — fetch mock → MSW 전환 실례
- Playwright: Writing tests · Best Practices
계약 테스트
- Fowler, ContractTest · Consumer-Driven Contracts
- Pact — consumer-driven contract testing
균형 · 반론
- Testing Trophy 비판 — 속도/비용, “integration” 정의 모호성
- MSW #1682 — UI 없는 노드/서버 테스트에선 request 검증이 정당