좋은 단위 테스트의 가치는 네 가지 속성의 곱으로 정의된다: 회귀 방지, 리팩토링 저항, 빠른 피드백, 유지보수성이다. 어느 하나라도 0점이라면 테스트의 총 가치는 0이 된다.
이 속성들은 상호 보완적이지만 동시에 상충한다. 특히 회귀 방지·리팩토링 저항·빠른 피드백은 동시 극대화가 불가능하며, 보통 두 가지를 올리면 나머지 하나가 희생된다.
회귀 방지는 코드 변경 후 기존 기능에 생기는 버그를 얼마나 잘 잡아내는가에 대한 지표다.
효과를 높이려면 다음을 고려한다. 테스트가 실행하는 코드의 양, 그 코드의 복잡도, 도메인 중요성. 즉, 비즈니스 로직처럼 복잡하고 중요한 경로를 넓게, 깊게 실행하는 테스트가 가치가 높다.
리팩토링(관찰 가능한 동작을 바꾸지 않는 내부 구조 변경) 후에도 테스트가 안정적으로 통과해야 한다.
거짓 양성(false positives)의 주원인은 테스트가 SUT의 구현 세부 사항에 결합돼 있을 때다. 테스트는 내부의 how가 아니라 최종 결과인 what(관찰 가능한 동작)에 집중해야 한다.
피드백이 빠를수록 결함을 더 이른 시점에 발견하고 수정 비용을 줄일 수 있다.
실행 시간 단축을 위해 IO 의존성 제거, 순수 함수 단위 검증, 테스트 픽스처 정교화 등이 고려된다.
테스트의 크기(이해, 변경 용이성)와 외부 프로세스 종속성 유무가 유지보수 비용을 좌우한다.
작고 의도가 선명한 테스트, 최소한의 설정과 의존성으로도 의미 있는 보장을 제공하는 테스트가 유지보수성이 높다.
회귀 방지(거짓 음성 최소화)와 리팩토링 저항(거짓 양성 최소화)은 합쳐서 테스트의 정확성을 이룬다.
프로젝트가 커질수록 거짓 양성의 비용이 급격히 커지므로, 정확성은 버그 신호를 키우고 허위 경보 잡음을 줄이는 일로 이해해야 한다.
피라미드는 하위에 빠르고 견고한 단위 테스트, 중간에 통합 테스트, 상위에 소수의 End-to-end 테스트로 균형을 맞추되, 모든 층이 리팩토링 저항을 목표로 해야 한다.
작성은 블랙박스(관찰 가능한 동작 기준)를 지향하고, 분석은 화이트박스(커버리지, 데드 코드 탐지)를 보조로 활용한다.
공개 API는 관찰 가능한 동작을 노출하고, 구현 세부 사항은 감춰야 테스트가 안정적이다.
5장은 테스트 더블의 역할과 경계를 정리한다. 포인트는 상호작용의 방향과 관찰 가능성이다.
관찰 가능한 동작은 클라이언트 목표 달성에 도움을 주는 작업 또는 상태다. 그 밖의 내부 통신은 구현 세부 사항이다.
좋은 API는 관찰 가능한 동작만을 공개하고 나머지는 캡슐화한다. 묻지 말고 시켜라 원칙은 불필요한 질의-분기 로직을 줄여 결합과 취약성을 낮춘다.
외부 알림 발송은 관찰 가능한 동작의 일부다. 목으로 상호작용을 검증한다.
protocol NotificationGateway {
func send(to: String, message: String) throws
}
final class SendWelcomeService {
private let gateway: NotificationGateway
init(gateway: NotificationGateway) {
self.gateway = gateway
}
func execute(userEmail: String) throws {
// 도메인 규칙 일부 생략
try gateway.send(to: userEmail, message: "Welcome!")
}
}
final class NotificationGatewayMock: NotificationGateway {
private(set) var calls: [(to: String, message: String)] = []
func send(to: String, message: String) throws {
calls.append((to, message))
}
}
@Test("execute()는 환영 메시지를 외부 게이트웨이로 전송한다")
func test_execute_sendsWelcomeMessage() throws {
let mock = NotificationGatewayMock()
let sut = SendWelcomeService(gateway: mock)
try sut.execute(userEmail: "a@ex.com")
#expect(mock.calls.count == 1)
#expect(mock.calls.first?.to == "a@ex.com")
#expect(mock.calls.first?.message == "Welcome!")
}
외부 시스템과의 통신은 시스템 간 계약이므로 목으로 무엇을 보냈는가를 검증한다.
읽기 전용 입력은 값을 제공만 하고, 상호작용 검증은 하지 않는다. 결과만 본다.
protocol UserRepository {
func find(id: UUID) -> User?
}
struct User {
let id: UUID
let active: Bool
}
final class UserProfileService {
private let repo: UserRepository
init(repo: UserRepository) {
self.repo = repo
}
func profileSummary(id: UUID) -> String {
guard let user = repo.find(id: id) else {
return "NotFound"
}
return user.active ? "Active" : "Inactive"
}
}
final class UserRepositoryStub: UserRepository {
var result: User?
func find(id: UUID) -> User? {
result
}
}
@Test("활성 유저면 요약에 Active를 반환한다")
func test_profileSummary_returnsActive() {
let stub = UserRepositoryStub()
stub.result = User(id: UUID(), active: true)
let sut = UserProfileService(repo: stub)
let summary = sut.profileSummary(id: UUID())
#expect(summary == "Active")
}
@Test("유저가 없으면 NotFound를 반환한다")
func test_profileSummary_notFound() {
let stub = UserRepositoryStub()
stub.result = nil
let sut = UserProfileService(repo: stub)
let summary = sut.profileSummary(id: UUID())
#expect(summary == "NotFound")
}
스텁 호출 횟수, 인자 검증은 하지 않는다. 오직 최종 결과만 검증한다.
내부 서비스 간 호출은 구현 세부 사항이므로 목 대신 결과, 상태 변이, 퍼시스턴스 결과를 검증한다.
상위 E2E는 사용자 시나리오 관점에서 극소수만 유지하고, 나머지는 도메인 단위의 통합 테스트로 비용 대비 보장을 극대화한다.
핵심은 단순하다. 리팩토링 저항을 최우선으로 두고, 회귀 방지와 빠른 피드백 사이의 균형을 맥락에 맞게 최적화하는 것 이다.
목은 외부 커뮤니케이션 계약을 지키는 도구이며, 스텁은 데이터를 주는 조연일 뿐이다. 내부 구현에 결합된 테스트는 결국 발목을 잡는다.