통합 테스트는 테스트 스위트에서 중요한 역할을 한다. 또한 단위 테스트와 통합 테스트의 수를 균형 있게 유지하는 것도 중요하다.

통합 테스트 특성상 프로세스 외부 의존성에 직접 작동하면 느려질 수 밖에 없고, 이러한 형태의 테스트는 유지비용도 많이 지불해야한다. 유지보수 비용 증가는 다음 두 가지 이유로 인해 발생한다.
1. 프로세스 외부 의존성의 운영이 필요하다.
2. 관련된 협력자가 많아서 테스트가 비대해진다.
단위 테스트와 통합 테스트의 비율에 대해 정답은 없지만 아래와 같은 절차로 진행하면 된다.

빠르고 저렴한 단위 테스트는 대부분의 엣지 케이스를 다루는 반면, 적은 수의 느리고 더 비싼 통합 테스트는 시스템 전체의 정확성을 보장한다.
통합 테스트를 통해 비즈니스 시나리오당 하나의 해피 케이스와 엣지 케이스를 처리하는 방법에 대해 알아본다.
통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 해피 패스에 대해 테스트를 수행하는 것이 좋다.
func changeEmail(_ newEmail: String, company: Company) {
precondition(canChangeEmail() == nil)
/* the rest of the method */
}
컨트롤러는 CanChangeEmail() 메서드를 호출하고 해당 메서드가 오류를 반환하면 연산을 중단한다.
func changeEmail(userId: Int, newEmail: String) -> String? {
let userData = database.getUserById(userId)
let user = UserFactory.create(userData)
if let error = user.canChangeEmail() { // HERE
return error
}
/* the rest of the method */
}
위의 코드에서 HERE로 표시된 부분이 엣지 케이스이다.
만약 여기서 컨트롤러가 CanChangeEmail() 메서드를 참조하지 않고, 이메일을 변경하려고 하는 경우 애플리케이션이 실패하고, 실행만 하면 바로 버그를 특정할 수 있으므로 이를 쉽게 찾아내 수정할 수 있다. 즉 테스트를 통해 검증할 필요가 없는 케이스이다.
빠른 실패 원칙(The Fail Fast principle)
빠른 실패 원칙은 예기지 않은 오류가 발생하자마자 현재 동작을 중단하는 것을 의미한다.
이 원칙은 아래와 같은 효과로 애플리케이션의 안정성을 높여준다.
- 피드백 루프 단축(Shortening the feedback loop)
버그를 빨리 발견할수록 더 쉽게 해결할 수 있다.
이미 운영 환경으로 넘어간 뒤 발견된 버그는 수정에 더 큰 비용을 치뤄야 한다.- 지속성 상태 보호(Protecting the persistence state)
버그는 애플리케이션의 상태를 손상 시킨다.
손상된 상태가 외부 저장소 등으로 침투하면 수정이 훨씬 어려워지므로, 빨리 실패하여 손상의 확산을 막는 것이 좋다.
통합 테스트는 시스템이 프로세스 외부 의존성과 어떻게 통합하는지를 검증한다.
이를 검증하는 방법은 크게 두 가지로, 하나는 실제 프로세스 외부 의존성을 사용하거나, 해당 의존성을 Mock으로 대치하는 것이다.


이 시스템은 사용자의 이메일 변경 기능만 구현되어 있으며, 데이터베이스에서 사용자와 회사를 검색하고 의사 결정은 도메인 모델에 위임한 후 그 결과를 데이터베이스에 저장한 뒤, 필요한 경우 메시지 버스에 메시지를 실어 보낸다.

class UserController {
private let database = Database()
private let messageBus = MessageBus()
func changeEmail(userId: Int, newEmail: String) -> String? {
let userData = database.getUserById(userId)
let user = UserFactory.create(userData)
if let error = user.canChangeEmail() {
return error
}
let companyData = database.getCompany()
let company = CompanyFactory.create(companyData)
user.changeEmail(newEmail, company)
database.saveCompany(company)
database.saveUser(user)
for ev in user.emailChangedEvents {
messageBus.sendEmailChangedMessage(ev.userId, ev.newEmail)
}
return "OK"
}
}
가장 긴 해피 패스와 엣지 케이스들을 식별해보자. 먼저 가장 긴 해피 패스는 모든 프로세스 외부 의존성을 거치는 것이 기준이다.
고객 관리 프로젝트에를 기준으로 살펴보면 기업 이메일에서 일반 이메일로 변경하는 것이 가장 긴 해피 패스이다.
엣지 케이스는 하나로, 이메일을 변경할 수 없는 시나리오이다. 하지만 이 시나리오는 테스트할 가치가 없다. 컨트롤러에 변경 불가능에 대한 확인이 없으면 애플리케이션이 빠른 실패를 보장해주기 때문이다.
따라서 단일 통합 테스트만 남게 된다.
public func changingEmailFromCorporateToNonCorporate() { }


import Testing
@Test
func changingEmailFromCorporateToNonCorporate() async throws {
let db = Database(connectionString: connectionString)
let user = createUser("user@mycorp.com", type: .employee, db: db)
createCompany("mycorp.com", numberOfEmployees: 1, db: db)
let messageBusMock = MessageBusMock()
let sut = UserController(database: db, messageBus: messageBusMock)
let result = sut.changeEmail(userId: user.userId, newEmail: "new@gmail.com")
#expect(result == "OK")
let userData = db.getUserById(user.userId)
let userFromDb = UserFactory.create(userData)
#expect(userFromDb.email == "new@gmail.com")
#expect(userFromDb.type == .customer)
let companyData = db.getCompany()
let companyFromDb = CompanyFactory.create(companyData)
#expect(companyFromDb.numberOfEmployees == 0)
#expect(messageBusMock.sendEmailChangedMessageCalls.count == 1)
#expect(messageBusMock.sendEmailChangedMessageCalls.first?.userId == user.userId)
#expect(messageBusMock.sendEmailChangedMessageCalls.first?.newEmail == "new@gmail.com")
}
단위 테스트에서 가장 많이 오해되는 것 중 하나는 인터페이스의 사용이다. 인터페이스를 선언한 이유를 개발자들이 잘못 설명하거나, 그 결과를 남용하는 경우가 많기 때문이다.
protocol MessageBusProtocol { }
final class MessageBus: MessageBusProtocol { }
protocol UserRepositoryProtocol { }
final class UserRepository: UserRepositoryProtocol { }
이렇게 인터페이스를 남용하는 일반적인 이유는 다음과 같다.
1. 인터페이스가 프로세스 외부 의존성을 추상화해 느슨한 결합을 달성한다.
단일 구현을 위한 인터페이스는 추상화에 해당하지 않으므로 이는 명백한 오해이다. 추상화가 무엇인지 표현할 수 있는 의미가 있긴 하지만, 이는 코드에서 정의하는 것이 아닌 발견되는 것에 가까우며, 진정한 추상화는 구현이 두 가지 이상 있어야한다.
2. 인터페이스는 기존 코드를 변경하지 않고 새로운 기능을 추가해 공개 폐쇄 원칙을 준수한다.
이는 공개 폐쇄 원칙보다 더 기본적인 3대 원칙 중 하나인 YAGNI를 위반한다.
YAGNI 현재 필요하지않은 기능에 시간을 들여서는 안되는 원칙을 말하며, 이는 기회 비용을 낭비하거나 프로젝트 코드의 경량화를 방해한다.
OCP vs YAGNI
https://enterprise-craftsmanship.com/posts/ocp-vs-yagni.
final class UserController {
private let database: Database
private let messageBus: MessageBusProtocol
init(database: Database, messageBus: MessageBusProtocol) {
self.database = database
self.messageBus = messageBus
}
func changeEmail(userId: Int, newEmail: String) -> String? {
/* the method uses database and messageBus */
}
}
프로세스 외부 의존성뿐만 아니라 프로세스 내부 의존성(도메인 클래스 등)에도 단일 구현을 가진 인터페이스(예: IUser와 User 클래스 쌍)가 사용되는 경우가 있는데, 이는 큰 위험 신호이다.
protocol UserProtocol {
var userId: Int { get set }
var email: String { get }
func canChangeEmail() -> String?
func changeEmail(_ newEmail: String, company: Company)
}
final class User: UserProtocol {
/* ... */
}
프로세스 외부 의존성과 마찬가지로, 도메인 클래스에 단일 구현 인터페이스를 도입하는 유일한 이유는 목킹을 가능하게 하기 위함이다. 그러나 도메인 클래스 간의 상호 작용은 목으로 확인해서는 안 된다. 이렇게 하면 테스트가 구현 세부 사항과 결합되어 리팩토링 저항력이 낮은 취약한 테스트가 되기 때문이다.
통합 테스트를 최대한 활용하고 코드베이스의 건전성을 높이는 데 도움이 되는 몇 가지 일반적인 지침이 있다.
1. 도메인 모델의 경계 명시(Making domain model boundaries explicit)
2. 애플리케이션 내 계층 줄이기(Reducing the number of layers in the application)
3. 순환 의존성 제거(Eliminating circular dependencies)
코드베이스에서 도메인 모델의 명시적이고 잘 알려진 위치를 항상 확보해야 한다. 도메인 모델은 프로젝트가 해결하려는 문제에 대한 도메인 지식의 집합이다. 명시적인 경계를 할당하면 코드의 해당 부분을 더 잘 시각화하고 추론하는 데 도움이 되며, 단위 테스트와 통합 테스트를 쉽게 구분할 수 있다.


final class CheckOutService {
func checkOut(orderId: Int) {
let service = ReportGenerationService()
service.generateReport(orderId: orderId, checkOutService: self)
/* other code */
}
}
final class ReportGenerationService {
func generateReport(orderId: Int, checkOutService: CheckOutService) {
/* calls checkOutService when generation is completed */
}
}

final class CheckOutService {
func checkOut(orderId: Int) {
let service = ReportGenerationService()
let report = service.generateReport(orderId: orderId)
/* other work */
}
}
final class ReportGenerationService {
func generateReport(orderId: Int) -> Report {
/* ... */
}
}
- 준비 : 사용자 등록에 필요한 데이터 준비
- 실행 : UserController.registerUser() 호출
- 검증 : 등록 동작의 성공 여부를 확인하기 위해 데이터베이스 조회
- 실행 : UserController.deleteUser() 호출
- 검증 : 삭제 동작의 성공 여부를 확인하기 위해 데이터 베이스 조회
public final class User {
var userId: Int
private(set) var email: String
var type: UserType
var emailChangedEvents: [EmailChangedEvent] = []
private let logger: Logger
public func changeEmail(_ newEmail: String, company: Company) {
logger.info("Changing email for user \(userId) to \(newEmail)") // Start of the method
precondition(canChangeEmail() == nil)
if email == newEmail { return }
let newType: UserType = company.isEmailCorporate(newEmail) ? .employee : .customer
if type != newType {
let delta = newType == .employee ? 1 : -1
company.changeNumberOfEmployees(delta)
logger.info("User \(userId) changed type from \(type) to \(newType)") // Changes the user type
}
email = newEmail
type = newType
emailChangedEvents.append(EmailChangedEvent(userId: userId, newEmail: newEmail))
logger.info("Email is changed for user \(userId)") // End of the method
}
func canChangeEmail() -> String? {
/* ... */
}
init(userId: Int, email: String, type: UserType, logger: Logger) {
self.userId = userId
self.email = email
self.type = type
self.logger = logger
}
}
public func changeEmail(_ newEmail: String, company: Company) {
logger.info("Changing email for user \(userId) to \(newEmail)")
precondition(canChangeEmail() == nil) // Diagnostic logging
if email == newEmail { return }
let newType: UserType = company.isEmailCorporate(newEmail) ? .employee : .customer
if type != newType {
let delta = newType == .employee ? 1 : -1
company.changeNumberOfEmployees(delta)
domainLogger.userTypeHasChanged(userId: userId, oldType: type, newType: newType) // Support logging
}
email = newEmail
type = newType
emailChangedEvents.append(EmailChangedEvent(userId: userId, newEmail: newEmail))
logger.info("Email is changed for user \(userId)") // Diagnostic logging
}
public final class DomainLogger: DomainLoggerProtocol {
private let logger: Logger
public init(logger: Logger) {
self.logger = logger
}
public func userTypeHasChanged(userId: Int, oldType: UserType, newType: UserType) {
logger.info("User \(userId) changed type from \(oldType) to \(newType)")
}
}
logger.info("User Id is " + String(12))
logger.info("User Id is \(12)")

public func userTypeHasChanged(
userId: Int,
oldType: UserType,
newType: UserType
) {
logger.info("User \(userId) changed type from \(oldType) to \(newType)")
}
userTypeHasChanged()를 메시지 템플릿의 해시로 볼 수 있다. userId, oldType, newType 매개변수와 함께 이 해시는 로그 데이터를 형성한다. 이 메서드의 구현은 로그 데이터를 플랫 로그 파일로 렌더링한다.
지원 및 진단 로깅을 위한 테스트 작성 앞서 언급했듯이 DomainLogger는 프로세스 외부 의존성(로그 저장소)을 나타낸다. 이는 User가 해당 의존성과 상호작용하여 비즈니스 로직과 프로세스 외부 의존성과의 통신 간의 분리를 위반한다는 문제를 야기한다. DomainLogger의 사용은 User를 복잡한 코드(overcomplicated code) 범주로 전환하여 테스트하고 유지보수하기 어렵게 만든다.
이 문제는 외부 시스템에 변경된 사용자 이메일에 대한 알림을 구현했던 것과 동일한 방식으로 해결할 수 있다. 도메인 이벤트의 도움을 받아서이다. 사용자 유형의 변경 사항을 추적하기 위해 별도의 도메인 이벤트를 도입할 수 있다.
public func changeEmail(
_ newEmail: String,
company: Company
) {
logger.info("Changing email for user \(userId) to \(newEmail)")
precondition(canChangeEmail() == nil)
if email == newEmail { return }
let newType: UserType = company.isEmailCorporate(newEmail) ? .employee : .customer
if type != newType {
let delta = newType == .employee ? 1 : -1
company.changeNumberOfEmployees(delta)
addDomainEvent(UserTypeChangedEvent(userId: userId, oldType: type, newType: newType)) // Uses a domain event instead of DomainLogger
}
email = newEmail
type = newType
addDomainEvent(EmailChangedEvent(userId: userId, newEmail: newEmail))
logger.info("Email is changed for user \(userId)")
}
func changeEmail(userId: Int, newEmail: String) -> String? {
let userData = database.getUserById(userId)
let user = UserFactory.create(userData)
if let error = user.canChangeEmail() {
return error
}
let companyData = database.getCompany()
let company = CompanyFactory.create(companyData)
user.changeEmail(newEmail, company: company)
database.saveCompany(company)
database.saveUser(user)
eventDispatcher.dispatch(user.domainEvents) // Dispatches user domain events
return "OK"
}
public final class User {
private static let logger = Logger(for: User.self)
public func changeEmail(_ newEmail: String, company: Company) {
Self.logger.info("Changing email for user \(userId) to \(newEmail)")
/* ... */
Self.logger.info("Email is changed for user \(userId)")
}
}
public func changeEmail(
_ newEmail: String,
company: Company,
logger: Logger
) {
logger.info("Changing email for user \(userId) to \(newEmail)")
/* ... */
logger.info("Email is changed for user \(userId)")
}