[testing] The Four Pillars of a good unit test, Mocks and test fragility

Donghoon Bae·2025년 8월 26일

testing

목록 보기
2/6

단위 테스트의 네 가지 기둥

좋은 단위 테스트의 가치는 네 가지 속성의 곱으로 정의된다: 회귀 방지, 리팩토링 저항, 빠른 피드백, 유지보수성이다. 어느 하나라도 0점이라면 테스트의 총 가치는 0이 된다.
이 속성들은 상호 보완적이지만 동시에 상충한다. 특히 회귀 방지·리팩토링 저항·빠른 피드백은 동시 극대화가 불가능하며, 보통 두 가지를 올리면 나머지 하나가 희생된다.

회귀 방지

회귀 방지는 코드 변경 후 기존 기능에 생기는 버그를 얼마나 잘 잡아내는가에 대한 지표다.
효과를 높이려면 다음을 고려한다. 테스트가 실행하는 코드의 양, 그 코드의 복잡도, 도메인 중요성. 즉, 비즈니스 로직처럼 복잡하고 중요한 경로를 넓게, 깊게 실행하는 테스트가 가치가 높다.

리팩토링 저항

리팩토링(관찰 가능한 동작을 바꾸지 않는 내부 구조 변경) 후에도 테스트가 안정적으로 통과해야 한다.
거짓 양성(false positives)의 주원인은 테스트가 SUT의 구현 세부 사항에 결합돼 있을 때다. 테스트는 내부의 how가 아니라 최종 결과인 what(관찰 가능한 동작)에 집중해야 한다.

빠른 피드백

피드백이 빠를수록 결함을 더 이른 시점에 발견하고 수정 비용을 줄일 수 있다.
실행 시간 단축을 위해 IO 의존성 제거, 순수 함수 단위 검증, 테스트 픽스처 정교화 등이 고려된다.

유지보수성

테스트의 크기(이해, 변경 용이성)와 외부 프로세스 종속성 유무가 유지보수 비용을 좌우한다.
작고 의도가 선명한 테스트, 최소한의 설정과 의존성으로도 의미 있는 보장을 제공하는 테스트가 유지보수성이 높다.

정확성과 신호 대 잡음

회귀 방지(거짓 음성 최소화)와 리팩토링 저항(거짓 양성 최소화)은 합쳐서 테스트의 정확성을 이룬다.
프로젝트가 커질수록 거짓 양성의 비용이 급격히 커지므로, 정확성은 버그 신호를 키우고 허위 경보 잡음을 줄이는 일로 이해해야 한다.

트레이드오프와 테스트 피라미드

  • End-to-end tests: 회귀 방지, 리팩토링 저항은 높지만 느리므로 빈도와 범위를 제한한다.
  • Unit tests: 빠르지만 회귀 방지는 낮다. 남겨둘 가치가 있는지 임계치를 높게 잡는다.
  • Integration tests: 빠르고 많이 잡지만 구현 결합으로 거짓 양성이 잦다. 구조적 개선이 필요하다.

피라미드는 하위에 빠르고 견고한 단위 테스트, 중간에 통합 테스트, 상위에 소수의 End-to-end 테스트로 균형을 맞추되, 모든 층이 리팩토링 저항을 목표로 해야 한다.

블랙박스 vs 화이트박스

작성은 블랙박스(관찰 가능한 동작 기준)를 지향하고, 분석은 화이트박스(커버리지, 데드 코드 탐지)를 보조로 활용한다.
공개 API는 관찰 가능한 동작을 노출하고, 구현 세부 사항은 감춰야 테스트가 안정적이다.

목, 스텁, 그리고 테스트 취약성

5장은 테스트 더블의 역할과 경계를 정리한다. 포인트는 상호작용의 방향과 관찰 가능성이다.

테스트 더블의 구분

  • 스텁(Stub): 들어오는 상호작용(데이터 조회, query)을 흉내 내 값만 반환한다.
  • 목(Mock): 나가는 상호작용(명령, command)을 흉내 내 호출, 인자, 순서를 검증한다.
    원칙: 스텁과의 상호작용을 검증하지 않는다. 스텁 호출 검증은 구현 세부 사항 결합이며 취약성을 유발한다.

관찰 가능한 동작과 구현 세부 사항

관찰 가능한 동작은 클라이언트 목표 달성에 도움을 주는 작업 또는 상태다. 그 밖의 내부 통신은 구현 세부 사항이다.
좋은 API는 관찰 가능한 동작만을 공개하고 나머지는 캡슐화한다. 묻지 말고 시켜라 원칙은 불필요한 질의-분기 로직을 줄여 결합과 취약성을 낮춘다.

어디에 목을 쓸 것인가

  • 시스템 내 통신(내부 클래스 간 협력): 구현 세부 사항이므로 목 검증은 취약성을 키운다.
  • 시스템 간 통신(메일 서버, 메시지 버스, 외부 API): 애플리케이션 수준의 관찰 가능한 동작을 구성하므로 목으로 상호작용 계약을 검증할 수 있다.
    외부 프로세스라도 애플리케이션 내부에서만 접근하는 데이터베이스처럼 구현 세부 사항인 경우에는 실제 인스턴스로 테스트하는 편이 안정적이다.

학파 비교와 실천적 가이드

  • 런던 학파: 광범위한 목 사용은 내부 통신까지 검증 대상으로 만들며, 리팩토링 저항을 낮추기 쉽다.
  • 고전 학파: 테스트 간 격리를 유지하되, 공유, 외부 프로세스 의존성에만 더블을 신중히 도입한다.
    실천 요령: 목은 시스템 경계에서만, 스텁은 데이터 공급자로만, 나머지는 실제 구현으로 검증한다.

Example

1) 커맨드(외부 발송)에는 목으로 검증

외부 알림 발송은 관찰 가능한 동작의 일부다. 목으로 상호작용을 검증한다.

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!")
}

외부 시스템과의 통신은 시스템 간 계약이므로 목으로 무엇을 보냈는가를 검증한다.

2) 쿼리(데이터 조회)에는 스텁으로 값만 주입

읽기 전용 입력은 값을 제공만 하고, 상호작용 검증은 하지 않는다. 결과만 본다.

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")
}

스텁 호출 횟수, 인자 검증은 하지 않는다. 오직 최종 결과만 검증한다.

3) 내부 협력은 결과 중심, E2E는 소수 정예

내부 서비스 간 호출은 구현 세부 사항이므로 목 대신 결과, 상태 변이, 퍼시스턴스 결과를 검증한다.
상위 E2E는 사용자 시나리오 관점에서 극소수만 유지하고, 나머지는 도메인 단위의 통합 테스트로 비용 대비 보장을 극대화한다.

설계와 테스트의 맞물림

  • API 설계: 공개 API는 관찰 가능한 동작과 일치해야 하며, 내부 구현은 캡슐화한다.
  • 아키텍처: 헥사고날(또는 청결 아키텍처)로 도메인과 인프라를 분리하면, 목을 쓸 경계와 실제 구현을 쓸 내부가 자연스럽게 구분된다.
  • 전략: 리팩토링 저항은 타협하지 말고, 회귀 방지와 빠른 피드백 사이에서 맥락에 맞게 균형을 잡는다. 테스트는 적을수록 좋다가 아니라, 곱의 가치가 큰 소수 정예가 좋다.

체크리스트

  • 관찰 가능한 동작을 검증하는가, 구현 세부 사항을 검증하는가?
  • 스텁 호출을 검증하지 않고 값만 제공하는가?
  • 목은 시스템 경계(외부 커뮤니케이션)에만 쓰는가?
  • 테스트가 비즈니스 의미가 큰 경로를 충분히 실행하는가?
  • 실행 속도와 유지보수성 임계치를 충족하는가?
  • 리팩토링 시 거짓 양성이 거의 발생하지 않는가?

마무리

핵심은 단순하다. 리팩토링 저항을 최우선으로 두고, 회귀 방지와 빠른 피드백 사이의 균형을 맥락에 맞게 최적화하는 것 이다.
목은 외부 커뮤니케이션 계약을 지키는 도구이며, 스텁은 데이터를 주는 조연일 뿐이다. 내부 구현에 결합된 테스트는 결국 발목을 잡는다.

profile
iOS & Swift

0개의 댓글