테스트 41분 읽기

좋은 테스트는 구현이 아니라 동작을 검증한다 — 동작 vs 구현, 단 하나의 칼날

같은 동작인데 구현을 바꿨다고 테스트가 깨진다면, 그 테스트는 무엇을 지키고 있는 걸까. 그 직관을 끝까지 밀어붙인 기록.

같은 동작인데 구현을 바꿨다고 테스트가 깨진다면, 그 테스트는 무엇을 지키고 있는 걸까. 동작 vs 구현이라는 단 하나의 칼날로 테스트의 거의 모든 판단을 꿰뚫어 본 기록.

이 글은 “좋은 테스트란 무엇인가” 시리즈의 1편(동작 vs 구현)이다.


들어가며 — TDD를 하다가 든 생각

테스트 품질을 들여다보다가 가장 근본적인 질문에 부딪혔다. 무엇이 좋은 테스트인가?

내 첫 직관은 이거였다. 좋은 테스트는 구현이 아니라 동작을 검증해야 한다. 같은 동작인데 구현을 바꿨다고 테스트가 깨진다면, 그 테스트는 구현에 의존하고 있다는 뜻이고, 그러면 테스트의 신뢰가 무너진다.

그리고 여기에 한 가지 생각을 더 했다. TDD가 성립하는 이유 자체가 “테스트가 동작을 검증하기 때문”이다. 구현이 존재하지 않는 단계에서 테스트를 먼저 쓰려면, 검증 대상이 “관찰 가능한 동작”일 수밖에 없다. 아직 만들어지지 않은 구현을 검증할 수는 없으니까. 만약 구현을 검증하는 테스트를 먼저 쓴다면, 그건 “무엇을 만들겠다”가 아니라 “어떻게 만들겠다”를 적는 것이다.

이 글은 그 직관을 끝까지 밀어붙인 기록이다. 동작 vs 구현이라는 단 하나의 칼날로 — classicist/mockist, TDD, characterization, 레거시까지 — 테스트의 거의 모든 판단을 꿰뚫어 본다.


1. 대전제 — 동작을 테스트하라

먼저 가장 단순한 명제부터.

동작이 그대로인데 테스트가 깨졌다면, 그 원인은 사실상 하나 — “테스트가 구현에 결합”되어 있다는 것이라고 본다.

나는 처음에 “구현을 잘못했거나, 테스트가 구현에 의존하거나” 둘 중 하나라고 생각했다. 하지만 따져보면 “구현을 잘못함”은 의도와 달리 동작이 바뀐 경우다. 그럼 테스트가 깨지는 게 옳다(이건 보호 성공이다). 전제가 “동작은 그대로”라면 그 갈래는 빠진다. 남는 원인은 하나, 테스트가 구현에 결합된 것뿐이다.

이것이 좋은 테스트의 최상위 신호인 리팩토링 내성(resistance to refactoring)이다. Khorikov의 Unit Testing은 좋은 테스트를 4개 기둥으로 본다.

기둥정의깨지면
회귀 보호버그가 생기면 잡나거짓 초록불(false negative)
리팩토링 내성동작 그대로인데 내부만 바꿔도 안 깨지나거짓 빨간불(false positive)
빠른 피드백빨리 도나
유지보수성읽고 고치기 쉽나

리팩토링 내성이 왜 최상위 신호냐면 — 내성 없는 테스트는 거짓 빨간불을 양산한다. “또 깨졌네, 코드는 맞는데”가 반복되면 개발자는 테스트를 불신하고, 게이트는 무력화된다. Software Engineering at Google도 같은 결론을 “unchanging test(변하지 않는 테스트)“로 표현한다: 순수 리팩토링·새 기능 추가·버그 수정 시 테스트는 안 바뀌어야 하고, 오직 동작이 바뀔 때만 바뀐다.

테스트가 깨졌을 때 — 동작이 바뀌었으면 보호 성공(true positive), 동작은 그대로인데 깨졌으면 거짓 빨간불(false positive, 구현 결합) → 리팩토링 내성 붕괴

그럼 핵심은 “무엇이 동작이고 무엇이 구현이냐”를 판정하는 기준이다. 그게 다음 절이다.


2. “동작”을 어떻게 판정하나 — 클라이언트와 두 관찰자

판정 기준은 한 단어로 압축된다. 클라이언트.

여기서 클라이언트는 일상어(고객)가 아니라 “그 코드를 호출해서 쓰는 쪽”이라는 설계 용어다. A가 B를 호출하면 A는 B의 클라이언트다. “누가 누구를 부르냐”는 방향으로만 정해진다.

호출 방향으로 정해지는 클라이언트 — Controller는 Service의 클라이언트이고, 같은 Service는 repo/mailer 입장에선 클라이언트다

같은 OrderService도 위에서 보면(CheckoutController 입장) 협력 대상이고, 자기가 부르는 repo/mailer 입장에선 클라이언트다. 그래서 동작/구현 판정은 이렇게 묻는다.

“내 클라이언트가 이걸 신경 쓰나? 이게 바뀌면 클라이언트가 깨지나?”

  • 의존한다 → 동작(public contract)
  • 모른다 → 구현 디테일

그리고 결정적으로 — 테스트 코드 역시 클라이언트의 일종이다. 좋은 테스트는 다른 평범한 클라이언트와 똑같은 방식으로 SUT를 쓴다. Google의 명제 “if such a test breaks, an existing user will also be broken”에서 ‘user’가 바로 이 클라이언트다. user = client = caller, 다 같은 말이다.

관찰자는 둘이다

여기서 한 단계 더. “클라이언트가 모르면 구현”이라는 단순화에는 함정이 있다. 관찰자가 이기 때문이다.

  1. 내부 호출 클라이언트 — SUT를 부르는 위쪽 코드
  2. 시스템 경계 밖 외부 세계 — 이메일을 받는 고객, 다른 시스템, 제3자

이메일 발송을 보자. 직접 클라이언트(CheckoutController)는 이메일을 어떻게 보내는지 몰라야 맞다. 하지만 외부 세계는 안다 — 고객 받은편지함에 메일이 실제로 도착하니까. 그래서 “이메일 발송”은 (1)에겐 안 보여도 (2)에겐 보이는 관찰 가능한 부수효과다.

정확한 정의: 시스템의 어떤 관찰자도 의존하지 않으면 구현. 관찰자 = 호출 클라이언트 + 외부 세계.

이 “두 관찰자”가 다음에 나올 managed/unmanaged 구분의 뿌리다.


3. Classicist vs Mockist — 같은 코드, 두 가지 테스트

이제 본격적으로. 검증 대상이 OrderService다.

class OrderService {
  constructor(private repo: OrderRepo, private mailer: Mailer) {}

  placeOrder(items: Item[]): Order {
    const total = items.reduce((sum, i) => sum + i.price * i.qty, 0)
    const order: Order = { id: uuid(), items, total, status: 'placed' }
    this.repo.save(order)            // 협력객체 ① — 도메인 내부(저장)
    this.mailer.sendReceipt(order)   // 협력객체 ② — 경계(외부 이메일)
    return order
  }
}

여기서 검증 대상을 SUT(System Under Test)라 부른다. OrderService가 SUT, repo/mailer협력객체(collaborator)다. 두 학파는 “SUT를 협력객체로부터 얼마나 떼어놓나”에서 갈린다.

Classicist — 상태(결과)를 검증

test('주문하면 항목 가격이 합산된 총액으로 주문이 생성된다', () => {
  const repo = new InMemoryOrderRepo()   // real 대용 fake
  const mailer = new FakeMailer()
  const service = new OrderService(repo, mailer)

  const order = service.placeOrder([
    { price: 100, qty: 2 },
    { price: 50, qty: 1 },
  ])

  expect(order.total).toBe(250)                  // ← 관찰 가능한 결과(상태)
  expect(repo.findById(order.id)).toEqual(order) // ← "저장됐다" = 조회하면 있다(동작)
})

Mockist — 상호작용(호출)을 검증

test('주문하면 repo.save와 mailer.sendReceipt를 호출한다', () => {
  const repo = { save: vi.fn(), findById: vi.fn() }
  const mailer = { sendReceipt: vi.fn() }
  const service = new OrderService(repo, mailer)

  service.placeOrder([{ price: 100, qty: 2 }])

  expect(repo.save).toHaveBeenCalledOnce()                         // ← "호출했다"
  expect(mailer.sendReceipt).toHaveBeenCalledWith(
    expect.objectContaining({ total: 200 }),
  )
})

classicist는 “총액이 250이 됐나, 조회하면 있나” — 결과를 본다. mockist는 “save를 불렀나, sendReceipt를 불렀나” — 호출을 본다.

두 학파의 차이를 정리하면:

Classicist (Detroit)Mockist (London)
단위(unit)의 정의동작의 단위코드의 단위(클래스)
협력객체real 우선항상 mock
SUT 형태sociable (함께 돈다)solitary (격리)
검증상태 검증상호작용 검증
리팩토링 내성높음낮음 (구현 결합)
TDD 방향inside-out (도메인부터)outside-in (인터페이스부터)

Mockist는 협력객체를 mock으로 격리(solitary, 호출 검증)하고, Classicist는 실제 객체와 함께 돌리며 상태를 검증(sociable)한다

stub과 mock은 다르다

Fowler가 “Mocks Aren’t Stubs”라는 제목으로 굳이 가른 핵심. 테스트 더블 5종(Meszaros):

종류역할검증
dummy자리만 채움
stub미리 정한 입력 제공상태 검증 보조
spystub + 호출 기록사후 조회
mock기대치 사전 설정호출 안 되면 실패
fake작동하는 경량 구현상태 검증

핵심은 mock만이 “상호작용 검증”과 한 몸이라는 것. classicist도 stub/fake는 쓴다. 그러니 두 학파를 가르는 건 mock의 개수가 아니라 “호출을 expectation으로 명시하느냐”다.

mockist는 “behavior verification”이라는데, 역설이다

mockist는 자기 방식을 “behavior verification”이라 부르고, outside-in으로 협력 인터페이스를 발견하는 설계 이점을 명분으로 든다. 하지만 검증하는 건 진짜 관찰 가능한 동작이 아니라 내부 호출(interaction)이고, 내부 협력 방식은 곧 구현이다. 그래서 동작은 그대로인데 내부 구조만 리팩토링해도 깨진다.

그렇다고 mockist가 항상 악은 아니다. 다만 그 설계 이점은 “인터페이스를 먼저 정의하는 행위” 자체에서 나오는 것이지 mock이 유일한 길은 아니다. 인터페이스 우선 설계 + 상태 검증으로도 같은 통찰을 얻는다. 그래서 내가 받아들인 절충은: 설계는 인터페이스 우선 + 상태 검증(classic)으로, mock은 경계에만.


4. mock의 경계 — 의존성은 3종이다

“경계에만 mock”이라 했는데, 그 “경계”의 정확한 이름이 managed / unmanaged dependency다.

  • managed dependency: 내가 전적으로 통제하고 외부에서 안 보임 (내 DB). → 구현.
  • unmanaged dependency: 내 통제 밖이고 외부에서 관찰됨, 다른 시스템과 공유 (이메일·메시지 버스). → 동작.

여기서 2절의 “두 관찰자”가 작동한다. DB 저장은 클라이언트도 외부 세계도 못 본다(managed → 구현). 이메일은 외부 세계가 받는다(unmanaged → 동작). 결정적 차이는 시스템 경계를 넘느냐다.

repo.save() (managed)mailer.send() (unmanaged)
위치경계 경계를 넘음
외부에서 관찰?✅ (고객이 메일 받음)
정체구현동작

비유하면 — repo.save내 방 서랍에 메모를 넣는 것(밖에서 안 보임), mailer.send창밖으로 편지를 던져 우체부에게 주는 것(외부로 나감).

그래서 의존성은 방향에 따라 3종으로 나뉜다

의존성 3종 — 들어오는 입력은 stub/fake로 공급만(검증 X), 나가는 managed(DB)는 최종 상태 검증, 나가는 unmanaged(이메일)는 mock으로 메시지 검증

의존성 종류더블무엇을
들어오는 입력(읽기)환율 조회, 설정stub / fake검증 안 함 — 입력만 제공
나가는 managed(쓰기)내 DB 저장real / fake최종 상태 검증 (호출은 무시)
나가는 unmanaged(부수효과)이메일, 메시지 버스mock나간 메시지 검증

이 표가 핵심이다. managed와 unmanaged는 테스트 방식이 정반대다.

  • unmanaged(SMTP): 통신 자체가 외부로 나가는 동작 → “무엇을 보냈나” 검증
  • managed(DB): 통신은 구현 → “무엇이 남았나” 검증 (호출은 무시)

“DB 저장이 구현이라며 왜 findById로 검증하지?”

여기서 나도 한참 헷갈렸다. DB와 관련해 검증할 수 있는 건 두 층위다.

// ① "save를 호출했다" — 저장하는 '방법/동선' (mockist) → 구현
expect(repo.save).toHaveBeenCalledWith(order)

// ② "조회하면 나온다" — 저장의 '결과/효과' (classicist) → 동작
expect(repo.findById(order.id)).toEqual(order)

“DB 저장은 구현”이라 한 건 ①을 말한 것이다. ②는 내부적으로 saveuow.commit이든 상관없이 “넣었으면 다시 읽힌다”는 영속성, 즉 동작이다. 서랍 비유로는 — “어떻게 넣었나”는 구현, “서랍을 다시 열면 있나”는 동작. findById서랍을 다시 열어보는 행위다.

그리고 findById검증 수단이지 검증 대상이 아니다. 우리는 “findById를 호출했다”가 아니라 findById가 돌려주는 값(상태)을 본다.

expect(repo.findById).toHaveBeenCalled()        // ❌ 호출 검증
expect(repo.findById(order.id)).toEqual(order)  // ✅ 리턴값(상태) 검증

Khorikov의 한 문장: “managed dependency와의 통신은 구현 디테일이니 무시하고, 그 통신이 만든 최종 상태만 검증하라.”

unmanaged라도 “내용”만 — 메커니즘은 금지

// ✅ 동작 — 외부로 나간 메시지의 관찰 가능한 내용
expect(sentEmail).toMatchObject({ to: 'customer@x.com', subject: '영수증', total: 200 })

// ❌ 다시 구현으로 미끄러짐 — 내부 호출 메커니즘
expect(mailer.send).toHaveBeenCalledTimes(1)         // 재시도하면 2번일 수도
expect(mailer.connect).toHaveBeenCalledBefore(mailer.send)  // 내부 순서 = 구현

경계를 넘는 메시지(what)는 동작, 그걸 보내는 방식(how)은 구현. 같은 unmanaged 안에서도 이 선이 또 그어진다.


5. 그런데 “unit”이 정확히 뭔가

지금까지 대부분 unit 테스트 얘기였다. 그런데 여기서 unit은 무슨 뜻일까? 나도 처음엔 “한 클래스”라고 여겼는데, 따져보니 아니었다.

여기서 unit은 “한 클래스”가 아니라 “한 동작”이다.

classicist에서 “isolated(격리)“가 가리키는 건 SUT 격리가 아니라 테스트 케이스끼리의 격리(순서·공유상태 의존 없음)다. 그래서 한 동작을 검증하는 데 여러 실제 객체가 함께 참여해도(sociable) 그게 한 unit 테스트다.

그럼 unit과 integration의 경계는 클래스 수가 아니다.

out-of-process 의존성(real DB·네트워크·파일시스템)을 실물로 건드리나? 안 건드리면 unit, 건드리면 integration.

테스트repo를…레벨
order.total === 250의존성 없음unit
repo.findById(id) 검증in-memory fakeunit (프로세스 안)
같은 검증real Postgresintegration (프로세스 밖)
SMTP 메시지 검증mockunit
같은 검증real SMTPintegration

unit과 integration의 경계는 부품 수가 아니라 프로세스 경계 — fake/mock으로 프로세스 안에 머물면 unit, real DB·real SMTP를 건드리면 integration

같은 findById 테스트가 unit이냐 integration이냐는 “OrderService + repo 두 덩어리라서”가 아니라 “repo가 fake냐 real DB냐”로 정해진다. 우리 예제가 거의 다 unit인 건 managed를 fake로, unmanaged를 mock으로 둬서 전부 프로세스 안에 머물렀기 때문이다.

참고: Google은 “unit”이란 말이 너무 오염돼서 small(단일 프로세스) / medium(단일 머신·DB 접근) / large(여러 머신)로 다시 나눈다. 부품 수가 아니라 경계로 나누는 게 덜 모호하다.

(레벨별 책임과 어느 레벨에 무게를 두나 — 트로피/허니콤 — 는 2편 주제다.)


6. TDD로 보면 — 동작은 비워두고, 구현은 따라온다

이제 도입부의 TDD 직관을 코드로 실증한다. RED → GREEN → REFACTOR.

Classicist TDD — “무엇을(동작)“만 적는다

// 🔴 Step 1 RED — 가장 단순한 동작. 협력객체는 아직 없다
test('항목 가격이 합산된 총액으로 주문이 생성된다', () => {
  const order = placeOrder([{ price: 100, qty: 2 }, { price: 50, qty: 1 }])
  expect(order.total).toBe(250)
})

// 🟢 GREEN — 통과시킬 최소 구현
function placeOrder(items: Item[]): Order {
  const total = items.reduce((s, i) => s + i.price * i.qty, 0)
  return { id: uuid(), items, total, status: 'placed' }
}

// 🔴 Step 2 RED — 새 동작: "저장되어 조회된다". 검증은 '호출'이 아니라 '조회 결과'
test('주문은 저장되어 id로 조회된다', () => {
  const repo = new InMemoryOrderRepo()
  const service = new OrderService(repo)
  const order = service.placeOrder([{ price: 100, qty: 1 }])
  expect(repo.findById(order.id)).toEqual(order)
})

// 🟢 GREEN
class OrderService {
  constructor(private repo: OrderRepo) {}
  placeOrder(items: Item[]): Order {
    const total = items.reduce((s, i) => s + i.price * i.qty, 0)
    const order = { id: uuid(), items, total, status: 'placed' }
    this.repo.save(order)   // ← 내 구현 '선택'. 테스트가 시킨 게 아님
    return order
  }
}

repo.save(order)내가 고른 구현이다. 테스트는 “조회되면 OK”만 요구했지 “save를 불러라”라고 안 했다. 그래서 나중에 uow.commit()으로 바꿔도 통과 — 구현 자유가 보존된다.

Mockist TDD — “어떻게(협력)“를 먼저 고정한다

// 🔴 Step 1 RED — outside-in으로 협력 구조부터 결정
test('주문하면 저장하고 영수증을 보낸다', () => {
  const repo = { save: vi.fn() }
  const mailer = { sendReceipt: vi.fn() }
  const service = new OrderService(repo, mailer)

  service.placeOrder([{ price: 100, qty: 2 }])

  expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ total: 200 }))
  expect(mailer.sendReceipt).toHaveBeenCalled()
})

이 테스트를 적는 순간 이미 “save로 저장하고 sendReceipt로 보낸다”는 협력 구조를 결정해버렸다. 구현이 한 줄도 없는데 구현 스케치가 테스트에 고정된 것이다.

🔵 REFACTOR — 여기서 운명이 갈린다

동작은 그대로 두고 저장만 Unit of Work로 정리한다.

this.uow.register(order)
this.uow.commit()         // repo.save() 대신

REFACTOR(save→uow.commit, 동작 동일)에서 갈림 — Classicist는 결과를 봐서 통과(리팩토링 자유), Mockist는 호출을 봐서 빨간불(동작 멀쩡한데 깨짐)

TDD의 리듬은 RED → GREEN → REFACTOR인데, refactor 단계가 성립하려면 “동작 안 바꾼 변경엔 테스트가 침묵한다”리팩토링 내성이 전제돼야 한다. mockist는 그 전제를 스스로 깬다. mockist TDD는 red-green은 돌지만 refactor에서 자기 발에 걸려 넘어진다.

도입부 직관의 적중

“구현을 검증한다면 그건 ‘어떻게’지 ‘무엇을’이 아니다.”

mockist TDD가 정확히 이것이다. “구현이 없는데 어떻게 테스트를 먼저 쓰나?”의 답은 — 머릿속 구현(협력 프로토콜)을 미리 상상해서 테스트로 옮긴다. 즉 mockist는 “구현 없이도 테스트를 쓸 수 있다”를 보여주지만, 그건 “어떻게”를 적은 것이라 직관의 반례가 아니라 실증이다.

classicist TDDmockist TDD
테스트가 적는 것결과(무엇을)협력(어떻게)
구현 자유도비어있음 → 자유이미 결정됨
REFACTOR작동깨짐

TDD의 효능은 “시점(test-first)“이 아니라 “검증 대상의 선택(상태 vs 상호작용)“에서 나온다.


7. Characterization — 동작 검증의 정당한 특수해

“동작을 테스트하라”에는 정당한 예외가 있다. 테스트가 전혀 없는 레거시 코드를 수정할 때, 기존 동작을 고정시키려 만드는 characterization test다.

이걸 “구현을 테스트하는 형태”라고 부르는 걸 종종 봤는데, 내가 보기엔 정밀화가 필요한 지점이다. characterization은 구현(내부 메커니즘)을 고정하는 게 아니라 “현재 관찰되는 동작(출력)“을 고정한다. 코드로 보면 classicist 출력 검증과 생긴 게 똑같다.

// 레거시 할인 함수 — 왜 이렇게 동작하는지 아무도 모름
test('[characterization] 현재 동작 고정 — 정답 보장 아님', () => {
  expect(legacyDiscount(100, 'VIP', 3)).toBe(73)  // 왜 73? 모름. '지금' 그렇게 나옴
  expect(legacyDiscount(100, 'NEW', 1)).toBe(95)
  expect(legacyDiscount(0,   'VIP', 0)).toBe(0)
})

차이는 검증 대상이 아니라 expectation의 출처다.

spec/behavior 테스트characterization
검증 형태입력→출력입력→출력 — 같음
expectation 출처의도된 명세 (“250이어야 한다”)관찰된 현재값 (“돌려보니 73”)
정답 보장버그여도 정답으로 고정
정체명세change detector(변경 감지기)

그러니 정확한 문장은 — characterization은 “구현을 테스트한다”가 아니라 “동작을 테스트하되, 그 동작이 옳은지 모른 채 현재 값을 잠정 명세로 승격시킨다”이다. “예외”인 지점은 검증 형태가 아니라 “expectation이 검증된 명세냐, 미검증 현상이냐”다.

description이 다르다 — 그리고 그게 핵심이다

spec 테스트와 characterization은 description이 다르다. 그리고 그건 단순한 표현 차이가 아니라 그 테스트의 “인식론적 상태”를 코드에 새겨 넣는 행위다.

// characterization — 아직 '왜'를 모름. 관찰한 사실만
test('legacyDiscount(100, "VIP", 3)은 73을 반환한다', () => {
  expect(legacyDiscount(100, 'VIP', 3)).toBe(73)
})

// spec — '왜'가 담김. 같은 값, 다른 정체
test('VIP는 3개 이상 구매 시 단가의 27%를 할인받는다', () => {
  expect(legacyDiscount(100, 'VIP', 3)).toBe(73)  // 100 * 0.73
})

검증 코드(toBe(73))는 똑같다. 다른 건 description과 주석뿐이고, 그게 “우리가 27%라는 규칙을 이해했다”를 증명한다. 위는 “넣으니 나온다(관찰)”, 아래는 “이래야 한다(규칙)”.

이건 좋은 테스트 이름의 일반 원칙으로 확장된다: 테스트 제목은 “검증 수단(how)“이 아니라 “명세(what/why)“를 서술해야 한다. characterization은 이 원칙의 유일하게 정직한 예외다 — “넣으니 나온다”를 쓰는 게 허용되는데, 그건 아직 why를 모른다는 사실을 숨기지 않기 때문이다. (그래서 [characterization] 태그로 의도를 드러내야 한다.)

언제 없어지나 — “이해하는 순간”

characterization은 영구 테스트가 아니다. 사라지거나 변하는 시점은 개발자가 그 코드가 왜 그렇게 짜였는지 이해하는 순간이다. 다만 이해는 트리거고, 직후 두 갈래로 갈린다.

  • 이해해보니 의도된 동작 → 값은 두고 description에 ‘왜’를 담아 spec으로 승격
  • 이해해보니 버그 → 고치고 expectation 갱신

정당한 종료 트리거는 셋이다: ① 이해 → 승격 ② 대체(제대로 된 spec이 커버하면 중복 → 제거) ③ 소멸(레거시 코드 자체가 삭제). 끝내 이해 못 하면 “이해 못 한 채 고정함”이라는 라벨을 단 채 잔존하는 것도 정당하다. 안티패턴은 이해도 대체도 안 했는데 영구 방치하는 것 — 그럼 “버그를 정답으로 고착시킨 테스트”가 된다.

그리고 두 가지(언제 없어지나 / description이 다르다)는 사실 하나로 묶인다.

description의 변화가 곧 “이해하는 순간”의 코드상 증거다. 'legacyDiscount(100,"VIP",3)은 73을 반환한다''VIP는 3개 이상 시 27% 할인'으로 바뀌는 순간 = 이해 완료 + 승격 완료. 테스트 제목이 이해의 지도가 된다.

mockist 안티패턴 vs characterization

둘 다 “리팩토링하면 깨질 수 있다”는 증상을 공유하지만, 깨짐의 가치가 정반대다.

mockist 안티패턴characterization
무엇을 박나내부 협력(구현)외부 출력(현재 동작)
의도성모르고/실수로알고서/의도적으로
정당성없음있음 (레거시 안전망)
깨짐의 정체버그(false positive)기능(change detector)
수명영구 (그래서 문제)임시

characterization의 “깨짐”은 버그가 아니라 설계된 기능이다. “변했다”를 알려주는 게 존재 이유니까.


8. 레거시 진입 — 왜 하필 바깥(e2e)부터인가

characterization을 어디서 시작하느냐에 또 하나의 통찰이 있다. 테스트가 전혀 없는 레거시에 처음 테스트를 붙일 때, 가장 바깥(전체 동작, e2e)부터 시작한다.

왜 내부 유닛부터 못 짜냐면 — 내부 함수를 격리해 테스트하려면 의존성을 끊을 seam(이음새)이 필요한데(Feathers 용어: 코드를 수정하지 않고 동작을 바꿔 끼울 수 있는 지점), 레거시엔 seam이 없다. 강결합 덩어리라 한 조각만 떼기가 위험하다. seam을 만들려고 내부에 손대는 순간 = 리팩토링 = 동작 깨질 위험. 그런데 그걸 막아줄 테스트가 아직 없다. 닭-달걀.

그래서 손을 안 대고 감쌀 수 있는 가장 바깥을 통째로 characterization으로 두른다.

// 레거시 주문 모듈 — 내부 1000줄, 전체를 아는 사람 없음. seam 없음.
test('[characterization] 전체 주문 플로우 현재 출력을 고정한다', async () => {
  const result = await legacyOrderModule.process(SAMPLE_INPUT)
  expect(result).toEqual(OBSERVED_OUTPUT)  // 돌려보고 나온 그대로. 내부는 0 앎
})

여기가 핵심이다 — 내부 구현을 하나도 모르는데(구현을 테스트할 수 없는데) 입력→출력은 고정할 수 있다. 동작은 바깥에서 관찰 가능하니까. 이게 “동작을 테스트하라”는 원칙의 가장 극적인 위력이다. 구현 지식 0으로도 안전망이 만들어진다.

그 다음은 라이프사이클대로 — 안전망을 깔았으니 안심하고 내부 리팩토링 → seam을 만들며 이해 → 빠르고 좁은 유닛 테스트로 승격/대체(description이 “넣으니 나온다”→“이래야 한다”로 진화) → 바깥 e2e characterization은 역할을 다하면 정리.


9. 안티패턴 — 구현을 “복사”한 테스트

지금까지 “구현 테스트는 나쁘다”를 주로 추상적으로 말했다. 가장 흔한 실패 형태를 코드로 명시해두자. 동어반복(미러링)이다.

const TAX_RATE = 0.1                              // 소스에도 0.1
test('세율은 0.1이다', () =>
  expect(getTaxRate()).toBe(TAX_RATE))            // 테스트에도 0.1 = 동어반복

소스를 0.2로 바꾸면 TAX_RATE도 같이 바뀌어 버그를 못 잡는다(보호 0). 같은 부류:

// mock passthrough — mock이 리턴한 걸 그대로 assert
const repo = { getUser: vi.fn().mockReturnValue({ id: 1 }) }
expect(service.getUser(1)).toEqual({ id: 1 })     // mock 값 그대로 = 동어반복

// 구조만 확인 — 키만 보고 값은 안 봄
expect(result).toHaveProperty('total')            // total이 틀려도 통과

이들은 구현을 테스트에 복사한 것이라 보호도 내성도 동시에 0이 된다. 동작이 바뀌어도(버그) 안 잡히고, 구현이 바뀌어도(리팩토링) 깨질 수 있는 — 가장 나쁜 조합이다.


10. 그래서 “좋은 테스트”의 조작적 정의

여기까지 오면 좋은 테스트를 한 문장이 아니라 두 상황에서의 반응으로 정의할 수 있다.

// 상황 A: 동작 그대로, 구현만 리팩토링 (save → uow.commit)
//   → 좋은 테스트는 침묵해야 한다 (🟢 통과)

// 상황 B: 동작을 망가뜨림 — 버그 주입 (total 계산을 *가 아니라 +로)
//   → 좋은 테스트는 울려야 한다 (🔴 실패)
동작 그대로 + 구현 변경동작 변경(버그 주입)
좋은 테스트🟢 침묵 (내성)🔴 울림 (보호)
구현 결합 테스트🔴 거짓 빨강
동어반복 테스트🟢 거짓 초록 (못 잡음)

좋은 테스트 = 이 두 상황에서 정확히 반응하는 테스트. 동작 보존 변경엔 침묵(false positive 없음), 동작 변경엔 울림(false negative 없음).

(그런데 이 두 반응을 눈이 아니라 기계로 검사하는 방법이 있다 — mutation testing과 red-green 토글. 그건 3편에서.)


11. 프론트엔드에서도 같은 원칙이다

지금까지 예제가 백엔드(OrderService)라 “이건 서버 얘기네”로 오해할 수 있다. 프론트 예제 한 컷이면 이 원칙이 레이어 무관임이 드러난다.

// 구현 테스트 — 내부 state를 검증 (안티)
expect(wrapper.state('count')).toBe(1)

// 동작 테스트 — 사용자가 보는 것 (React Testing Library)
await user.click(button)
expect(screen.getByText('1')).toBeVisible()

Kent C. Dodds의 “구현 디테일을 테스트하지 마라”가 정확히 이 축의 프론트 버전이다. 구현 디테일이란 “사용자(최종 사용자 + 컴포넌트 소비자)가 쓰지 않는 것” — 내부 state 변수명·메서드 호출은 구현, 화면에 보이는 것·상호작용 결과는 동작. RTL의 슬로건이 이걸 압축한다:

“The more your tests resemble the way your software is used, the more confidence they can give you.”

2절의 클라이언트 개념 그대로다 — UI에선 클라이언트가 사람 사용자이고, 그 사람이 의존하는 것(화면)이 동작, 모르는 것(내부 state)이 구현이다.


12. 닫으며 — 신규와 레거시의 대칭

도입부의 TDD와 7~8절의 characterization은 사실 거울상이다.

신규 코드레거시 코드
명세(내부 의도)를안다모른다
방법TDD (test-first)characterization (test-after)
방향inside-out (도메인부터)outside-in (e2e/바깥부터)
동작을선언한다 (정답을 알고)관찰한다 (정답을 모르고)
공통 원칙둘 다 “동작을 테스트한다”← 같음

동작을 테스트한다는 단 하나의 원칙 아래 — 신규는 명세를 알아서 TDD/inside-out으로 동작을 선언, 레거시는 명세를 몰라서 characterization/outside-in으로 동작을 관찰

동작을 테스트한다는 원칙은 신규든 레거시든 같다. 다른 건 “명세를 아느냐”뿐이고, 그게 방향(inside-out vs outside-in)과 방법(TDD vs characterization)을 가른다. characterization조차 이 원칙의 예외가 아니라, 정보 부족 상황에 적용한 특수해다 — “옳은 동작을 모르니 현재 동작이라도 동작으로서 붙잡는다.”

그리고 마지막 한 줄.

좋은 테스트는 값으로 명세를 검증하고, 좋은 테스트 이름은 우리가 그 명세를 이해했는지를 검증한다.


시리즈 예고

  • 2편 — 좋은 테스트를 어디에 두나: 레벨별 책임(unit/integration/e2e가 검증하면 안 되는 것), 프론트/백엔드 전략 차이(트로피/피라미드/허니콤), 중복 제거(push-down)
  • 3편 — 내 테스트가 정말 동작을 검증하나: 눈으로 매긴 평가가 실제로 뒤집히는 사례, mutation testing과 red-green 토글로 “명세 반영”을 측정하기

참고 자료 (학습 경로)

1주차 — 무료 아티클로 지형 파악

2~3주차 — 책 한 권 정독

  • Vladimir Khorikov, Unit Testing: Principles, Practices, and Patterns (Manning, 2020) — 4 pillars, managed/unmanaged, observable behavior. 이 글의 거의 모든 프레임의 원전. (Ch.4·5·8·11이 핵심)

4주차 — TDD를 손으로

  • Kent Beck, Test-Driven Development by Example — 얇고 실습형, red-green-refactor 원전

그 다음 — 영역 깊이

  • Michael Feathers, Working Effectively with Legacy Code — characterization·seam의 원전
  • Freeman & Pryce, Growing Object-Oriented Software, Guided by Tests — mockist/outside-in·double-loop TDD의 원전
  • characterization test (Wikipedia) / Approval Testing