리팩터링은 “겉보기 동작을 바꾸지 않고 내부 구조를 개선한다”는 약속 위에 서 있다. 그 약속을 지키고 있는지 매 순간 확인해 주는 장치가 바로 테스트다. 이 장은 생산 계획(production plan) 예제를 가지고 테스트 스위트를 맨바닥부터 쌓아 가는 과정을 보여준다. 저자의 주장은 단순하다. 견고한 테스트가 있으면 두려움 없이 코드를 주무를 수 있고, 두려움이 사라지면 리팩터링 속도가 오르고, 결국 개발 전체가 빨라진다. 나는 “테스트 짜는 시간이 아깝다”고 느끼던 시절이 있었는데, 이 장은 그 감각이 착시라는 걸 계산기로 두들겨 보여준다. 버그를 찾는 데 쓰는 시간이 테스트를 쓰는 시간보다 훨씬 크기 때문이다.

예제: Province와 Producer

예제 도메인은 지역(Province)의 생산 계획이다. 한 지역은 이름, 총 수요(demand), 판매가(price)를 갖고, 여러 생산자(Producer)를 거느린다. 각 생산자는 생산 비용(cost)과 생산량(production)을 갖는다. 여기서 파생되는 값이 두 개 있다. 총생산량(모든 생산자의 production 합)과 부족분(shortfall = 수요 - 총생산량), 그리고 이익(profit)이다. 이익 계산은 수요와 생산량 중 작은 쪽을 판매가로 곱한 수입에서, 각 생산자의 비용을 뺀 값이다. 계산이 여러 단계로 얽혀 있어서 딱 테스트로 지켜야 할 종류의 코드다.

class Province {
  constructor(doc) {
    this._name = doc.name
    this._producers = []
    this._totalProduction = 0
    this._demand = doc.demand
    this._price = doc.price
    doc.producers.forEach((d) => this.addProducer(new Producer(this, d)))
  }
  get shortfall() {
    return this._demand - this.totalProduction
  }
  get profit() {
    return this.demandValue - this.demandCost
  }
  // ... totalProduction, demandValue, demandCost 등
}

자가 테스트 코드의 가치

핵심 단어는 “자가 테스트(self-testing)“다. 테스트는 완전히 자동으로 돌아야 하고, 결과가 맞는지까지 코드가 스스로 판정해야 한다. 콘솔에 숫자를 찍어 놓고 사람이 눈으로 “음, 5가 맞네” 하는 건 테스트가 아니다. 사람의 눈은 지치고 게을러지며, 스무 번째 실행쯤이면 화면을 대충 넘긴다. 판정을 기계에게 넘겨야 초록불/빨간불이라는 이진 신호가 생기고, 그 신호가 있어야 안심하고 다음 걸음을 뗄 수 있다.

저자가 권하는 리듬은 이렇다. 기능을 조금 짜고, 그에 대한 테스트를 짜고, 테스트가 통과하면 다음 조각으로 넘어간다. 코드 몇 줄마다 테스트를 돌리니 버그가 생겨도 “방금 만진 그 몇 줄” 안에 갇혀 있어서 찾기가 쉽다. 디버깅이란 결국 “언제부터 잘못됐나”를 이진 탐색하는 일인데, 자주 실행하면 탐색 구간이 애초에 짧다.

이 장이 권하는 테스트 한 사이클을 한 바퀴 도는 그림으로 정리하면 이렇다. 각 단계는 다음 장의 절과 이어진다.

flowchart TD
  t1[테스트 작성] --> t2["일부러 버그 심어 실패 확인"]
  t2 --> t3[버그 되돌리고 통과 확인]
  t3 --> t4["경계 조건 추가: 0·음수·빈 컬렉션"]
  t4 --> t5[위험한 로직에 화력 집중]
  t5 --> t1

마지막에서 다시 처음으로 돌아오는 화살표가 핵심이다 — 테스트는 한 번 짜고 끝내는 게 아니라 계속 도는 리듬이다.

Mocha와 Chai로 첫 테스트 쓰기

책의 예제는 테스트 러너로 Mocha, 단언(assertion) 라이브러리로 Chai를 쓴다. 구조는 describe로 대상을 묶고, it으로 개별 케이스를 쓰고, expect(...).equal(...) 형태로 기대값을 선언한다.

describe("province", function () {
  it("shortfall", function () {
    const asia = new Province(sampleProvinceData())
    expect(asia.shortfall).equal(5)
  })
})

여기서 sampleProvinceData()는 테스트용 표본 데이터를 만들어 주는 헬퍼다. 실행하면 Mocha가 초록색으로 “1 passing”을 찍는다. 이 초록불 하나가 앞으로 이 코드를 리팩터링할 때 뒤를 봐주는 안전망의 첫 매듭이다.

실패해야 할 때 실패하는지 확인하라

이 장에서 가장 인상 깊은 규칙. 테스트가 초록불이라고 곧이곧대로 믿으면 안 된다. 항상 통과하기만 하는 테스트는 없느니만 못하다 — 가짜 안전감을 주기 때문이다. 그래서 새 테스트를 짜면 일부러 제품 코드에 오류를 심어 그 테스트가 빨간불로 바뀌는지 확인한다.

get shortfall() {
  return this._demand - this.totalProduction * 2;  // 일부러 심은 버그
}

이렇게 고쳐 놓고 테스트를 돌려 빨간불이 뜨면, 그 테스트는 최소한 “이 계산이 틀리면 잡아낸다”는 걸 증명한 것이다. 확인이 끝나면 심었던 오류를 되돌린다. 나는 이 습관을 TDD의 “빨강 → 초록” 순서와 같은 뿌리로 이해한다. 실패를 한 번도 본 적 없는 테스트는 무엇을 지키는지 스스로도 모른다.

경계 조건을 공략하라

수요가 정상 범위일 때만 테스트하면 절반만 지킨 것이다. 버그는 대개 경계에 산다. 생산자가 하나도 없을 때, 생산량이 음수일 때, 컬렉션이 비었을 때, 값이 0일 때. 이런 극단을 일부러 찔러 본다.

describe("no producers", function () {
  let noProducers
  beforeEach(function () {
    const data = { name: "No producers", producers: [], demand: 30, price: 20 }
    noProducers = new Province(data)
  })
  it("shortfall", function () {
    expect(noProducers.shortfall).equal(30) // 수요 전부가 부족분
  })
  it("profit", function () {
    expect(noProducers.profit).equal(0)
  })
})

이어서 수요를 문자열로 바꿔 넣으면 어떻게 되는지, 음수 수요는 무엇을 의미해야 하는지 같은 “이상한 입력”도 던져 본다. 저자의 태도가 재밌다. 이런 경우 프로그램이 어떻게 동작해야 하는지 명세가 애매하다면, 그건 테스트를 못 짜는 게 아니라 요구사항이 덜 정해진 것이다. 경계 테스트는 종종 숨은 설계 질문을 끄집어낸다.

어차피 다 못 잡으니 위험한 곳에 집중

완벽주의로 가면 안 된다는 경고도 명확하다. 테스트로 모든 버그를 잡는 건 불가능하고, 그걸 목표로 삼으면 단순한 접근자(getter)까지 테스트하느라 지쳐서 정작 중요한 로직 테스트가 부실해진다. 본말전도다. 그래서 “어디가 잘못되면 제일 아픈가”를 기준으로 위험한 계산 로직, 조건 분기, 상태 변경에 화력을 집중한다. 리스크가 낮고 뻔한 코드는 과감히 비운다.

여기엔 심리적 함정도 있다. 테스트가 완벽하지 않다는 이유로 “테스트를 다 갖출 때까지 리팩터링을 미룬다”는 건 순서가 거꾸로다. 불완전한 테스트라도 지금 짜서 돌리는 편이, 완벽한 스위트를 기다리며 아무것도 안 하는 것보다 압도적으로 낫다.

픽스처는 매번 새로 — 공유의 함정

테스트마다 new Province(...)를 다시 만드는 게 낭비처럼 보여서, 한 번 만든 객체를 여러 테스트가 나눠 쓰고 싶은 유혹이 생긴다. 이건 지옥의 문이다. 앞 테스트가 그 객체의 상태를 바꿔 놓으면 뒤 테스트 결과가 실행 순서에 따라 달라진다. 그래서 픽스처는 beforeEach로 각 테스트 직전에 새로 만든다.

describe("province", function () {
  let asia
  beforeEach(function () {
    asia = new Province(sampleProvinceData()) // 매 테스트마다 새 객체
  })
  it("shortfall", function () {
    expect(asia.shortfall).equal(5)
  })
  it("profit", function () {
    expect(asia.profit).equal(230)
  })
})

beforeEach는 매번 도는 게 낭비 같지만, 그 대가로 각 테스트가 서로 완전히 독립이 된다는 보증을 산다. 테스트 간 결합이야말로 나중에 원인 모를 “가끔 깨지는 테스트”의 주범이다. 나는 pharos에서 소켓 상태를 다루는 테스트를 짤 때 이걸 뼈저리게 느꼈다. 이전 테스트가 남긴 소켓/파일이 다음 테스트에 스며들면 단독 실행은 통과, 전체 실행은 실패라는 최악의 조합이 나온다. 격리는 사치가 아니라 기본값이다.

정리

  • 리팩터링을 하고 싶으면 테스트부터. 테스트 없는 코드를 만나면 리팩터링에 앞서 테스트를 심는 게 첫 작업이다.
  • 새 테스트는 반드시 한 번은 실패시켜 봐라. 일부러 버그를 심어 빨간불을 확인해야 그 테스트가 무엇을 지키는지 증명된다.
  • 버그 리포트를 받으면 그 버그를 드러내는 실패 테스트부터 작성한 뒤 고친다.
  • 경계(0, 음수, 빈 컬렉션, 최대값)와 위험한 로직에 화력을 집중하고, 뻔한 코드는 비운다.
  • 픽스처는 beforeEach로 매번 새로 만들어 테스트를 서로 독립시킨다.
  • 스위트가 충분한지의 정량 기준은 없다. “누가 이 코드를 고장 내면 테스트가 잡아줄까?”라는 주관적 확신이 유일한 기준이다.