31장. 의존성이 있는 코드 테스트하기
30장에서 테스트의 기본기를 배웠습니다.
계산기처럼 혼자 동작하는 코드는 테스트하기 쉬웠습니다.
그런데 실제 백엔드 코드는
다른 객체에 기대어 동작합니다.
예를 들어 회원 가입 서비스는
데이터베이스(Repository)에 의존합니다.
이럴 때 진짜 데이터베이스를 쓰면
테스트가 느리고 불안정해집니다.
그래서 우리는 “가짜 대역“을 씁니다.
이 장에서 그 방법을 배웁니다.
31.1 Test Double
영화에서 위험한 장면은
배우 대신 스턴트 대역이 연기합니다.
테스트에서도 마찬가지입니다.
진짜 객체 대신 쓰는 “대역“을
테스트 더블(Test Double)이라고 합니다.
테스트 더블에는 여러 종류가 있습니다.
| 종류 | 한 줄 설명 |
|---|---|
| Dummy | 그냥 자리만 채우는 가짜 |
| Stub | 정해진 답만 돌려주는 가짜 |
| Fake | 간단하지만 실제로 동작하는 가짜 |
| Mock | 호출을 기록하고 검증하는 가짜 |
이제 하나씩 살펴봅니다.
먼저 예제에 쓸 인터페이스를 정합니다. (8장 참고)
interface UserRepository {
fun findById(id: Long): User?
fun save(user: User): User
}
31.2 Dummy
더미(Dummy)는
그냥 자리만 채우는 가짜입니다.
호출되지 않을 것을 알지만,
매개변수 자리를 채워야 할 때 씁니다.
class DummyUserRepository : UserRepository {
override fun findById(id: Long): User? = null
override fun save(user: User): User = user
}
내용은 거의 비어 있습니다.
“필요하지만 실제로 쓰이지는 않는” 역할입니다.
31.3 Stub
스텁(Stub)은
“정해진 답만 돌려주는” 가짜입니다.
특정 상황을 만들고 싶을 때 유용합니다.
class StubUserRepository : UserRepository {
override fun findById(id: Long): User? {
return User(id = 1, name = "홍길동") // 늘 같은 답
}
override fun save(user: User): User = user
}
“회원이 존재하는 상황“을
간단히 만들어 낼 수 있습니다.
31.4 Fake
페이크(Fake)는
간단하지만 실제로 동작하는 가짜입니다.
예를 들어 진짜 데이터베이스 대신
메모리(Map)에 저장하는 저장소를 만들 수 있습니다.
class FakeUserRepository : UserRepository {
private val storage = mutableMapOf<Long, User>()
override fun findById(id: Long): User? = storage[id]
override fun save(user: User): User {
storage[user.id] = user
return user
}
}
저장하면 진짜로 저장되고,
조회하면 저장한 값이 나옵니다.
다만 메모리에서만 동작하므로
빠르고 가볍습니다.
Fake는 백엔드 테스트에서 특히 자주 쓰입니다.
31.5 Mock
목(Mock)은
“어떤 호출이 일어났는지“를 기록하고
그것을 검증하는 데 쓰는 가짜입니다.
“save가 정말 호출됐는가?” 같은 것을
확인하고 싶을 때 유용합니다.
Mock은 보통 손으로 만들기보다
전문 라이브러리로 만듭니다.
코틀린에서는 MockK를 많이 씁니다.
31.6 Repository를 Fake로 교체하기
이제 실제 테스트를 해 봅시다.
검사할 서비스입니다. (Service는 36장에서 자세히 다룹니다)
class UserService(
private val userRepository: UserRepository
) {
fun register(name: String): User {
val user = User(id = 1, name = name)
return userRepository.save(user)
}
}
UserService는 UserRepository에 의존합니다.
테스트에서는 이 자리에 Fake를 넣습니다.
@Test
fun `회원을 등록하면 저장된다`() {
// Given
val fakeRepository = FakeUserRepository()
val userService = UserService(fakeRepository)
// When
val user = userService.register("홍길동")
// Then
assertEquals("홍길동", fakeRepository.findById(user.id)?.name)
}
진짜 데이터베이스 없이도
서비스 로직을 완전히 검증할 수 있습니다.
이것이 가능한 이유는UserService가 구현체가 아니라
인터페이스에 의존하기 때문입니다.
(8장에서 배운 원리입니다.)
31.7 MockK 맛보기
MockK는 코틀린용 목 라이브러리입니다.
dependencies {
testImplementation("io.mockk:mockk:1.13.11")
}
간단한 사용 예를 봅시다.
@Test
fun `조회 결과를 지정한다`() {
val repository = mockk<UserRepository>()
// findById(1)을 부르면 이 값을 돌려주도록 지정
every { repository.findById(1) } returns User(1, "홍길동")
val user = repository.findById(1)
assertEquals("홍길동", user?.name)
}
every { ... } returns ...로
“이 호출엔 이 답을 줘라“라고 지정합니다.
호출 여부를 검증할 수도 있습니다.
verify { repository.findById(1) }
MockK는 강력하지만,
Fake로 충분한 경우에는
Fake가 더 읽기 쉬울 때도 많습니다.
31.8 테스트하기 좋은 코드의 구조
여기서 중요한 깨달음이 있습니다.
테스트하기 어려운 코드는
대개 구조가 좋지 않은 코드입니다.
반대로,
테스트하기 쉬운 코드는 대개 구조가 좋습니다.
테스트하기 좋은 코드의 특징은 이렇습니다.
- 의존성을 생성자로 받는다 (36장 참고)
- 구현체가 아니라 인터페이스에 의존한다
- 하나의 객체가 하나의 책임만 가진다
“테스트하기 쉽게 짜라“는 말은
사실 “좋은 구조로 짜라“는 말과 같습니다.
이 감각은 11부(백엔드 구조)에서
본격적으로 다룹니다.
31장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 테스트 더블(Dummy, Stub, Fake, Mock)의 종류와 차이
- Fake Repository로 의존성을 교체하는 법
- 인터페이스 덕분에 교체가 가능하다는 점
- MockK로 목을 만드는 기본 사용법
- 테스트하기 좋은 코드가 곧 좋은 구조라는 점
이것으로 10부(프로젝트와 테스트)를 마칩니다.
다음 11부에서는 지금까지 배운 것을 모아
백엔드 애플리케이션의 구조를 만들어 봅니다.