728x90
업무나 협업 프로젝트를 진행하면서 테스트 코드를 작성할 때 다양한 테스트 방법이 사용된다.
그 중에서도 Test Double은 실제 객체를 대신하여 테스트에 사용되는 객체를 말하는데, 종종 개발자간 서로 이해한 개념이 달랐던 적이 있어서 오해가 생긴 일도 있었다. 특히 Mock과 Stub의 차이점이나, Spy와 Mock의 차이점 등에서 혼란이 있었다. 그래서 이 개념을 정확히 이해하고 상황에 맞게 사용하는 것이 중요하기에 짚고 넘어가려고 한다.
더미 (Dummy)
class DummyEmailService : EmailService {
override fun sendEmail(email: String, message: String) {
// 아무 동작도 하지 않음 - 단순히 파라미터를 채우기 위한 용도
}
}
// 사용 예시
@Test
fun testUserRegistration() {
val dummyEmailService = DummyEmailService()
val userService = UserService(dummyEmailService) // 이메일 서비스는 실제로 사용되지 않음
userService.register("test@test.com")
}
- 가장 단순한 형태
- 실제로 사용되지 않고 단순히 인스턴스화된 객체가 필요한 경우 사용
- 메서드가 호출되어도 아무 동작도 하지 않음
- 어떤 비즈니스 애플리케이션에 전달해야 할 인수가 여러 개 있지만 테스트는 이들 중 몇개만 수행할 때 흔히 볼 수 있다.
사용 이유
- 매개변수로 전달해야 하지만 실제로는 테스트에서 사용되지 않는 객체가 필요할 때
- 단순히 컴파일을 통과시키기 위해 필요한 경우
- 테스트 대상 코드가 의존성을 요구하지만, 해당 의존성의 기능은 테스트와 무관할 때
- 예: UserService를 테스트할 때 EmailService가 필요하지만 이메일 발송 기능은 테스트하지 않을 경우
페이크 (Fake)
class FakeUserRepository : UserRepository {
private val users = mutableListOf<User>()
override fun save(user: User) {
users.add(user)
}
override fun findById(id: Long): User? {
return users.find { it.id == id }
}
}
// 사용 예시
@Test
fun testUserOperations() {
val fakeRepo = FakeUserRepository()
val userService = UserService(fakeRepo)
userService.createUser(User("test", "[test@test.com](mailto:test@test.com)"))
}
- 실제 구현을 단순화한 구현체
- 실제로 동작하는 구현을 가짐
- 주로 메모리 내 저장소로 구현됨
사용 이유
- 실제 구현체가 아직 준비되지 않았거나 사용하기 어려운 경우
- 실제 구현체가 테스트하기에 너무 느리거나 복잡한 경우
- 데이터베이스나 외부 API와 같은 외부 의존성을 대체해야 할 때
- 예: 실제 데이터베이스 대신 인메모리 데이터베이스를 사용하는 경우
스텁 (Stub)
class PaymentServiceStub : PaymentService {
override fun processPayment(amount: Double): PaymentResult {
// 항상 성공 반환
return PaymentResult(true, "Payment Successful")
}
}
// 사용 예시
@Test
fun testOrderProcessing() {
val stubPayment = PaymentServiceStub()
val orderService = OrderService(stubPayment)
val result = orderService.createOrder(100.0)
assert(result.isSuccess)
}
- 미리 준비된 답변을 제공
- 호출에 대해 하드코딩된 응답을 반환
- 테스트를 위해 프로그래밍된 응답만 제공
- 즉 상태 검증의 시각으로 보는 것도 좋다.
사용 이유
- 특정 상태나 시나리오를 테스트해야 할 때
- 의존성의 특정 반환값이 필요한 경우
- 외부 서비스의 다양한 응답 상황을 시뮬레이션할 때
- 예: 결제 실패, 네트워크 오류 등의 시나리오 테스트
모의 객체 (Mock)
@Test
fun testUserNotification() {
// Mockito 사용 예시
val mockNotificationService = mock(NotificationService::class.java)
// 동작 정의
whenever(mockNotificationService.send(any()))
.thenReturn(true)
val userService = UserService(mockNotificationService)
userService.notifyUser("test message")
// 호출 검증
verify(mockNotificationService).send("test message")
verify(mockNotificationService, times(1)).send(any())
}
- 호출 여부와 방법을 검증
- 예상된 동작을 미리 프로그래밍 가능
- 실제로 호출되었는지 검증하는 것이 주 목적
- 즉 행위 검증의 시각으로 보는 것도 좋다.
사용 이유
- 객체 간의 상호작용을 검증해야 할 때
- 특정 메서드가 정확한 매개변수로 호출되었는지 확인할 때
- 메서드 호출 순서나 횟수를 검증해야 할 때
- 예: 사용자 등록 후 이메일이 정확히 한 번 발송되었는지 확인
스파이 (Spy)
class EmailServiceSpy : EmailService {
var emailsSent = 0
private val sentEmails = mutableListOf<String>()
override fun sendEmail(email: String, message: String) {
emailsSent++
sentEmails.add(email)
}
fun getLastEmailSent(): String? = sentEmails.lastOrNull()
}
// 사용 예시
@Test
fun testEmailSending() {
val emailSpy = EmailServiceSpy()
val userService = UserService(emailSpy)
userService.sendWelcomeEmail("test@test.com")
assertEquals(1, emailSpy.emailsSent)
assertEquals("test@test.com", emailSpy.getLastEmailSent())
}
- 이름에서 알 수 있듯이 의존성을 감시한다.
- 실제 객체를 감싸서 추가 정보를 기록
- 호출된 내용을 기록하면서 실제 동작도 수행 가능
- 모의 객체와 실제 객체의 중간 형태
- 테스트 대상 메서드가 의존 대상과 어떻게 상호작용하는지 단언하고자 하는 경우에 사용된다.
사용 이유
- 실제 객체의 동작을 유지하면서 추가적인 정보가 필요할 때
- 메서드 호출을 감시하면서 실제 로직도 실행해야 할 때
- 부분적인 모의 객체가 필요한 경우
- 예: 실제 이메일을 발송하면서 동시에 발송 횟수를 추적해야 할 때
요약
- 더미: 아무 동작도 하지 않음
- 페이크: 단순화된 실제 구현 제공
- 스텁: 미리 준비된 응답만 제공
- 모의 객체: 예상된 동작을 검증
- 스파이: 실제 동작을 하면서 호출 정보도 기록