단위 테스트 - 생산성과 품질을 위한 단위 테스트 원칙과 패턴 책을 바탕으로 정리를 진행했습니다. 하단 북마크에 책 구매 링크를 연결해두었습니다.
단위 테스트 2부 - 5장 목과 테스트 취약성(1) 에 이어서 작성된 내용입니다.
식별할 수 있는 동작과 구현 세부 사항
리팩터링 내성이 향상된 테스트를 작성하기 위해선:
•
최종 결과를 검증하고
•
‘어떻게’가 아니라 ‘무엇’에 중점을 둔
•
목표를 달성하는데 도움이 되는 연산, 상태를 노출한
식별할 수 있는 동작을 테스트로 작성해야 합니다. 구현 세부 사항은 어느것에도 해당하지 않습니다.
이상적인 시스템은 식별할 수 있는 동작은 노출하면서 구현 세부 사항은 철저히 숨겨야 합니다.
∴ 식별할 수 있는 동작은 Public API로 작성하고, 구현 세부 사항은 Private API로 작성해야 합니다.
하지만, Private으로 설정되어야 하는 구현 세부 사항이 Public으로 설정되면 문제가 생기게 됩니다.
UserUseCase에 trimUsernameToMaxLength와 updateUsername 메서드를 추가하겠습니다.
final actor UserUseCase {
private let maxLength = 10
...
func trimUsernameToMaxLength(_ name: String) -> String {
return name.count > maxLength ? String(name.prefix(maxLength)) : name
}
func updateUsername(_ name: String) async {
await userInfoRepository?.patchUsername(name)
}
}
Swift
복사
설정된 이름은 MockRepository의 username 값으로 설정됩니다.
final actor MockUserInfoGatewayRepository: UserInfoGatewayRepository {
private(set) var username: String?
func patchUsername(_ username: String) {
self.username = username
}
}
Swift
복사
유저 이름 변경 시에 도메인 룰에 맞춰서 이름을 제대로 저장하는지 확인하는 테스트를 작성하기 위해서는 아래처럼 테스트를 작성하게 될 겁니다.
struct UserTests {
@Test("유저 이름 변경 시 도메인 룰에 따라 이름 변경 > 최대 10자")
func test_update_username() async {
// given
let mock = MockUserInfoGatewayRepository()
let sut = UserUseCase(userInfoRepository: mock)
// when
let trimmedUsername = await sut.trimUsernameToMaxLength("Christabella")
await sut.updateUsername(trimmedUsername)
// then
await #expect(mock.username == "Christabel")
}
}
Swift
복사
테스트는 성공적으로 실행되겠지만, 잘 설계된 API는 아닙니다.
구현 세부 사항이 Public API로 노출되었기 때문이죠.
이름을 변경하는 경우에 유저의 이름을 최대 글자수로 제한하는 로직을 외부에 호출할 필요가 있을까요?
username이 최종 결과이기 때문에 최대 글자수로 제한하는 로직은 내부적으로 호출할 수 있도록 숨겨져야 합니다.
해당 로직을 Private API로 만들고 updateUsername 내부에서 호출하도록 수정하면 테스트가 아래처럼 변경됩니다.
struct UserTests {
@Test("유저 이름 변경 시 도메인 룰에 따라 이름 변경 > 최대 10자")
func test_update_username() async {
// given
let mock = MockUserInfoGatewayRepository()
let sut = UserUseCase(userInfoRepository: mock)
// when
await sut.updateUsername("Christabella")
// then
await #expect(mock.username == "Christabel")
}
}
Swift
복사
우리가 최종적으로 확인하고자 하는 목표만 로직으로 나타나는 것이죠.
구현 세부 사항을 철저히 숨기지 못한 API는 캡슐화 또한 제대로 유지하지 못해 불변성 위반을 가져옵니다.
캡슐화를 제대로 유지하지 않을 경우에 세부 사항 API와 목표가 되는 API가 모두 드러나기 때문에 코드가 복잡해지게 되고, 작업이 어려워지게 됩니다. 실수할 가능성이 높아지게 되는거죠.
이를 해결할 수 있는 방법 중 하나가 데이터를 연산 기능과 결합하는 방법입니다.
데이터에 구현 세부 사항 API를 숨겨서 캡슐화가 깨지는 것을 막고 클래스 내부가 손상될 위험을 막는 겁니다.
UserUseCase에 아래와 같은 username 프로퍼티를 만들고 해당 값이 설정될 때 내부에서 연산 기능을 호출하게 하는 겁니다.
final actor UserUseCase {
nonisolated(unsafe) var username: String = "" {
didSet(oldName) {
Task {
let trimmedUsername = await trimUsernameToMaxLength(oldName)
await updateUsername(trimmedUsername)
username = trimmedUsername
}
}
}
}
Swift
복사
아래 테스트 코드처럼 username 프로퍼티에 직접 접근하여 구현 세부 사항 API를 숨길 수 있습니다.
struct UserTests {
@Test("유저 이름 변경 시 도메인 룰에 따라 이름 변경 > 최대 10자")
func test_update_username() async {
// given
let mock = MockUserInfoGatewayRepository()
let sut = UserUseCase(userInfoRepository: mock)
// when
sut.username = "Christabella"
// then
await #expect(mock.username == "Christabel")
}
}
Swift
복사
제가 작성한 로직은 Actor를 사용하기 때문에 username 프로퍼티를 nonisolated하게 수정해주어야 합니다.
따라서, 이 경우에는 메서드를 통해서 username 값을 get, set 하는 경우가 더 적절할 거 같습니다.
메서드를 사용해도 충분히 구현 사항이 숨겨지기도 하구요. 실제로 저 테스트는 Task 이슈인지 실패합니다.
정리하자면, 잘 설계된 API를 가진 좋은 단위 테스트는:
•
모든 구현 세부 사항이 Private API로 숨겨집니다.
•
목표 달성에 직접적으로 도움이 되는 코드만 Public API로 공개됩니다.
•
테스트가 목표로 하는 동작을 검증하는 것 외에는 다른 선택지가 없습니다.
결과적으로, 잘 설계된 API를 가진 단위 테스트는 리팩터링 내성이 자동으로 좋아집니다.
만약, 자신이 작성한 테스트가 세부 사항을 유출하고 있는지 판단하기 어렵다면 아래 규칙을 적용해보세요.
단일한 목표를 달성하고자 클래스에서 호출해야 하는 연산의 수가 1보다 크면 해당 클래스에서 구현 세부 상을 유출할 가능성이 있다.
이상적인 테스트는 // when 절에서 단일 연산을 호출함으로써 목표를 달성하게 됩니다.
목과 테스트 취약성 간의 관계
전형적인 애플리케이션은 도메인 계층과 애플리케이션 서비스 계층으로 구성됩니다.
▷ 도메인 계층
도메인 계층은 애플리케이션 사용 방법에 해당하는 비즈니스 로직을 포함하고 있는 계층입니다.
해당 계층은 계층이 포함하고 있는 비즈니스 로직에 대해서만 책임을 집니다. 비즈니스 로직은 애플리케이션에서 중요한 로직이기 때문에 도메인 계층은 비즈니스 로직 외에 다른 책임은 지지 않습니다.
▷ 애플리케이션 서비스 계층
애플리케이션 서비스는 도메인과 외부 의존성 간의 통신을 조정하는 계층입니다.
애플리케이션에서 외부 애플리케이션과의 통신을 해야한다거나 DB에서 데이터를 검색해온다고 할 때 애플리케이션 계층이 그 일을 도맡아 하게 됩니다.
애플리케이션 내 비즈니스 로직은 도메인 계층이 책임지기 때문에, 애플리케이션 서비스 계층은 비즈니스 로직을 포함하지 않습니다. 애플리케이션 서비스 계층은 도메인 계층에 포함된 비즈니스 로직을 사용하는 대상으로만 존재합니다.
▷ 아키텍처 특징
도메인 계층과 애플리케이션 서비스 계층은 서로의 관심사가 분리되어 있습니다.
각 계층은 식별할 수 있는 동작으로 구성되어 있습니다. 도메인 계층은 비즈니스 로직만을 가지게 됩니다. 애플리케이션 서비스 계층은 도메인 계층이 제공하는 비즈니스 로직을 사용해서 목표를 달성합니다.
서로 관심사가 분리된 두 개의 계층을 가진 여러 육각형 아키텍처는 서로 소통하면서 집합을 이룹니다.
따라서, 육각형 아키텍처는 통신하는 방식이 두 가지입니다.
•
단일 아키텍처 내에서의 통신(내부 통신)
•
여러 아키텍처 사이에서의 통신(외부 통신)
단일 아키텍처 내에서의 통신(내부 통신)
육각형 아키텍처는 애플리케이션 서비스 계층에서 도메인 계층으로 단방향 의존성 흐름을 가집니다.
따라서, 애플리케이션 서비스 계층이 도메인 계층을 의존하는 경우는 존재하지만, 반대의 경우는 존재하지 않습니다. 도메인 계층은 외부 환경으로 분리되어야 하기 때문에 도메인 계층 외에 다른 계층에 대한 의존성을 가지지 않습니다.
시스템 내부 통신은 애플리케이션 내 클래스 간의 통신입니다. 즉, 구현 세부 사항에 해당하게 됩니다.
구현 세부 사항은 목표와는 직접적인 연관이 없는 연산이나 상태이기 때문에, 내부 통신과 결합된 테스트는 취약해질 수 있습니다.
여러 아키텍처 사이에서의 통신(외부 통신)
여러 육각형 아키텍처들은 애플리케이션 서비스 계층에 있는 공통 인터페이스를 통해서 통신하게 됩니다.
물론, 구현 세부 사항에 대한 값이 Private API로 존재하기 때문에, 애플리케이션 서비스 계층 밑에 있는 도메인 계층에는 직접 접근이 불가능합니다.
애플리케이션 간의 통신에 사용되는 로직들은 전체적으로 시스템의 목표가 되는 동작들로 구성됩니다.
외부 통신 시엔 애플리케이션 간에 서로 이해할 수 있도록 맺어둔 방식(계약)을 사용해서 통신하게 되는데, 해당 방식을 목으로 대체하게 되면 시스템과 외부 애플리케이션 간의 통신 패턴을 확인하는데 좋습니다.
반대로, 시스템 클래스 간의 검증을 위해서 목을 사용하게 될 경우엔 구현 세부 사항과 결합하게 되어 리팩터링 내성이 약해지게 됩니다.
테스트 예시
실제 테스트 코드를 통해서 목 사용에 대한 예시를 들어보겠습니다.
유저가 물건을 구매한 상황에서 시스템이 구매 영수증을 보내는지에 대한 값을 검증하려고 합니다.
@Test("유저가 상품 구매를 성공한다.")
func test_purchase_success() async {
// given
let shoppingCart: [Product] = [
Product(id: "35", name: "Banana", price: 125_000),
Product(id: "40", name: "Apple", price: 5_000),
Product(id: "40", name: "Apple", price: 5_000),
Product(id: "40", name: "Apple", price: 5_000),
Product(id: "37", name: "Orange", price: 10_000),
]
let mock = MockStoreGatewayRepository(
shoppingCart: shoppingCart,
isSuccess: true
)
let sut = CustomerUseCase(repository: mock)
// when
await sut.purchaseShoppingCart()
// then
await #expect(mock.isReceiptSent!)
}
Swift
복사
외부 애플리케이션과 통신하는 부분을 MockStoreGatewayRepository로 대체했습니다.
이 경우에는 목을 사용하여 외부 애플리케이션 간의 통신 패턴을 확인할 수 있어서 목을 사용하는 것이 타당합니다.
아래의 예제도 외부 애플리케이션과의 통신 부분을 목으로 대체하였습니다.
하지만, 목표를 달성에 필요한 값을 검증하지 않습니다.
@Test("유저가 상품 구매를 실패한다.")
func test_purchase_fail() async {
// given
let shoppingCart: [Product] = [
Product(id: "35", name: "Banana", price: 125_000),
Product(id: "35", name: "Apple", price: 5_000),
Product(id: "35", name: "Apple", price: 5_000),
Product(id: "35", name: "Apple", price: 5_000),
Product(id: "35", name: "Orange", price: 10_000),
]
let mock = MockStoreGatewayRepository(
shoppingCart: shoppingCart,
isSuccess: false
)
let sut = CustomerUseCase(repository: mock)
// when
await sut.purchaseShoppingCart()
// then
await #expect(mock.shoppingCart.isEmpty)
}
Swift
복사
로직을 살펴보면 유저가 상품 구매를 실패한 경우에 결과적으로 영수증을 전송하지 않는데, 목표가 되는 영수증 전송값을 확인하지 않고 목표로 가는 중간 단계(구현 세부 사항)에 해당하는 shoppingCart를 검증하는 것을 알 수 있습니다.
책에 있는 기본적인 예시와 내용을 기반으로 테스트 예시를 작성했는데, 개인적으로 test_purchase_fail 예시는 shoppingCart를 검증하는 것도 의미가 있지 않나 싶긴 합니다.
프랙탈(fractal) 특성
목표만 검증할 수 있도록 잘 설계된 API는 프랙탈(fractal) 특성이 있습니다.
프랙탈은 작은 구조 안에 전체 구조가 반복되는 패턴으로, 확대해도 계속 같은 모양의 구조가 반복됩니다.
만약, 애플리케이션이 구현 세부 사항을 잘 숨긴 구조를 가진다면 API 뿐만아니라 테스트도 프랙탈 구조를 가지게 됩니다. 프랙탈 구조를 가진 테스트는 검증하고자 하는 목표(비즈니스 로직)는 같지만 서로 다른 수준에서 동작을 검증하게 됩니다.
외부 클라이언트에서 애플리케이션 서비스로 검증하고자 하는 목표와, 애플리케이션 서비스에서 도메인 계층으로 검증하고자 하는 목표는 동일하지만 서로 다른 수준에서 동작을 검증하게 됩니다.
위와 같은 구조를 가진 테스트는 테스트를 통해서 비즈니스 요구 사항으로 거슬러 올라갈 수 있습니다.
잘 설계된 API는 비즈니스 요구 사항과 관계가 있기 때문에 구현 세부 사항과 결합되어 있지 않아 테스트가 안정적이고, 의미있게 됩니다.
단위 테스트의 고전파와 런던파 재고
🟣 단위 테스트란 무엇인가? 에서 고전파와 런던파에 대해서 알아보았습니다.
고전파와 런던파는 테스트 대역 사용 대상에 차이가 있었습니다.
테스트 대역 사용 대상 | |
고전파 | 공유 의존성 |
런던파 | 불변 의존성 외 모든 의존성 |
런던파는 불변 의존성 외 모든 의존성에 목 사용을 권장합니다.
즉, 시스템 간의 의존성인지, 시스템 내의 의존성인지를 구분하지 않고 목을 사용하게 됩니다.
결론적으로 구현 세부 사항과 결합되어 리팩터링 내성이 약해져 가치없는 테스트가 작성될 수 있습니다.
고전파는 공유하는 의존성만 목으로 교체합니다.
프로세스 외부 의존성만 목으로 교체하기 때문에 목표하는 동작에 대해서만 목이 사용되어 런던파의 경우보다 의미있는 테스트가 작성될 수 있습니다.
그렇다면, 공유하는 의존성은 모두 목으로 교체하면 될까?
의존성에는 여러 유형이 있는데, 모든 상황에 대해서 목이 필요한 건 아닙니다.
▷ 공유 의존성이 외부에 존재
공유 의존성이 프로세스 외부에 있으면, 테스트가 복잡해지고 테스트 실행 전에 데이터베이스를 인스턴스화하거나 하는 작업을 하기가 어렵습니다.
이러한 의존성은 목과 스텁으로 교체하면 좋습니다.
▷ 공유 의존성이 외부에 존재하지만 애플리케이션을 통해서만 접근 가능
외부에서 접근할 수 없는 애플리케이션의 데이터베이스(ex. CoreData)가 있다고 해봅시다.
해당 데이터베이스는 항상 애플리케이션과 함께 배포됩니다.
따라서, 클라이언트에 영향을 주지 않고 데이터베이스와 애플리케이션의 통신 패턴이 구현 세부 사항이 됩니다.
이렇게 완전히 통제권을 가진 프로세스 외부 의존성에 목을 사용하게 되면 깨지기 쉬운 테스트로 이어지게 됩니다.
따라서, 이런 경우에는 하나의 시스템(내부 통신)으로 취급해서 목을 사용하지 않는 것이 좋습니다.
정리하며
많은 상황에서 테스트에 목을 사용해서 동작을 검증합니다.
하지만, 대부분의 상황에서 목을 사용하여 클래스 간의 통신을 검증하게 됩니다.
이는 목표하는 동작과는 관계없는 구현 세부 사항을 나타내기 때문에 가치없는 테스트를 만들게 됩니다.
따라서, 아래 경우에 해당하는 테스트를 작성할 때 목 사용을 고려해보면 좋습니다.
•
애플리케이션 경계를 넘나드는 경우에 대한 상호 작용 검증
•
상호 작용의 사이드 이펙트가 외부 환경에 보일 때
위에 작성된 예시 코드는 아래 레포에서 확인하실 수 있습니다.