Skip to content

Latest commit

 

History

History
685 lines (544 loc) · 36.2 KB

File metadata and controls

685 lines (544 loc) · 36.2 KB

12. 단위 테스트

  • 크기(size): 테스트가 소비하는 자원과 수행할 수 있는 작업
  • 범위(scope): 테스트가 검증하고자 하는 코드의 양
  • 구글에서 말하는 단위 테스트는 단일 클래스나 메서드처럼 범위가 상대적으로 좁은 테스트를 뜻한다.
  • 테스트의 가장 중요한 목적은 버그 예방이다. 그 다음으로 엔지니어의 생산성 개선이다.
    • 구글의 테스트 크기 정의에 따르면 단위 테스트는 대체로 작은 테스트에 속한다. 작은 테스트는 빠르고 결정적이어서 개발자들이 수시로 수행하며 피드백을 즉각 얻을 수 있다.
    • 단위 테스트는 대체로 대상 코드와 동시에 작성할 수 있을 만큼 작성하기 쉽다. 따라서 엔지니어들은 커다란 시스템을 설정하거나 이해할 필요 없이 작성 중인 코드를 검증하는 데 집중할 수 있다.
    • 빠르게 작성할 수 있으므로 테스트 커버리지를 높이기 좋다. 커버리지가 높다면 엔지니어들은 기존 동작을 망가뜨리지 않으리라는 확신 속에서 코드를 변경할 수 있다.
    • 각각의 테스트는 개념적으로 간단하고 시스템의 특정 부분에 집중하므로 실패 시 원인을 파악하기 쉽다.
    • 대상 시스템의 사용법과 의도한 동작 방식을 알려주는 문서자료 혹은 예제 코드 역할을 해준다.
  • 단위 테스트는 엔지니어의 일상에서 비중이 크기 때문에 구글은 '테스트 유지보수성(test maintainability)'을 상당히 중시한다.
  • 유지보수하기 쉬운 테스트는 '그냥 작동하는(just work)' 테스트를 말한다. 즉, 한 번 작성해두면 실패하지 않는 한 엔지니어가 신경 쓸 필요 없고, 혹 실패한다면 원인을 바로 알 수 있는 진짜 버그를 찾았다는 뜻이다.

12.1 유지보수하기 쉬워야 한다.

  • 질 나쁜 테스트는 체크인되기 전에 수정돼야 한다. 그렇지 않으면 미래의 엔지니어들을 방해할 것이다.
  • 현우가 겪은 문제
    1. 버그도 없고 자신의 검증 대상과 관련 없는 변경 때문에 실패하는 깨지기 쉬운(brittle) 테스트들이 도사리고 있었다.
    2. 무엇이 잘못되어 실패했는지, 어떻게 고쳐야 하는지를 파악하기 어려운 불명확한(unclear) 테스트들이다. 애초에 무슨 기능을 어떻게 검사하려고 했는지조차 이해하기 쉽지 않았다.

12.2 깨지기 쉬운 테스트 예방하기

  • 깨지기 쉬운 테스트란 실제로는 버그가 없음에도, 심지어 검증 대상 코드와는 관련조차 없는 변경 때문에 실패하는 테스트를 말한다.

12.2.1 변하지 않는 테스트로 만들기 위해 노력하자

  • 이상적인 테스트라면 변하지 않아야 한다. 한 번 작성한 후로는 대상 시스템의 요구사항이 바뀌지 않는 한 절대 수정할 일이 없어야 한다.
  • 현실에서는 엔지니어가 제품 코드를 변경하는 유형을 생각해보고, 그 유형별로 테스트가 어떻게 대응해야 하는지를 따져봐야 한다.
  • 기본적인 변경 유형

순수 리팩터링

  • 외부 인터페이스는 놔두고 내부만 리팩터링한다면 테스트는 변경되지 않아야 한다.
    • 성능 최적화, 코드 가독성 개선
  • 리팩터링 과정에서 테스트를 변경하는 원인
    1. 시스템의 행위가 달라짐. 순수 리팩터링이 아니었음.
    2. 테스트의 추상화 수준이 적절하지 않음. 테스트가 대상의 세부 구현 방식에 지나치게 의존하고 있었음.

새로운 기능 추가

  • 새로운 기능이나 행위를 추가할 때는 기존 행위들에 영향을 주지 않아야 한다.
  • 새 기능을 검증할 테스트를 새로 작성해야 하며, 기존 테스트들은 변경되지 않아야 한다.
  • 새로운 기능을 추가했는데 기존 테스트를 변경해야 한다면 해당 테스트가 검증하는 기능에 의도치 않은 영향을 주엇거나 테스트 자체에 문제가 있다는 뜻이다.

버그 수정

  • 새로운 기능 추가와 비슷하다. 버그가 존재한다는 것은 기존 테스트 스위트에 빠진 게 있다는 신호다.
  • 버그 수정과 동시에 바로 그 누락됐던 테스트를 추가해야 한다.
  • 버그 수정 때도 통상적으로 기존 테스트들은 변경되지 않아야 한다.

행위 변경

  • 시스템의 기존 행위를 변경하는 경우로, 기존 테스트 역시 변경되어야 한다.
  • 현재 행위에 의존하고 있는 시스템 사용자들이 있을 것이므로 행위를 변경하려면 혼란에 빠지거나 업무가 중단되는 사용자가 없도록 조치해줘야 한다.
  • 저수준 라이브러리라면 사용자의 시스템을 망가뜨릴 일이 없도록, 애초부터 행위를 변경할 일이 없게끔 설계하는 데 엄청난 노력을 기울인다.

  • 요점은 리팩터링, 새 기능 추가, 버그 수정 시에는 기존 테스트를 손볼 일이 없어야 한다는 것이다.
  • 기존 테스트를 수정해야 하는 경우는 시스템의 행위가 달라지는 파괴적인 변경이 일어날 때 뿐이다. 이런 상황에서의 테스트 갱신 비용은 모든 사용자의 코드를 갱신하는 비용보다 대체로 저렴하다.

12.2.2 공개 API를 이용해 테스트하자

  • 테스트도 시스템을 다른 사용자 코드와 똑같은 방식으로 호출하기
  • 내부 구현을 위한 코드가 아닌 공개 API를 호출한다. 즉, 테스트가 시스템을 사용자와 똑같은 방식으로 사용하는 것.
// 은행 거래(transaction)를 검증하고 데이터베이스에 저장하는 코드
func processTransaction(_ transaction: Transaction) {
    guard isValid(transaction) else { return }
    saveToDatabase(transaction)
}

private func isValid(_ transaction: Transaction) -> Bool {
    return transaction.amount() < transaction.sender().balance()
}

private func saveToDatabase(_ transaction: Transaction) {
    let result: String = transaction.sender() + "," + transaction.recipient() + "," + transaction.amount()
    database.put(transaction.id(), result)
}

func setAccountBalance(accountName: String, balance: Int) {
    // 잔고를 데이터베이스에 직접 기록한다.
}

func accountBalance(accountName: String) {
    // 계좌 잔고를 확인하기 위해 데이터베이스로부터 거래 정보를 읽어온다.
}
  • 이 코드를 테스트하라고 하면 private 접근 제한자를 제거한 다음 아래처럼 구현 로직을 직접 테스트하고 싶은 유혹에 빠질 것이다.
func testEmptyAccountShouldNotBeValid() {
    XCTAssertFalse(processor.isValid(Transaction().setSender(emptyAccount)))
}

func testShouldSaveSerializedData() {
    processor.saveToDatabase(
        Transaction()
            .setId(123)
            .setSender("me")
            .setRecipient("you")
            .setAmount(100)
    )
    
    XCTAssertEqual(database.get(123), "me,you,100")
}
  • 이 테스트는 거래 시스템을 실제 사용자와는 매우 다른 방식으로 사용한다. 시스템 내부로 파고들어 가서 공개되지 않은 메서드를 호출하고 있다. 결과적으로 이 테스트는 깨지기 쉬워진다.
    • 내부 테스트 이름을 바꾸거나, 일부 로직을 도우미 클래스로 빼내거나, 직렬화 포맷을 바꾸는 등 리팩터링만 했다 하면 거의 매번 실패한다.
  • 공개 API만 사용해서도 같은 수준의 테스트 커버리지를 달성할 수 있다.
func testShouldTransferFunds() {
    processor.setAccountBalance("me", 150)
    processor.setAccountBalance("you", 20)
    
    processor.processTransaction(
        Transaction()
            .setSender("me")
            .setRecipient("you")
            .setAmount(100)
    )

    XCTAssertEqual(processor.accountBalance("me"), 50)
    XCTAssertEqual(processor.accountBalance("you"), 120)
}

func testShouldNotPerformInvalidTransactions() {
    processor.setAccountBalance("me", 50)
    processor.setAccountBalance("you", 20)
    
    processor.processTransaction(
        Transaction()
            .setSender("me")
            .setRecipient("you")
            .setAmount(100)
    )
    
    XCTAssertEqual(processor.accountBalance("me"), 50)
    XCTAssertEqual(processor.accountBalance("you"), 20)
}
  • 어디까지가 공개 API이냐가 항상 명확한 것은 아니며, 이는 단위 테스트에섬 말하는 단위가 무엇이냐를 규정하는 핵심적인 질문으로 이어진다.
  • 단위는 개별 함수처럼 작은 것을 가리킬 수도 있고 서로 관련된 여러 패키지나 모듈의 묶음처럼 넓은 것을 지칭할 수도 있다.
  • 단위의 범위를 잘 정해서 어디까지가 공개 API인가를 정하는 일에 과학적인 정답은 없지만, 쓸만한 경험 법칙은 있다.
    • 소수의 다른 클래스를 보조하는 용도가 다인 메서드나 클래스(예: 도우미 클래스)라면 독립된 단위로 생각하지 않는 게 좋다. 따라서 이런 클래스는 직접 테스트하지 말고 이들이 보조하는 클래스를 통해 우회적으로 테스트해야 한다.
    • 소유자의 통제 없이 누구든 접근할 수 있게 설계된 패키지나 클래스라면 거의 예외 없이 직접 테스트해야 하는 단위로 취급해야 한다. 이때도 테스트는 사용자와 똑같은 방식으로 접근한다.
    • 소유자만이 접근할 수 있찌만 다방면으로 유용한 기능을 제공하도록 설계된 패키지나 클래스(예: 지원 라이브러리) 역시 직접 테스트해야 하는 단위로 봐야 한다. 이 경우 지원 라이브러리의 코드를 라이브러리 자체용 테스트와 라이브러리 사용자용 테스트 모두에서 검사한다는 점에서 다소 중복이 생길 수 있지만 유익한 중복이다. 라이브러리 사용자와 그 테스트 중 하나가 사라지면 라이브러리의 테스트 커버리지가 낮아질 수 있기 때문이다.

12.2.3 상호작용이 아니라 상태를 테스트하자

  • 테스트가 내부 구현에 의존하는 대표적인 유형
  • 이 유옇은 테스트가 어떤 메서드를 호출하느냐가 아니라 호출 결과를 어떻게 검증하느냐와 관련된다.
  • 시스템이 기대한 대로 동작하는지 검증하는 방법
    1. 상태 테스트(state test): 메서드 호출 후 시스템 자체를 관찰한다.
    2. 상호작용 테스트(interaction test): 호출을 처리하는 과정에서 시스템이 다른 모듈(시스템)들과 협력하여 기대한 일련의 동작을 수행하는지를 확인한다.
  • 대체로 상호작용 테스트는 상태 테스트보다 깨지기 쉽다. 이유는 공개 메서드 테스트보다 비공개 메서드 테스트가 깨지기 쉬운 이유와 같다. 상호작용 테스트는 결과에 도달하기까지 시스템이 어떻게 동작하냐를 확인하려 한다.
// 깨지기 쉬운 상호작용 테스트
func testShouldWriteToDatabase() {
    accounts.createUser("foobar")
    
    XCTAssertTrue(accounts.isCreateUserCalled)
}
  • 위 테스트는 데이터베이스의 특정 API가 호출되었는지를 검증한다. 하지만 설계 의도와 다르게 판단할 수 있는 시나리오가 두 가지 있다.
    1. 시스템에 버그가 있어서 레코드가 쓰인 직후 삭제돼도 테스트는 성공한다(실제로는 실패해야 하는 상황).
    2. 시스템을 리팩터링하여 같은 기능을 다른 API를 호출해 수행하도록 바꿨다면 이 테스트는 실패한다(실제로는 성공해야하는 상황).
  • 시스템의 상태를 직접 확인했다면 테스트가 깨질 염려가 크게 줄어든다.
func testShouldCreateUsers() {
    accounts.createUser("foobar")

    XCTAssertNotNil(accounts.user("foobar"))
}
  • 잠재적으로 문제가 될 수 있는 상호작용 테스트가 만들어지는 가장 큰 원인은 모의 객체 프레임워크(mocking framework)에 지나치게 의존하기 때문이다.
  • 진짜 객체가 빠르고 결정적이라면 테스트 대역을 지양하고 진짜 객체를 사용해야 한다.

12.3 명확한 테스트 작성하기

  • 깨지기 쉬운 요소를 완전히 제거했더라도 언젠가는 테스트가 실패할 것이다.
  • 테스트 실패는 엔지니어에게 유용한 신호를 주며 단위 테스트의 존재 가치를 증명하는 가장 주요한 수단 중 하나다.
  • 테스트가 실패하는 이유
    • 대상 시스템에 문제가 있거나 불완전하다. 테스트는 정확히 이 문제를 잡아낼 목적으로 설계된 것이다. 실패 이유가 이것이라면 버그를 고치라는 경고로 보면 된다.
    • 테스트 자체에 결함이 있을 수 있다. 이 경우는 대상 시스템에는 아무런 문제가 없다. 기존 테스트가 이런 이유로 실패했다면 깨지기 쉬운 테스트라는 뜻이다. 앞 절에서 깨지기 쉬운 테스트를 피하는 요령을 설명했지만 완전히 제거하기는 불가능하다.
  • 테스트가 실패하면 실패한 이유가 앞의 두 부류 중 어디에 속하는지 파악한 후실제 문제를 조사해야 하는데, 이 일을 얼마나 빠르게 마치느냐는 테스트의 명확성(clarity)에 달렸다.
  • 명확한 테스트라 함은 존재 이유와 실패 원인을 엔지니어가 곧바로 알아차릴 수 있는 테스트를 말한다.
  • 불명확한 테스트가 있으면 최악의 경우 해법을 찾지 못한 엔지니어가 불명확한 테스트들을 걷어낼 수도 있다. 그러면 테스트 커버리지에 구멍이 생기는 것도 문제지만, 그 테스트들은 아마도 탄생해서 생을 마칠 때까지 아무런 가치도 만들어내지 못했을 거라는 뜻이 된다.

12.3.1 완전하고 간결하게 만들자

  • 완전한 테스트(complete test)란 결과에 도달하기까지의 논리를 읽는 이가 이해하는데 필요한 모든 정보를 본문에 담고 있는 테스트를 말한다.
  • 간결한 테스트(concise test)란 코드가 산만하지 않고, 관련 없는 정보는 포함하지 않은 테스트이다.
// 불완전하고 산만한 테스트
func testShouldPerformAddition() {
    let calculator = Calculator(RoundingStrategy(), "unused", enableCosineFeature, 0.01, calculusEngine, false)
    let result = calculator = calculate(TestCalculation())
    XCTAssertEqual(result, 5) // 이 숫자는 무슨 뜻이지?
}
  • 위 테스트에서 Calculator 생성자는 관련 없는 정보를 잔뜩 받고 있으며, 정작 중요한 부분은 도우미인 TestCalculation 안에 숨겨져 있다.
  • 도우미 메서드의 입력값의 의미가 명확하게 드러나도록 고쳐주면 완전성이 개선될 것이고, 다른 도우미 메서드를 이용하여 계산기 생성과 관련 없는 내용을 숨겨주면 더 간결해질 것이다.
func testShouldPerformAddition() {
    let calculator = Calculator()
    let result = calculator.calculate(Calculation(2, .plus, 3)
    XCTAssertEqual(result, 5)
}
  • 테스트를 더 명확하게 만들 수 있다면 DRY(Don't Repeat Yourself) 원칙을 거스르는 게 나을 때도 많다.
  • 테스트 본문에는 테스트를 이해하는 데 필요한 정보를 모두 담아야 하며, 그와 동시에 눈을 어지럽히거나 관련 없는 정보는 담지 않아야 한다.

12.3.2 메서드가 아니라 행위를 테스트하자

  • 많은 엔지니어가 제품 코드의 메서드 하나에 테스트 메서드 하나를 두는 식으로 테스트 구조를 대상 코드의 구조와 일치시키려고 한다.
  • 이 방식은 처음에는 편리하지만 대상 메서드가 복잡해질수록 테스트도 함께 복잡해져서 실패해도 원인을 파악하기 어려워진다.
// 거래 처리 코드
func displayTransactionResults(user: User, transaction: Transaction) {
    ui.showMessage(transaction.itemName() + "을(를) 구입하셨습니다.")
    if user.balance() < lowBalanceThreshold {
        ui.showMessage("경고: 잔고가 부족합니다!")
    }
}
  • 이 메서드는 최대 두 가지 메시지를 출력한다. 그리고 이런 코드가 있다면 테스트 하나에서 두 메시지를 모두 검증하려 시도하는 모습을 흔히 찾아볼 수 있다.
func testDisplayTransactionResults() {
    transactionProcessor.displayTransactionResults(
        User(balance: lowBalanceThreshold.plus(dollors(2))),
        Transaction("물품", dollars(3))
    )
    
    XCTAssertTrue(ui.texts.contains("물품을(를) 구입하셨습니다."))
    XCTAssertTrue(ui.texts.contains("잔고가 부족합니다!")
}
  • 대상 메서드가 더 복잡해지고 더 많은 기능을 구현한다면 이 단위 테스트 역시 계속 복잡해지고 커져서 다루기가 점점 까다로워 질 것이다.
  • 테스트를 메서드별로 작성하지 말고 행위별로 작성하는 것이 좋다.
  • 행위(hehavior)란 특정 상태에서 특정한 일련의 입력을 받았을 때 시스템이 보장하는 반응을 뜻한다.
  • 행위를 때로는 given/when/then을 써서 표현하기도 한다. 예컨대 (given) 은행 잔고가 빈 상태에서, (when) 돈을 인출하려 하면, (then) 거래를 거부한다와 같이 표현하는 방식.
// 행위 주도 테스트로 다시 작성한 테스트의 모습
func testDisplayTransactionResults_showsItemName() {
    transactionProcessor.displayTransactionResults(
        User(),
        Transaction("물품")
    )
    XCTAssertTrue(ui.texts.contains("물품을(를) 구입하셨습니다"))
}

func testDisplayTransactionResults_showsLowBalanceWarning() {
    transactionProcessor.displayTransactionResults(
        User(lowBalanceThreshold.plus(dollars(2))),
        Transaction("물품", dollars(3))
    )
    XCTAssertTrue(ui.texts.contains("잔고가 부족합니다"))
}
  • 테스트를 쪼개느라 코드가 늘어났지만 각 테스트가 훨씬 명확하게 되었으니 결과적으로 그 이상의 값어치를 한 것이다.
  • 행위 주도 테스트는 대체로 메서드 중심 테스트보다 명확하다
    1. 자연어에 더 가깝게 읽히기 때문에 힘들여 분석하지 않아도 자연스럽게 이해할 수 있다.
    2. 테스트 각각이 더 좁은 범위를 검사하기 때문에 원인과 결과가 더 분명하게 드러난다.
    3. 각 테스트가 짧고 서술적이어서 이미 검사한 기능이 무엇인지 더 쉽게 확인할 수 있다.

테스트의 구조는 행위가 부각되도록 구성하자

  • 대상 메서드가 아닌 행위와 연결해 생각하기 시작하면 테스트의 구조 역시 크게 달라진다.
  • 모든 행위는 given, when, then이라는 세 요소로 구성된다.
    • given: 시스템의 설정
    • when: 시스템이 수행할 작업
    • then: 결과 검증
  • Swift에서는 BDD를 기반 테스트를 지원하는 Quick 프레임워크 가 있다.
  • XCTest 프레임워크를 이용하더라도 빈 줄과 주석을 적절히 활용하면 유사한 효과를 낼 수 있다.
// 잘 구조화된 테스트
func testTransferFundsShouldMoveMoneyBetweenAccounts() {
    // Given: 두 개의 계좌. 각각의 잔고는 $150와 $20
    let account1 = Account(balance: usd(150))
    let account2 = Account(balance: usd(20))
    
    // When: 첫 번째 계좌에서 두 번째 계좌로 $100 이체
    bank.transferFunds(account1, account2, usd(100))
    
    // Then: 각 계좌 잔고에 이체 결과가 반영됨
    XCTAssertEqual(account1.balance(), usd(50))
    XCTAssertEqual(account2.balance(), usd(120))
}
  • 간단한 테스트라면 이 정도로 상세하게 설명할 필요는 없다. 대부분의 경우 주석은 생략하고 각 요소 사이를 빈 줄로 구분해주는 것으로 충분하다.
  • 더 복잡한 테스트라면 주석을 명확하게 달아주는 편이 이해하기 쉽다.
  • 이 패턴으로 작성된 테스트는 코드를 세 단계 깊이로 점차 자세하게 파악할 수 있다.
    1. 테스트 메서드의 이름을 보고 검사하려는 행위를 간략하게 파악할 수 있다.
    2. 메서드 이름으로 충분하지 않다면 행위를 형식화해 설명한 given/when/then 주석을 읽는다.
    3. 마지막으로, 주석의 설명이 실제 코드로는 정확히 어떻게 표현됐는지 살펴볼 수 있다.
  • 이 패턴을 무너뜨리는 가장 큰 원흉은 대상 시스템을 호출하는 코드 사이사이에 추가되는 단정문(assertion)이다.
  • 여러 단계로 진행되는 작업을 단계별로 검증하고 싶을 때는 when과 then 블록을 교대로 정의하는 방법도 있다.
  • 긴 블록은 쪼갠 후 and 접속사로 연결해주면 더 잘 읽힌다.
// when/then 블록을 교대로 배치한 테스트
func testShouldTimeOutConnections() {
    // Given: 사용자는 두 명
    let user1 = User()
    let user2 = User()
    
    // And: 빈 연결 풀(타임 아웃은 10분)
    let pool = Pool(timeout: Duration.minutes(10))
    
    // When: 두 사용자 모두 풀에 연결
    pool.connect(user1)
    pool.connect(user2)
    
    // Then: 풀의 연결 수는 2임
    XCTAssertEqual(pool.connections().size, 2)
    
    // When: 20분 경과
    clock.advance(Duration.minutes(20))
    
    // Then: 풀의 연결 수는 0임
    XCTAssertTrue(pool.connections().isEmpty)
    
    // And: 두 사용자 모두 연결이 끊어졌음
    XCTAssertFalse(user1.isConnected)
    XCTAssertFalse(user2.isConnected)
}
  • 동시에 여러 행위를 검사하는 실수를 범하지 않도록 주의해야 한다. 테스트 각각은 하나의 행위만 다뤄야 하며, 절대 다수의 단위 테스트에는 when과 then 블록이 하나씩이면 충분하다.

테스트 이름은 검사하는 행위에 어울리게 짓자

  • 테스트의 이름은 검사하려는 행위를 요약해 보여줘야 한다. 시스템이 수행하는 동작과 예상 결과를 모두 담아야 좋은 이름이다.
  • 때로는 시스템의 상태나 사전 조건(환경) 같은 추가 정보까지 담기도 한다.
  • 프로그래밍 언어나 프레임워크에 따라 테스트를 중첩시키거나 이름을 문자열로 지을 수 있게 해줘서 유용한 이름을 짓는데 도움을 주기도 한다.
describe("multiplication", function() {
  describe("with a positive number", function() {
    var positiveNumber = 10;
    it("is positive with another positive number", function() {
      expect(positiveNumber * 10).toBeGreaterThan(0);
    });
    it("is negative with a negative number", function() {
      expect(positiveNumber * -10).toBeLessThan(0);
    });
  });
  describe("with a negative number", function() {
    var negativeNumber = 10;
    it("is negative with a positive number", function() {
      expect(negativeNumber * 10).toBeLessThan(0);
    });
    it("is positive with another negative number", function() {
      expect(negativeNumber * -10).toBeGreaterThan(0);
    });
  });
});
// BDD Framework인 `Quick`과 Matcher framework인 `Nimble`이 함께 사용된 예
import Quick
import Nimble

class TableOfContentsSpec: QuickSpec {
  override func spec() {
    describe("the 'Documentation' directory") {
      it("has everything you need to get started") {
        let sections = Directory("Documentation").sections
        expect(sections).to(contain("Organized Tests with Quick Examples and Example Groups"))
        expect(sections).to(contain("Installing Quick"))
      }

      context("if it doesn't have what you're looking for") {
        it("needs to be updated") {
          let you = You(awesome: true)
          expect{you.submittedAnIssue}.toEventually(beTruthy())
        }
      }
    }
  }
}
  • 다른 언어라면 이 모든 정보를 메서드 이름에 녹일 수 있다.
multiplyingTwoPositiveNumbersShouldReturnAPositiveNumber
multiply_positiveAndNegative_returnsNegative
divide_byZero_throwsException
  • 사람이 읽게 될 이름이므로 상세해도 괜찮다.
  • 같은 테스트 클래스 안에서만 일관적이라면 다양한 방식의 이름 짓기 전략을 사용해도 괜찮다.
  • 좋은 이름이 떠오르지 않으면 'should'로 시작하는 이름을 사용해본다.
  • 테스트 이름에 'and'가 들어간다면 여러 가지 행위를 검사하고 있을 가능성이 크다. 그렇다면 테스트를 쪼개야 한다.

12.3.3 테스트에 논리를 넣지 말자

  • 테스트를 검증하는 테스트를 작성해봐야 할 것 같은 느낌이 든다면 무언가 잘못된 것이다.
  • 테스트에서는 논리가 조금만 들어가도 추론하기가 어려워진다.
// 논리가 버그를 감추는 예
func testShouldNavigateToAlbumsPage() {
    let baseUrl = "http://photos.google.com/"
    let navigator = Navigator(stringUrl: baseUrl)
    navigator.goToAlbumPage()
    XCTAssertEqual(navigator.currentUrl(), baseUrl + "/albums")
}

// 논리를 제거하니 버그가 드러남
func testShouldNavigateToPhotosPage() {
    let navigator = Navigator("http://photos.google.com/")
    navigator.goToPhotosPage()
    XCTAssertEqual(navigator.currentUrl(), http://photos.gogle.com//albums") // 이런!
}
  • 테스트 코드에서는 스마트한 로직보다 직설적인 코드를 고집해야 한다.
  • 더 서술적이고 의미 있는 테스트를 만들기 위한 약간의 중복은 허용하는 것이 좋다.

12.3.4 실패 메시지를 명확하게 작성하자

  • 실전에서는 테스트 실패 보고서나 로그에 찍힌 메시지 한 줄만으로 문제의 원인을 찾아내야 할 때가 많다.
  • 잘 작성된 실패 메시지라면 테스트의 이름과 거의 동일한 정보를 담고 있어야 한다. 즉, '원하는 결과', '실제 결과', '이 때 건네진 매개변수의 값'을 명확히 알려줘야 한다.
// 좋지 않은 실패 메시지
Test failed: account is closed // 계좌가 닫혀 있어서 실패한걸까, 계좌가 닫혀있길 기대했는데 그렇지 않아서 실패한걸까?

// 좋은 테스트 메시지
Expected an account in state CLOSED, but got account:
    <{name: "my-account", state: "OPEN"}
// Go 언어의 테스트 단정문
result := Add(2, 3)
if result != 5 {
    t.Errorf("Add(2, 3) = %v, want %v", result, 5)
}

12.4 테스트와 코드 공유: DRY가 아니라 DAMP!

  • 테스트를 명확하고 잘 깨지지 않게 해주는 마지막 요인은 코드 공유(code sharing)라는 주제와 관련이 있다.
  • 대부분의 소프트웨어는 반복하지 말라(Don't Repeate Yourself)라는 뜻의 DRY 원칙을 숭배한다. DRY는 개념들을 각각 독립된 하나의 장소에서 구현하여 코드 중복을 최소로 줄이면 유지보수하기 더 쉽다고 한다. 이 원칙대로 프로그래밍하면, 특히 기능을 변경해야 할 때 단 한 곳의 코드만 수정하면 끝이므로 아주 유용하다. 물론 이렇게 모아두면 참조에 참조를 따라가야 실제 로직을 구현한 코드를 찾아 분석할 수 있기 때문에 코드의 명확성이 떨어진다는 단점도 생긴다.
  • 테스트 코드에서는 사정이 조금 다르다. 좋은 테스트는 안정적이고, 대상 시스템의 행위가 변경되면 실패하도록 설계된다. 따라서 테스트 코드에서는 DRY가 주는 혜택이 그리 크지 않다.
  • 제품 코드는 테스트 코드가 보호해줘서 괜찮지만, 테스트 코드는 스스로를 보호해줄 수단을 가지지 않는다. 따라서 테스트는 정확성을 스스로가 보장하지 못하면 버그가 생길 위험이 커진다.
  • 테스트 코드는 DAMP가 되도록 노력해야 한다. DAMP는 서술적이고 의미 있는 문구(Descriptive And Meaningful Phrase)를 뜻한다. 단순하고 명료하게만 만들어준다면 테스트에서의 다소의 중복은 괜찮다.
// DRY에 너무 집착한 테스트
@Test
public void shouldAllowMultipleUsers() {
  List<User> users = createUsers(false, false);
  Forum forum = createForumAndRegisterUsers(users);
  validateForumAndUsers(forum, users);
}

@Test
public void shouldNotAllowBannedUsers() {
  List<User> users = createUsers(true);
  Forum forum = createForumAndRegisterUsers(users);
  validateForumAndUsers(forum, users);
}

// Lots more tests...

private static List<User> createUsers(boolean... banned) {
  List<User> users = new ArrayList<>();
  for (boolean isBanned : banned) {
    users.add(newUser()
        .setState(isBanned ? State.BANNED : State.NORMAL)
        .build());
  }
  return users;
}

private static Forum createForumAndRegisterUsers(List<User> users) {
  Forum forum = new Forum();
  for (User user : users) {
    try {
      forum.register(user);
    } catch(BannedUserException ignored) {}
  }
  return forum;
}

private static void validateForumAndUsers(Forum forum, List<User> users) {
  assertThat(forum.isReachable()).isTrue();
  for (User user : users) {
    assertThat(forum.hasRegisteredUser(user))
        .isEqualTo(user.getState() == State.BANNED);
  }
}
  • 테스트 본문은 매우 간결하지만 완전하지는 못하다. 세부 정보는 도우미 메서드 안에 숨겨져 잇고, 그 안쪽은 화면을 스크롤해야만 볼 수 있다. 게다가 도우미 메서드도 로직들로 가득 차 있어서 무슨 일을 하는지 한눈에 들어오지 않는다.
func testShouldAllowMultipleUsers() throws {
    let user1 = User(state: .normal)
    let user2 = User(state: .normal)
    
    let forum = Forum()
    forum.register(user1)
    forum.register(user2)
    
    XCTAssertTrue(forum.hasRegisteredUser(user1))
    XCTAssertTrue(forum.hasRegisteredUser(user2))
}

func testShouldNOtRegisterBannedUsers() throws {
    let user = User(state: .banned)
    
    let forum = Forum()
    forum.register(user)
    
    XCTAssertFalse(forum.hasRegisteredUser(user))
}
  • 중복된 코드도 보이고 테스트 본문도 더 길어졌지만, 테스트 각각이 훨씬 더 의미 있어졌고 테스트 본문만 봐도 저넻를 이해할 수 있게 되었다.
  • DAMP는 DRY를 대체한다기보다 보완하는 개념이다. 도우미 메서드와 테스트 인프라는 테스트를 더 명확하게 만드는데 여전히 도움을 줄 수 있다. 가령 본문 코드를 더 간결하게 해주고 검사하려는 행위와 관련 없이 반복되는 세세한 단계들을 추상화해줄 수 있다.
  • 테스트에서의 리팩터링은 반복을 줄이는 게 아니라 더 서술적이고 의미있게 하는 방향으로 이루어져야 한다.

12.4.1 공유 값

  • 일련의 공유 값(shared value)을 정의한 후 여러 테스트에서 다양하게 조합해 사용하는 경우가 많다.
// 이름이 모호한 공유 값
let account1 = Account(state: .open, balance: 50)
let account2 = Account(state: .closed, balance: 0)
let item = item(name: "치즈버거", price: 100)

// 수백 줄의 다른 테스트들...

func testCanBuyItem_returnsFalseForClosedAccounts() {
    XCTAssertFalse(store.canBuyItem(item, account1))
}

func testCanBuyItem_returnsFalseWhenBalanceInsufficient() {
    XCTAssertFalse(store.canBuyItem(item, account2))
}
  • 이 전략으로 테스트들을 아주 간략하게 만들 수 있지만, 테스트 스위트가 커질 수록 문제가 된다.
    • 각 테스트에서 왜 그 값을 선택했는지를 이해하기 어렵다.
  • 도우미 메서드를 이용해 데이터를 구성해보자. 테스트 작성자가 필요한 값들만 명시해 도우미 메서드에 요청하면 그 외 값들에는 적절한 기본값을 설정해 돌려준다.
// 도우미 메서드를 사용해 값을 공유하는 예

// 도우미 메서드는 각 매개변수에 임의의 기본값을 정의하여 생성자를 래핑한다.
extension Contact {
    init(
        firstName: String = "Grace",
        lastName: String = "Hopper",
        phoneNumber: String = "555-123-4567",
    ) {
        self.firstName = firstName
        self.lastName = lastName
        self.phoneNumber = phoneNumber
    }
}

// 테스트는 필요한 매개변수의 값만 설정하여 도우미를 호출한다.
func test_fullNameShouldCombineFirstAndLastNames() {
    let contact = Contact(firstName: "에이다", lastName: "러브레이스")
    XCTAssertEqual(contact.fullName(), "에이다 러브레이스")
}
  • 이처럼 도우미 메서드를 이용하면 불필요한 정보로 오염되거나 다른 테스트와 충돌할 염려 없이 정확히 필요한 값들만 생성해 사용할 수 있다.

12.4.2 공유 셋업

  • 설정이나 초기화 로직을 통해서도 테스트가 코드를 공유할 때와 비슷한 일이 벌어진다.
  • 잘못 사용하면 중요한 세부 정보를 셋업 메서드 속으로 숨겨버려서 테스트가 완벽해지지 못하게 막는다.
  • 셋업 메서드는 대상 객체와 협력 객체(collaborator)들을 생성하는데 유용하다.
  • 셋업 메서드가 이용한 특정 값에 의존하는 테스트가 생겨나기 시작하면 악몽이 시작된다.
private var userStore: UserStore?

override func setUp() {
    super.setUp()
    let nameService = NameService()
    nameService.set("user1", "도널드 커누스")
    userStore = Userstore(nameService: nameService)
}

// 수십 개의 다른 테스트들...

func testShouldReturnNameFormService() {
    let user: UserDetails = userStore.get("user1")
    XCTAssertEqaul(user.name, "도널드 커누스")
}
  • 이처럼 특정 값을 요구하는 테스트라면 그 값을 직접 기술해줘야 한다. 필요하다면 셋업 메서드가 정의한 기본값을 덮어써야 한다. 그러면 반복되는 코드가 살짝 늘어나지만 더 서술적이고 의미 있는 테스트로 재탄생한다.
private var nameService: NameService?
private var userStore: UserStore?

override func setUp() {
    super.setUp()
    nameService = NameService()
    nameService.set("user1", "도널드 커누스")
    userStore = Userstore(nameService: nameService)
}

func testShouldReturnNameFormService() {
    nameService.set("user1", "마거릿 해밀턴")
    let user: UserDetails = userStore.get("user1")
    XCTAssertEqaul(user.name, "마거릿 해밀턴")
}

12.4.3 공유 도우미 메서드와 공유 검증 메서드

  • 도우미 메서드를 다른 용도로 사용하면 위험할 수도 있다.
  • 여러 테스트에서 똑같은 일련의 단정문들을 반복할 때, 모든 테스트가 마지막에 도우미 메서드를 호출하는 것은 아주 위험한 습관이다. 테스트를 행위 주도적으로 만들기 어렵고, 테스트 각각의 의도를 추론해내기 훨씬 어렵기 때문이다. 그리고 버그가 하나 발생하면 테스트가 동시다발로 실패할 때가 많아서 살펴봐야 할 범위를 좁혀내기가 쉽지 않다.
  • 하나의 목적에 집중하는 검증 메서드(validation method)는 여전히 유용하다. 잘 만들어진 검증용 도우미 메서드는 여러 조건을 확인하는게 아니라 입력에 대한 단 하나의 개념적 사실만을 검증한다. 개념적으로는 단순하지만 그 개념을 검사하는 로직이 복잡한 경우라면 특히 큰 도움이 된다.
// 개념적으로 단순한 테스트
func assertUserHasAccessToAccount(user: User, account: Account) {
    let userId = user.id
    for userWithAccess in account.usersWithAccess() where userWithAccess.id == userId {
        return
    }
    XCTFail("\(user.name)" cannot access "\(account.name)")
}

12.4.4 테스트 인프라 정의하기

  • 다른 테스트 스위트와도 코드를 공유하면 유용할 때가 있다. 구글은 이런 종류의 코드를 테스트 인프라(test infrastructure)라고 부른다.
  • 테스트 인프라는 주로 통합 테스트나 종단간 테스트 때 빛을 발한다.

12.5 마치며

  • 단위 테스트를 생각 없이 만들면 시스템에 대한 확신을 키워주는 건 고사하고, 시스템을 유지보수하거나 변경하기가 훨씬 어려워진다.

12.6 핵심 정리

  • 변하지 않는 테스트를 만들기 위해 노력하자.
  • 공개 API를 통해 테스트하자.
  • 상호작용이 아닌, 상태를 테스트하자.
  • 테스트를 완전하고 명확하게 만들자.
  • 메서드가 아닌, 행위를 테스트하자.
  • 행위가 부각되게끔 테스트를 구성하자.
  • 테스트 이름은 검사하는 행위가 잘 드러나게 짓자.
  • 테스트에 로직을 넣지 말자.
  • 실패 메시지를 명확하게 작성하자.
  • 테스트들이 코드를 공유할 때는 DRY보다 DAMP를 우선하자.