단위 테스트에서 나오는 자주 보이는
테스트 더블
과연 무엇일까?
1. 테스트 더블
스턴트맨을 아는가?
주인공이 위험하거나 고난이도 무술과 같이 실제 배우를 대체하는 사람이 스턴트맨이다.
이런 스턴트맨중에서 주연배우와 체형이나 생김새가 비슷하여 액션 외 간단한 연기까지 하는 이들을
스턴트 더블(Stunt Double)이라고 부른다.
테스트에서도 실제 객체를 직접 쓰기 힘들 때 테스트 더블을 사용한다.
2. 테스트 더블? 쓸 일 없을거같은데요?
테스트 더블 사용 사례
- 개발되지 않은 모듈 사용
- 특정 상황(예외 발생, 에러 응답 등)을 강제로 재현해야 할 때
- 단위 테스트의 고립성을 보장을 위해
테스트 더블 종류
Dummy
- 매개변수를 채우기 위해서만 사용 실제로 사용X
- 호출되지 않고 전달만 됨. (예: null 방지용)
Stub
- 호출되면 미리 정의한 값(답변)을 반환하는 객체.
- 외부 의존성을 단순히 대체하기 위해 사용.
- 호출에 대한 응답만 처리하고 그 외의 것은 무시
- 예: DB 대신 "고정된 유저 데이터" 반환.
Fake
- 실제 구현을 가지고 있지만 프로덕션에 적합하지 않은 간단한 구현체
- 실제 DB 대신 InMemory DB를 만들어 쓰는 식.
Spy
- 실제 객체를 감싸면서 호출된 내용을 기록하는 객체
- 실제 메서드를 호출하면서 동시에 검증
Mock
- 행위(behavior)에 대한 기대를 사전에 설정하고, 테스트 후에 기대대로 호출됐는지 검증.
- 검증 중심(Verification-based) 테스트에 많이 사용.
- 예: "sendEmail()이 정확히 한 번 호출돼야 한다"
무슨말인지 잘 모르겠다고?
그렇다면 아래 사례를 함께 참고하자
3. 사례로 보는 테스트 더블
1. 테스트할 클래스들
interface EmailService {
fun sendEmail(to: String, subject: String, body: String): Boolean
}
interface UserRepository {
fun findById(id: Long): User?
fun save(user: User): User
}
data class User(val id: Long, val name: String, val email: String)
class UserService(
private val userRepository: UserRepository,
private val emailService: EmailService
) {
fun registerUser(name: String, email: String): User {
val user = User(0, name, email)
val savedUser = userRepository.save(user)
emailService.sendEmail(email, "Welcome", "Welcome to our service!")
return savedUser
}
fun getUserInfo(id: Long): String? {
val user = userRepository.findById(id)
return user?.let { "${it.name} (${it.email})" }
}
}
1. Dummy
class DummyEmailService : EmailService {
override fun sendEmail(to: String, subject: String, body: String): Boolean {
// 아무것도 하지 않음 - 단순히 매개변수를 채우기 위해서만 존재
return true
}
}
// 사용 예시
@Test
fun `사용자 정보 조회 - 이메일 서비스는 사용되지 않으므로 Dummy 사용`() {
// 이메일 서비스는 호출되지 않으므로 Dummy로 충분
val dummyEmailService = DummyEmailService()
val fakeRepository = FakeUserRepository()
fakeRepository.save(User(0, "김철수", "kim@test.com"))
val userService = UserService(fakeRepository, dummyEmailService)
val result = userService.getUserInfo(1L)
assertEquals("김철수 (kim@test.com)", result)
}
2. Stub
class StubUserRepository : UserRepository {
override fun findById(id: Long): User? {
// 항상 같은 고정된 값을 반환
return if (id == 1L) User(1, "김철수", "kim@example.com") else null
}
override fun save(user: User): User {
// 항상 ID가 1인 유저를 반환
return user.copy(id = 1)
}
}
// 사용 예시
@Test
fun `존재하지 않는 사용자 조회 - 항상 null 반환하는 Stub 사용`() {
// 특정 상황(사용자가 없는 경우)을 강제로 만들 때
val stubRepository = object : UserRepository {
override fun findById(id: Long): User? = null // 항상 null 반환
override fun save(user: User): User = user
}
val userService = UserService(stubRepository, DummyEmailService())
val result = userService.getUserInfo(999L)
assertNull(result)
}
@Test
fun `DB 저장 실패 상황 시뮬레이션 - 예외 던지는 Stub`() {
// 예외 상황을 강제로 재현할 때
val stubRepository = object : UserRepository {
override fun findById(id: Long): User? = null
override fun save(user: User): User = throw RuntimeException("DB 연결 실패")
}
val userService = UserService(stubRepository, DummyEmailService())
assertThrows<RuntimeException> {
userService.registerUser("테스트", "test@test.com")
}
}
3. Fake
class FakeUserRepository : UserRepository {
private val users = mutableMapOf<Long, User>()
private var nextId = 1L
override fun findById(id: Long): User? = users[id]
override fun save(user: User): User {
// 프로덕션에 적합하지 않은 최소한의 구현
val savedUser = user.copy(id = nextId++)
users[savedUser.id] = savedUser
return savedUser
}
}
// 사용 예시
@Test
fun `사용자 등록 후 조회 - 실제 DB 없이 메모리에서 동작하는 Fake 사용`() {
// 실제 DB 연동 없이 비즈니스 로직만 테스트하고 싶을 때
val fakeRepository = FakeUserRepository()
val dummyEmailService = DummyEmailService()
val userService = UserService(fakeRepository, dummyEmailService)
// 여러 사용자 저장하고 조회하는 복잡한 시나리오 테스트
val user1 = userService.registerUser("김철수", "kim@test.com")
val user2 = userService.registerUser("이영희", "lee@test.com")
assertEquals("김철수 (kim@test.com)", userService.getUserInfo(user1.id))
assertEquals("이영희 (lee@test.com)", userService.getUserInfo(user2.id))
}
4. Spy
class SpyEmailService : EmailService {
var sendEmailCallCount = 0
var lastEmailTo: String? = null
var lastSubject: String? = null
override fun sendEmail(to: String, subject: String, body: String): Boolean {
// 실제 이메일은 보내지 않지만, 호출 정보를 기록
sendEmailCallCount++
lastEmailTo = to
lastSubject = subject
return true // 실제 동작을 시뮬레이션
}
}
// 사용 사례
@Test
fun `사용자 등록시 이메일 전송 여부 확인 - 호출 추적하는 Spy 사용`() {
// 메소드가 호출되었는지, 몇 번 호출되었는지 확인하고 싶을 때
val spyEmailService = SpyEmailService()
val fakeRepository = FakeUserRepository()
val userService = UserService(fakeRepository, spyEmailService)
userService.registerUser("김철수", "kim@test.com")
userService.registerUser("이영희", "lee@test.com")
// 이메일이 2번 호출되었는지 확인
assertEquals(2, spyEmailService.sendEmailCallCount)
assertEquals("lee@test.com", spyEmailService.lastEmailTo)
assertEquals("Welcome", spyEmailService.lastSubject)
}
@Test
fun `실패한 사용자 등록시 이메일 미전송 확인 - Spy로 호출 안됨을 검증`() {
val spyEmailService = SpyEmailService()
val stubRepository = object : UserRepository {
override fun findById(id: Long): User? = null
override fun save(user: User): User = throw RuntimeException("저장 실패")
}
val userService = UserService(stubRepository, spyEmailService)
assertThrows<RuntimeException> {
userService.registerUser("김철수", "kim@test.com")
}
// 저장 실패시 이메일이 전송되지 않았는지 확인
assertEquals(0, spyEmailService.sendEmailCallCount)
}
5. Mock
val mockEmailService = mockk<EmailService>()
every { mockEmailService.sendEmail(any(), any(), any()) } returns true
val userService = UserService(FakeUserRepository(), mockEmailService)
userService.registerUser("이영희", "lee@example.com")
// 검증: sendEmail이 정확한 파라미터로 한 번 호출되었는지 확인
verify(exactly = 1) {
// 기대값을 설정하고 호출 여부를 검증
mockEmailService.sendEmail("lee@example.com", "Welcome", "Welcome to our service!")
}
// 사용 예시
@Test
fun `사용자 등록시 정확한 파라미터로 이메일 전송 검증 - Mock 사용`() {
// 정확한 파라미터로 메소드가 호출되었는지 엄격하게 검증할 때
val mockEmailService = mockk<EmailService>()
every { mockEmailService.sendEmail(any(), any(), any()) } returns true
val userService = UserService(FakeUserRepository(), mockEmailService)
userService.registerUser("김철수", "kim@test.com")
// 정확한 파라미터로 정확히 한 번 호출되었는지 검증
verify(exactly = 1) {
mockEmailService.sendEmail("kim@test.com", "Welcome", "Welcome to our service!")
}
}
@Test
fun `이메일 전송 실패해도 사용자는 저장되는지 검증 - Mock으로 실패 상황 만들기`() {
// 외부 서비스 실패 상황을 만들고 싶을 때
val mockEmailService = mockk<EmailService>()
every { mockEmailService.sendEmail(any(), any(), any()) } returns false
val fakeRepository = FakeUserRepository()
val userService = UserService(fakeRepository, mockEmailService)
val savedUser = userService.registerUser("김철수", "kim@test.com")
// 이메일 실패해도 사용자는 저장되었는지 확인
assertNotNull(savedUser.id)
assertEquals("김철수", savedUser.name)
verify { mockEmailService.sendEmail(any(), any(), any()) }
}
결론:
테스트
알고 쓰자
'공부 > 테스트 코드' 카테고리의 다른 글
| TDD 초 기초편 2(SecurityConfig 에러) (0) | 2025.08.10 |
|---|---|
| TDD 초 기초편 (0) | 2025.08.09 |
| ArchUnit 테스트? 그것이 무엇인가? (4) | 2025.08.08 |