단위 테스트 - 생산성과 품질을 위한 단위 테스트 원칙과 패턴 책을 바탕으로 정리를 진행했습니다. 하단 북마크에 책 구매 링크를 연결해두었습니다.
목(Mock)과 스텁(Stub) 구분하기
목과 스텁 모두 SUT와 협력자 사이 상호 작용 검사 시에 사용할 수 있는 테스트 대역(Test Double)입니다.
예를 들어서, 앱과 서버 사이에 상호 작용을 검사하는 경우에 비운영용 가짜 서버를 만들어서 실제 서버와의 연결을 대체할 수 있습니다. 테스트에서만 사용되는 비운영용 가짜 서버는 실제 서버를 사용할 때 발생할 수 있는 서버 의존성 문제를 해결하기 때문에 테스트를 편리하게 진행할 수 있게 됩니다.
물론, 실제 의존성 대신 사용되기 때문에 설정이나 유지보수에 어려움이 있을 수 있습니다.
테스트 대역은 크게 두 가지, 목과 스텁으로 나눠집니다.
목과 스텁은 상호 작용을 모방하는 방향에 있어서 차이가 있습니다.
▷ 목(Mock)
목은 외부로 나가는 상호 작용을 모방하고 검사하는 데 사용됩니다.
SUT에서 외부 의존성을 대신하는 목을 향해 상호 작용을 하게 되고, 상호 작용을 통해서 외부 의존성에 해당하는 목에 있는 값이 변경되게 됩니다. 변경된 값은 테스트를 통해서 검증됩니다.
SUT에서 목을 향해서 값 변경이 진행되기 때문에 목에서는 별도로 반환되는 값이 없고, 값이 변경되기 때문에 사이드 이펙트가 발생할 수 있습니다.
목은 목과 스파이(Spy)로 나눠집니다. 직접 목을 생성하는가(스파이), 목을 프레임워크를 사용해서 생성하는가(목)로 차이가 있을뿐 거의 동일하게 볼 수 있습니다.
▷ 스텁(Stub)
스텁은 목과는 반대로 내부로 들어오는 상호 작용을 모방합니다.
SUT에서 입력 데이터를 얻기 위해서 스텁을 호출합니다. SUT는 스텁에서 얻는 값을 사용해서 로직이나 상태를 검증하게 됩니다.
SUT는 스텁을 사용해서 입력 데이터를 얻기 때문에 스텁은 값을 반환하게 됩니다. 스텁의 값은 변경되지 않기 때문에 사이드 이펙트는 발생하지 않게 됩니다.
스텁은 스텁, 더미(Dummy), 페이크(Fake)로 나눠집니다.
간단한 입력 데이터는 null, 가짜 문자열 같은 하드 코딩된 값을 반환하는 더미입니다. 더미는 변경할 수 없는 고정된 값을 가지는 데이터입니다.
반대로 스텁과 페이크는 시나리오마다 다른 값을 반환하게끔 구성하게 됩니다. 하드 코딩된 값이 아닌 상황에 맞춰서 다른 값을 반환하게끔 하는거죠. 페이크는 스텁과 거의 동일하지만 아직 존재하지 않는 의존성을 대체한다는 점에서 스텁과는 다릅니다.
위의 내용을 표로 정리하면 이렇게 나타낼 수 있을 거 같네요.
테스트 대역 | 의존성 방향 | 특징 | 반환 값 | 값 검증 |
목(Mock) | SUT → 외부 의존성(Mock) | 프레임워크를 사용해서 Mock 생성 | 없음 | 가능 |
스파이(Spy) | SUT → 외부 의존성(Mock) | 직접 Mock 생성 | 없음 | 가능 |
스텁(Stub) | 내부 의존성(Stub) → SUT | 시나리오마다 다른 값 반환 | 있음 | 불가능 |
페이크(Fake) | 내부 의존성(Stub) → SUT | 시나리오마다 다른 값 반환 | 있음 | 불가능 |
더미(Dummy) | 내부 의존성(Stub) → SUT | 고정된 값 반환 | 있음 | 불가능 |
▷ 사용 예시
조금 더 이해하기 쉽게 테스트 코드로 목과 스텁을 만나 봅시다.
아래 코드에서 UserUseCase에 대한 테스트 코드를 작성해보려고 합니다.
greetUser는 User에게 환영 이메일을 보내줍니다. 이메일 전송 시에 이메일 전송 API를 사용하게 됩니다. 해당 EmailGateway를 Mock을 사용하여 대체해봅시다.
protocol EmailGatewayRepository: Actor {
func sendEmail(_ email: String, content: String)
}
final actor MockEmailGatewayRepository: EmailGatewayRepository {
private(set) var sentEmail: String?
func sendEmail(_ email: String, content: String) {
sentEmail = email
}
}
final actor UserUseCase {
private let repository: EmailGatewayRepository
init(repository: EmailGatewayRepository) {
self.repository = repository
}
func greetUser(_ email: String) async {
await repository.sendEmail(email, content: "Hi there! welcome to our app!")
}
}
Swift
복사
MockEmailGatewayRepository를 사용해서 외부 의존성을 모방하게 됩니다. 또한, Mock 인스턴스 내에 있는 프로퍼티 값을 변경시켜서 변경된 값을 검증합니다.
Mock를 사용하는 경우에는 SUT → 외부 의존성(Mock) 방향의 상호작용을 하게 됩니다.
struct UserTests {
@Test("유저에게 인사 이메일을 보내는 기능")
func test_sending_a_greeting_email() async {
// given
let mock = MockEmailGatewayRepository()
let sut = UserUseCase(repository: mock)
let userEmail = "test@example.com"
// when
await sut.greetUser(userEmail)
// then
await #expect(mock.sentEmail == "test@example.com")
}
}
Swift
복사
이번에는 UserUseCase에 유저가 생성한 메일의 갯수를 가져올 수 있는 fetchMailContentsCount 메서드를 추가했습니다.
final actor UserUseCase {
...
func fetchMailContentsCount() async -> Int {
return await repository.fetchMailContents().count
}
}
Swift
복사
MockEmailGatewayRepository에는 init 메서드에 contents를 주입할 수 있도록 코드를 수정하고, 해당 contents를 fetchMailContents 메서드를 통해서 반환할 수 있도록 메서드 작성했습니다.
protocol EmailGatewayRepository: Actor {
func sendEmail(_ email: String, content: String)
func fetchMailContents() -> [String]
}
final actor MockEmailGatewayRepository: EmailGatewayRepository {
private let mailContents: [String]
init(mailContents: [String] = []) {
self.mailContents = mailContents
}
func fetchMailContents() -> [String] {
return mailContents
}
}
Swift
복사
위에서는 MockEmailGateway가 Mock으로써 동작했지만, 이번에는 SUT로 입력 값을 넣어주는 역할을 하기 때문에 stub으로 네이밍을 했습니다.
struct UserTests {
@Test("유저가 생성한 메일의 개수 확인 기능")
func test_created_mail_count() async throws {
// given
let stub = MockEmailGatewayRepository(mailContents: ["test1", "test2", "test3"])
let sut = UserUseCase(repository: stub)
// when
let contentsCount = await sut.fetchMailContentsCount()
// then
#expect(3 == contentsCount)
}
}
Swift
복사
이번에는 스텁을 사용해서 테스트 값(["test1", "test2", "test3"])을 주입합니다. 주입된 값을 스텁이 반환하게 되고 SUT는 반환된 값을 사용해서 자신의 메서드를 검증하게 됩니다.
Stub을 사용하는 경우에는 내부 의존성(Stub) → SUT 방향의 상호작용을 하게 됩니다.
이런 궁금증이 들 수도 있습니다.
목과 다르게 스텁에 설정된 값은 어떤 이유로 검증하지 않는걸까요?
스텁 테스트 예시를 스텁 설정값을 검증하는 방식으로 작성하게 된다면 아래처럼 테스트가 작성됩니다.
struct UserTests {
@Test("유저가 생성한 메일의 개수 확인 기능")
func test_created_mail_count() async throws {
// given
let stub = MockEmailGatewayRepository(mailContents: ["test1", "test2", "test3"])
let sut = UserUseCase(repository: stub)
// when
let contentsCount = await sut.fetchMailContentsCount()
// then
#expect(3 == contentsCount)
#expect(stub.mailContents == ["test1", "test2", "test3"])
}
}
Swift
복사
🟢 리팩토링 내성 에서 봤던 깨지기 쉬운 테스트가 생각납니다.
SUT은 스텁을 최종 결과를 생성하기 위해 사용하는 것이 아니라 결과를 산출하기 위한 수단으로 사용합니다. 단순히 SUT가 최종 결과를 낼 수 있도록 Input를 제공해주는 것이 스텁이 하는 일입니다.
따라서, 스텁 내부에 있는 값을 검증하는 것은 최종 결과를 검증하는 것이 아닌 구현 세부 사항을 검증하는 일이 됩니다.
스텁 테스트 예시에서 실제로 검증해야 하는 건 최종 결과인 count입니다. mailContents는 최종 결과를 내기 위해서 필요한 수단일 뿐입니다.
리팩터링 내성을 향상시키기 위해서는 스텁과의 상호작용을 검증하지 말아야 합니다.
구현 세부 사항을 검증하게 되면 리팩터링 내성이 약해지고 결과적으로 취약한 테스트가 완성됩니다.
▷ 두가지 일을 동시에,
“값 설정과 로직 검증을 동시에 할 수 있는 테스트 대역은 존재하지 않는건가”
라는 의문이 당연히 들 겁니다.
두 가지 특성을 모두 나타내는 테스트 대역도 존재합니다.
•
스텁처럼 준비된 응답을 반환하고,
•
목처럼 메서드의 호출을 검증하는,
테스트 대역말이죠.
해당 테스트 대역은 편하게 목(Mock)이라고 부르겠습니다.
아래 예시에 있는 mock이 두 가지 특성을 모두 나타냅니다.
struct CustomerTests {
private let mock: MockStoreGatewayRepository
private let sut: CustomerUseCase
init() {
// given
let preparedProducts: [Product] = [
Product(id: "1", name: "Test Product", price: 0),
Product(id: "20", name: "Test Shoes", price: 0),
Product(id: "100", name: "Test Bags", price: 0)
]
// 값 설정(Stub)
self.mock = MockStoreGatewayRepository(storedProducts: preparedProducts)
self.sut = CustomerUseCase(repository: mock)
}
@Test("재고가 있는 상품일 경우 장바구니에 추가한다.")
func test_add_shopping_cart() async {
// given
let addedProductId = "100"
// when
await sut.addProductToCart(addedProductId)
// then
// 값 검증(Mock)
await #expect(mock.shoppingCart.contains(where: { $0.id == addedProductId }))
}
@Test("재고가 없는 상품일 경우 장바구니를 비운다.")
func test_reset_shopping_cart() async {
// when
await sut.addProductToCart("30")
// then
// 값 검증(Mock)
await #expect(mock.shoppingCart.isEmpty)
}
}
Swift
복사
스텁처럼 준비된 응답을 반환하고,
•
mock을 사용해서 storedProducts에 값을 설정합니다.
목처럼 메서드의 호출을 검증하는,
•
최종 결과 값인 shoppingCart에 값이 제대로 설정되었는지 검증합니다.
방금 스텁은 결과를 산출하기 위한 수단이기 때문에 스텁과의 상호작용을 검증하면 안된다고 하지 않았나?
테스트 예시를 보면, 테스트 대역이 값 설정과 검증에 사용하는 값이 다르다는 걸 알 수 있습니다.
준비된 응답에 사용된 값은 storedProducts이고, 검증한 값은 shoppingCart입니다.
설정된 값(혹은 메서드)과 검증하는 값(혹은 메서드)이 다르다면 스텁과의 상호작용을 검증하지 말라는 규칙을 위배하지 않은 것으로 간주합니다.
위에 작성된 예시 코드는 아래 레포에서 확인하실 수 있습니다.