들어가기 앞서
•
목의 가치를 극대화하기
•
목을 스파이로 교체하기
•
목 처리에 대한 모범 사례
앞서 챕터에서 목에 관련된 이야기를 많이 했었다. 목은 테스트 대상 시스템과 의존성 간의 상호 작용을 모방하고 검사 하는 데 도움이 되는 테스트 대역이다. 목은 비관리 의존성에만 적용해야 한다.
비관리 의존성
해당 의존성과의 상호 작용을 외부에서 볼 수 있는 것을 뜻한다. 예를 들어, SMTP 서버와 메시지 버스 등이 있다. 둘 다 다른 애플리케이션에서 볼 수 있는 부작용을 발생시킨다.
만약, 다른 곳에 목을 사용하게 되면 깨지기 쉬운 테스트(리팩터링 내성이 없는 테스트)가 된다.
9장에서는 목에 대해 리팩터링 내성과 회귀 방지를 최대화해서 최대 가치의 통합 테스트를 개발하는 데 도움이 되는 지침을 알아본다.
목의 가치를 극대화하기
앞서 진행했던 CRM 시스템 예제 프로젝트를 보면서 목의 가치를 극대화하는 방법을 살펴보자.
// 8장 UserController
data class UserController(
private val _database: Database,
private val _messageBus: MessageBus
) {
fun changeEmail(userId: Int, newEmail: String): String {
val userData = _database.getUserById(userId)
val user = UserFactory.create(userData)
...
// 사용자 도메인 이벤트 전달
EventDispatcher().dispatch(user.domainEvents)
return "OK"
}
}
Kotlin
복사
8장의 UserController는 EventDispatcher의 dispatch 메서드를 사용해서 user의 도메인 이벤트를 두 가지 책임으로 분리하여 지원 로깅을 테스트 한다.
또한, User 클래스는 changeEmail 메서드의 시작과 끝에서 직접 로거 인스턴스를 사용하고 있다. 진단 로깅은 개발자만을 위한 것이기 때문에, 굳이 테스트할 필요도 없고, 도메인 모델 테스트에 포함되지 않아도 된다. 하지만 가능하다면 User나 다른 도메인 클래스에서 진단 로깅을 사용하진 말자.
// 8장 User 클래스
data class User(
val userId: Int,
var email: String,
var type: UserType,
val isEmailConfirmed: Boolean,
val logger: Logger,
val domainLogger: DomainLogger,
val domainEvents: List<EmailChangeEvent>
) {
...
fun changeEmail(newEmail: String, company: Company) {
//진단 로깅
logger.info(
"changing email for user $userId to $newEmail"
)
...
if (type != newType) {
val delta = if (newType == UserType.Employee) {
1
} else {
-1
}
company.changeNumberOfEmployees(delta)
// 지원 로깅
addDomainEvent(UserTypeChangedEvent(userId, type, newType))
}
email = newEmail
type = newType
// 지원 로깅
addDomainEvent(EmailChangeEvent(userId, newEmail))
// 진단 로깅
logger.info(
"$email is changed for user $userId"
)
}
private fun addDomainEvent(userTypeChangedEvent: EmailChangeEvent) {
// messageBus.sendEmailChangedMessage로 변환
}
private fun addDomainEvent(userTypeChangedEvent: UserTypeChangedEvent) {
// domainLogger.userTypeHasChanged로 변환
}
}
Kotlin
복사
이제 진단 로깅을 제거한 UserController와 User 클래스를 살펴보자.
// 9장 UserController
data class UserController(
private val _database: Database,
private val _messageBus: MessageBus,
private val _domainLogger: IDomainLogger,
) {
fun changeEmail(userId: Int, newEmail: String): String {
val userData = _database.getUserById(userId)
val user = UserFactory.create(userData)
val error = user.canChangeEmail()
error?.let { return it }
val companyData = _database.getCompany()
val company = CompanyFactory.create(companyData)
user.changeEmail(newEmail, company)
_database.saveCompany(company)
_database.saveUser(user)
EventDispatcher().dispatch(user.domainEvents)
return "OK"
}
}
Kotlin
복사
// 9장 User 클래스
data class User(
val userId: Int,
var email: String,
var type: UserType,
val isEmailConfirmed: Boolean,
val domainEvents: MutableList<IDomainEvent>
) {
...
fun changeEmail(newEmail: String, company: Company) {
require(canChangeEmail() == null)
if (email == newEmail) {
return
}
val newType = if (company.isEmailCorporate(newEmail)) {
UserType.Employee
} else {
UserType.Customer
}
if (type != newType) {
val delta = if (newType == UserType.Employee) {
1
} else {
-1
}
company.changeNumberOfEmployees(delta)
addDomainEvent(UserTypeChangedEvent(userId, type, newType))
}
email = newEmail
type = newType
addDomainEvent(EmailChangeEvent(userId, newEmail))
}
private fun addDomainEvent(userTypeChangedEvent: IDomainEvent) {
domainEvents.add(userTypeChangedEvent)
}
}
Kotlin
복사
User 클래스를 보면 진단 로깅은 사라지고 지원 로깅(IDomainLogger 인스턴스)만 남아 있는 것을 확인할 수 있다.
대신 도메인 모델에서 생성된 도메인 이벤트를 비관리 의존성에 대한 호출로 변환해주는 EventDispatcher 클래스를 추가하였다. (이전에는 UserController에서 직접 처리 함)
class EventDispatcher {
private val messageBus = MessageBus()
private val domainLogger = DomainLogger()
fun dispatch(events: List<IDomainEvent>) {
events.forEach {
dispatch(it)
}
}
private fun dispatch(event: IDomainEvent) {
when(event) {
is EmailChangeEvent -> {
messageBus.sendEmailChangedMessage(
event.userId,
event.newEmail
)
}
is UserTypeChangedEvent -> {
domainLogger.userTypeHasChanged(
event.userId,
event.oldType,
event.newType,
)
}
}
}
}
Kotlin
복사
자 이제, 비관리 의존성(IMessageBus, IDomainLogger)를 목으로 사용해서 통합 테스트를 진행해보자.
class UserIntegrationTest {
companion object {
private const val CONNECTION_STRING = "TEST"
}
@Test
fun changing_email_from_corporate_to_non_corporate_v1() {
val db = Database(CONNECTION_STRING)
val user: User = createUser("user@gmail.com", UserType.Employee, db)
createCompany("gmail.com", 1, db)
val mockMessageBus = mock<MessageBus>()
val mockLogger = mock<DomainLogger>()
val sut = UserController(
db, mockMessageBus, mockLogger
)
val result = sut.changeEmail(user.userId, "new@naver.com")
assertEquals("OK", result)
val userData = db.getUserById(user.userId)
val userFromDb = UserFactory.create(userData)
assertEquals("new@naver.com", userFromDb.email)
assertEquals(UserType.Customer, userFromDb.type)
val companyData = db.getCompany()
val companyFromDb = CompanyFactory.create(companyData)
assertEquals(0, companyFromDb.numberOfEmployees)
verify(mockMessageBus, times(1))
.sendEmailChangedMessage(user.userId, "new@naver.com")
verify(mockLogger, times(1))
.userTypeHasChanged(user.userId, UserType.Employee, UserType.Customer)
}
private fun createCompany(domainName: String, numberOfEmployees: Int, dataBase: Database): Company {
val company = Company(domainName, numberOfEmployees)
dataBase.saveCompany(company)
return company
}
private fun createUser(email: String, type: UserType, dataBase: Database): User {
val user = User(
userId = 0,
email = email,
type = type,
isEmailConfirmed = false,
domainEvents = mutableListOf()
)
dataBase.saveUser(user)
return user
}
}
Kotlin
복사
시스템 끝에서 상호 작용 검증하기
통합 테스트 version 1에서 사용했던 목이 회귀 방지와 리팩터링 내성 측면에서 이상적이지 않은 이유와 이를 해결하는 방법에 대해서 살펴보자.
목을 사용할 때는 “시스템 끝에서 비관리 의존성과의 상호 작용을 검증하라”를 항상 유의하자
mockMessageBus 의 문제점은 IMessageBus 인터페이스가 시스템 끝에 있지 않다는 것이다.
interface IMessageBus {
fun sendEmailChangedMessage(userId: Int, newEmail: String)
}
class MessageBus : IMessageBus {
private val bus = Bus()
override fun sendEmailChangedMessage(userId: Int, newEmail: String) {
bus.send("Subject: USER; Type: EMAIL CHANGED; Id: ${userId}; NewEmail: $newEmail")
}
}
interface IBus {
fun send()
}
Kotlin
복사
MessageBus와 IBus 인터페이스(구현 클래스 포함)는 모두 프로젝트 코드베이스에 속한다.
IBus는 메시지 버스 SDK 라이브러리 위에 있는 래퍼이다.
•
IBus → 꼭 필요하지 않는 기술 세부 사항을 캡슐화하고, 임의의 텍스트 메시지를 메시지 버스로 보낼 수 있는 깔끔한 인터페이스
IMessage 인터페이스는 IBus 위에 있는 래퍼로, 도메인과 관련된 메시지를 정의한다.
•
IMessage 인터페이스를 사용하면 모든 메시지를 한 곳에 보관하고 애플리케이션에서 재사용할 수 있음
물론, IBus와 IMessage 인터페이스를 합쳐서 사용할 수도 있지만, 이렇게 두 가지 책임을 분리하는 것이 좋다. (ILogger, IDomainLogger도 동일한 의미를 가짐)
외부 라이브러리의 복잡성을 숨기는 것과 모든 애플리케이션 메시지를 한 곳에 두는 것은 좋지 않은 방법이다.
위의 그림을 보면 IBus는 컨트롤러와 메시지 버스 사이의 마지막 사슬 고리이며, IMessage는 중간에 위치한 것을 볼 수 있다.
위의 통합 테스트에서는 IMessageBus를 목으로 둬서 사용했지만, 대신 IBus를 목으로 처리하면 회귀 방지를 극대화할 수 있다.
비관리 의존성과 통신하는 마지막 타입을 목으로 처리하면 통합 테스트가 거치는 클래스의 수가 증가하므로 보호가 향상된다.
@Test
fun changing_email_from_corporate_to_non_corporate_v2() {
val db = Database(CONNECTION_STRING)
val user: User = createUser("user@gmail.com", UserType.Employee, db)
createCompany("gmail.com", 1, db)
val mockBus = mock<IBus>()
val messageBus = MessageBus(mockBus)
val mockLogger = mock<DomainLogger>()
val sut = UserController(
db, messageBus, mockLogger
)
// ...
verify(mockBus, times(1))
.send(
"Type: USER EMAIL CHANGED;" +
"ID: ${user.userId};" +
"NewEmail: new@gmail.com"
)
}
Kotlin
복사
IMessageBus 인터페이스는 오로지 테스트에서 목으로 사용하기 위해서 만들어진 것이므로, 이 인터페이스를 삭제하고 MessageBus로 대체할 수 있다.
//v1
verify(mockMessageBus, times(1))
.sendEmailChangedMessage(user.userId, "new@naver.com")
//v2
verify(mockBus, times(1))
.send(
"Type: USER EMAIL CHANGED;" +
"ID: ${user.userId};" +
"NewEmail: new@gmail.com"
)
Kotlin
복사
사용자 정의 클래스에 대한 호출을 검증하는 것과 외부 시스템에 전송한 실제 텍스트 사이에는 큰 차이가 있다. 외부 시스템은 애플리케이션으로부터 텍스트 메시지를 수신하고, sendEmailChangedMessage 메서드를 가지고 있는 MessageBus와 같은 클래스를 호출하진 않는다.
즉, 이러한 메시지를 생성하는 데 참여하는 클래스는 단지 구현 세부 사항일 뿐이다.
IBus와 같이 시스템 끝에서 상호 작용을 확인하면 회귀 방지도 좋아지고, 리팩터링 내성도 향상된다.
따라서 이러한 코드는 코드베이스와의 결합도가 낮기 때문에, 낮은 수준의 리팩터링에도 영향을 많이 받지 않는다.
목을 스파이로 대체하기
스파이와 목은 같은 목적을 수행하는 테스트 대역이다. 그렇다면 차이가 무엇일까?
스파이는 직접 작성하는 반면, 목은 프레임워크의 도움을 받아 생성한다는 것이 유일한 차이이다.
스파이 부연 설명 필요 → 정리
시스템 끝에 있는 클래스의 경우(ex: IBus) 스파이가 목보다 낫다. 스파이는 검증 단계에서 코드를 재사용하여 테스트 크기를 줄이고 가독성을 향상시킨다.
class BusSpy: IBus {
private val _sentMessages: MutableList<String> = mutableListOf()
override fun send(message: String) {
_sentMessages.add(message)
}
fun shouldSendNumberOfMessages(number: Int): BusSpy {
assertEquals(number, _sentMessages.count())
return this
}
fun withEmailChangedMessage(userId: Int, newEmail: String): BusSpy {
val message = "Type: USER EMAIL CHANGE; " +
"ID: $userId;" +
"NewEmail: $newEmail"
assertContains(_sentMessages, message)
return this
}
}
Kotlin
복사
@Test
fun changing_email_from_corporate_to_non_corporate_v3() {
val db = Database(CONNECTION_STRING)
val user: User = createUser("user@gmail.com", UserType.Employee, db)
createCompany("gmail.com", 1, db)
val spyBus = BusSpy()
val messageBus = MessageBus(spyBus)
val mockLogger = mock<DomainLogger>()
val sut = UserController(
db, messageBus, mockLogger
)
// ...
spyBus.shouldSendNumberOfMessages(1)
.withEmailChangedMessage(user.userId, "new@gmail.com")
}
Kotlin
복사
BusSpy가 제공하는 플루언트 인터페이스 덕분에, 메시지 버스와의 상호 작용을 검증하는 것이 간결해지고 표현력도 생겼다.
플루언트 인터페이스는 여러 가지 검증을 묶을 수 있어 응집도 높고 쉬운 영어 문장을 형성할 수 있다.
플루언트 인퍼테이스(Fluent interface)
플루언트 인터페이스는 메서드 체이닝에 기반한 객체 지향 API 설계 메서드이다. 소스 코드의 가독성을 산문과 유사하게 만드는 것이 목적이다.
// 플루언트 인퍼테이스(Fluent interface) 예제 코드
mock.expects(once()).method("m").with( or(stringContains("hello"),
stringContains("howdy")) );
Kotlin
복사
근데 v1의 테스트 검증과 같은 의미가 아닌가 라고 할 수 있다.
verify(mockMessageBus, times(1)) -> shouldSendNumberOfMessages와 같음
.sendEmailChangedMessage(user.userId, "new@naver.com") -> withEmailChangedMessage과 같음
Kotlin
복사
이 둘과의 결정적인 차이는 BusSpy는 테스트 코드에, MessageBus는 제품 코드에 속한다. 테스트에서 검증문을 작성할 때 제품 코드에 의존하면 안되므로 이 차이는 중요하다.
스파이로 테스트 검증을 하지 않고 IMessageBus를 목으로 처리하면 제품 코드를 너무 많이 신뢰하게 되므로 좋지 않은 방법이다.
IDomainLogger는 어떤가?
IBus는 스파이로 대체했고, mockLogger의 검증 방법은 변경한 게 없다.
spyBus.shouldSendNumberOfMessages(1)
.withEmailChangedMessage(user.userId, "new@gmail.com")
verify(mockLogger, times(1))
.userTypeHasChanged(user.userId, UserType.Employee, UserType.Customer)
Kotlin
복사
ILogger 인터페이스도 애플리케이션의 경계에 있는데, 그럼 테스트도 이 인터페이스로 대상을 다시 지정해야 되지 않을까?
대부분의 프로젝트에서 이렇게 대상을 굳이 바꿀 필요는 없다.
로거와 메시지 버스는 비관리 의존성이므로 두 버전 모두 하위 호환성을 유지해야 하지만, 호환성의 정확도가 같을 필요는 없다.
메시지 버스의 경우애는 외부 시스템이 변경에 어떻게 반응하는지 알 수가 없어 메시지 구조를 변경하지 않는 것이 중요하다. 반면에 텍스트 로그의 정확한 구조는 대상 독자(지원 부서와 시스템 관리자 등)에게 그다지 중요하지 않다. 중요한 것은 로그가 있다는 사실과 로그에 있는 정보이다.
목 처리에 대한 모범 사례
지금까지 두 가지 모범 사례를 살펴봤다.
•
비관리 의존성에만 목 적용하기
•
시스템 끝에 있는 의존성에 대해 상호 작용 검증하기
이제 나머지 모범 사례를 살펴보자.
•
통합 테스트에만 목을 사용하고 단위 테스트에서는 하지 않기
•
항상 목 호출 수 확인하기
•
보유 타입만 목으로 처리하기
목은 통합 테스트만을 위한 것
“목은 통합 테스트만을 위한 것이며 단위 테스트에서 목을 사용하지 말자”의 지침은 비즈니스 로직과 오케스트레이션의 분리에서 비롯된다.
코드의 복잡성, 프로세스 외부 의존성과 통신을 목으로 처리할 수 있다고 생각할 수 있지만 이는 아니다. 해당 원칙은 도메인 모델(복잡도 처리)과 컨트롤러(통신 처리)라는 고유 계층 두 개로 만들어진다.
•
도메인 모델에 대한 테스트 → 단위 테스트
•
컨트롤러를 다루는 테스트 → 통합 테스트
목은 비관리 의존성에만 해당하며 컨트롤러에서만 이러한 의존성을 처리하기 때문에, 통합 테스트에서 컨트롤러를 테스트 할 때만 목을 적용해야 한다.
테스트 당 목이 하나일 필요는 없음
지금까지 살펴본 “단위”의 정의는 코드 단위가 아닌 동작 단위인 것을 무수히 많이 말했다. 동작 단위를 구현하는 데 필요한 코드의 양은 상관 없다.
목도 동일한 원칙이 적용된다. 동작 단위를 검증하는 데 필요한 목의 수는 관계가 없다. 통합 테스트에 사용할 목의 수는 통제할 수 없다. 목의 수는 운영에 참여하는 비관리 의존성 수에만 의존한다.
호출 횟수 검증하기
비관리 의존성과의 통신에 관해서는 다음 두 가지에 대한 경우를 모두 확인하는 것이 중요하다.
•
예상하는 호출이 있는가?
•
예상치 못한 호출은 없는가?
해당 요구 사항은 비관리 의존성과 하위 호환성을 지켜야 하는 데서 비롯된다. 호환성은 양방향이여야 한다.
즉, 애플리케이션은 외부 시스템이 예상하는 메시지를 생략해서는 안 되며 예상치 못한 메시지도 생성해서는 안 된다.
보유 타입만 목으로 처리하기
스티브 프리먼과 냇 프라이스가 소개한 “Growing Object-Oriented Software, Guided by Tests”의 지침에 따르면, 서드 파티 라이브러리 위에 항상 어댑터를 작성하고 기본 타입 대신 해당 어댑터를 목으로 처리해야 한다고 한다.
•
서드파티 코드의 작동 방식에 대해 깊이 이해하지 못하는 경우가 많다.
•
해당 코드가 이미 내장 인터페이스를 제공하더라도 목으로 처리한 동작이 실제로 외부 라이브러리와 일치하는지 확인해야 하므로, 해당 인터페이스를 목으로 처리하는 것은 위험하다.
•
서드파티 코드의 기술 세부 사항까지는 꼭 필요하지 않기에 어댑터는 이를 추상화하고, 애플리케이션 관점에서 라이브러리와의 관계를 정의한다.
저자는 위의 분석에 대해서 전적으로 동의한다고 한다. 실제로 어댑터는 코드와 외부 환경 사이의 손상 방지 계층(Anticorruption Layer)으로 작동한다.
손상 방지 계층은 다른 하위 시스템 사이에 배치하여 하위 시스템 간 격리하도록 한다. 이 레이어는 두 시스템 간의 통신을 변환하여, 다른 응용 프로그램이 해당 디자인 및 기술 방식을 손상시키지 않도록 해당 시스템을 변경되지 않은 상태로 유지하도록 할 수 있다.
위 그림처럼 하나의 Layer를 추가하여 외부에 인터페이스 변경에도 기존 도메인의 영향도를 낮출 수 있다.
어댑터를 통해
•
기본 라이브러리의 복잡성을 추상화
•
라이브러리에서 필요한 기능만 노출
•
프로젝트 도메인 언어를 사용하여 수행
의 긍정적인 효과를 가진다.
7,8,9 장에서 계속 보았던 CRM 프로젝트에서 IBus 인터페이스가 바로 이 목적에 부합한다. 기본 메시지 버스의 라이브러리가 IBus 만큼 훌륭하고 깔끔한 인터페이스를 제공하더라도, 고유의 래퍼를 그 위에 두는 것이 좋다.
IBus와 같은 인터페이스를 두지 않는다면? 만약 메시지 버스 라이브러리의 버전이 업그레이드 되어 서드파티 코드의 변경이 일어나고 우리는 이를 알지 못한다면 어떻게 될까? 악영향으로 전체 코드베이스에 걸쳐 파급 효과가 일어날 수 있다.
“보유 타입을 목으로 처리하라”의 지침은 프로세스 내부 의존성에는 적용되지 않는다. 계속 설명한 것처럼 목은 비관리 의존성에만 해당된다. 따라서 인메모리 의존성이나 관리 의존성을 추상화할 필요는 없다.
물론 모든 라이브러리 위에 고유의 래퍼를 둘 수 있지만, 비관리 의존성 이외의 다른 용도로는 노력을 들일 만한 가치가 없다.
라이브러리가 날짜와 시간 API를 제공하는 경우 비관리 의존성에 도달하지 않으므로 해당 API를 있는 그대로 사용할 수 있다.
본 장에서는 통합 테스트에서 목을 어떻게 처리하는 것이 좋은 가에 대해서 살펴봤다. 이제 10장에서 통합 테스트에서 프로세스 외부 의존성을 어떻게 관리해야 되는지에 대해서 자세히 살펴본다.