[Android] 단위 테스트 시 Stub, Mock, Fake, Spy 선택 기준
단위 테스트를 작성하다 보면 목과 스텁, 페이크, 스파이 중 뭘 선택해서 작성해야 할지 고민될 수 있다.
결론부터 보면 테스트의 목적에 따라 뭘 사용할지 달라진다고 할 수 있다.
뭘 목적으로 하는 테스트인가?
먼저 4가지의 정의를 간단하게 각각 정리하면 아래와 같다.
- 스텁 : 미리 정의된 고정값을 리턴하는 객체. 정의된 값을 리턴하는 것 외에 다른 로직은 포함하지 않음
- 목 : 객체 행동을 시뮬레이션할 때 사용. 행동 기반 검증(몇 번 호출됐는지, 무슨 매개변수로 호출됐는지 등)을 위해 사용
- 페이크 : 실제 시스템과 유사하게 작동하는 가짜 객체. 로직이 포함돼 있어서 스텁과 다른 구현체
- 스파이 : 실제 객체를 감시하며 실제 메서드를 호출하지만 호출 내용을 기록하거나 일부 메서드 동작을 변경할 수 있음
스텁을 사용할 수 있는 경우는 아래와 같다.
- 결과값 검증에 초점을 맞춘 테스트를 작성하는 경우 : 특정 입력에 대해 예상되는 결과를 얻으려 할 때 외부 의존성을 미리 정의된 고정값으로 대체 가능하다
- 간단한 테스트 : 단순히 테스트할 코드의 동작을 검증할 때
- 빠른 테스트 : 실제 동작 대신 미리 정해진 값을 제공해서 빠르게 테스트를 실행해야 하는 경우
DB에서 유저 프로필을 불러오는 기능을 테스트할 때 스텁으로 항상 같은 유저 데이터를 리턴하게 만들 수 있다. hilt를 사용할 경우 hilt를 통해 스텁 레포지토리를 주입하면 유저 프로필 데이터를 리턴하는 로직을 검증할 수 있다.
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [UserRepositoryModule::class]
)
object TestUserRepositoryModule {
@Provides
fun provideStubUserRepository(): UserRepository {
return StubUserRepository()
}
}
목을 사용할 수 있는 경우는 아래와 같다.
- 행동 기반 검증 : 어떤 객체가 어떤 메서드를 정확히 호출했는지 검증하고 그 호출이 어떻게 이뤄졌는지 더 세세하게 테스트할 수 있다
- 복잡한 상호작용을 검증해야 하는 겨우 : 여러 객체 간 상호작용이 복잡하게 발생할 때 호출 순서, 횟수를 검증할 수 있다
- 의존성 격리 : 테스트 중 실제 객체의 동작을 시뮬레이션해서 의존성 간의 상호작용을 격리하고 테스트할 수 있다
API 호출을 테스트할 때 실제 API 서버와 통신하지 않는 테스트를 만들고 싶을 수 있다. 이 때 목을 써서 서버가 응답을 주는 것처럼 동작하게 만들어 네트워크 요청이 정상적으로, 또는 시나리오에 맞게 작동하는지 검증할 수 있다.
hilt를 사용한다면 MockK 라이브러리로 모킹한 객체를 의존성 주입할 수 있어서 더 유연하게 테스트를 구성할 수 있다. 이를 통해 복잡한 동작을 모킹해서 테스트 시 실제 서비스 호출을 막고 상호작용에만 초점을 맞춰 검증할 수 있다.
아래는 API 호출 로직이 포함된 뷰모델을 테스트할 때 API를 호출하는 레포지토리를 목으로 주입해서 실제 네트워크 요청을 보내지 않고 API가 제대로 호출되는지 검증하는 테스트의 예시다.
inject()와 mockk()은 각 테스트 메서드가 실행될 때마다 새로 주입해야 한다면 @Before에 넣는 게 좋다. 또는 모든 테스트에서 같은 의존성을 사용하거나 매번 초기화하지 않아도 된다면 @BeforeAll에 넣는 걸 고려할 수도 있다.
@Test
fun testViewModelApiCall() {
val mockRepository = mockk<UserRepository>()
coEvery { mockRepository.getUser(any()) } returns User("John", 25)
hiltRule.inject()
viewModel.getUser("testUser")
verify { mockRepository.getUser("testUser") }
}
페이크를 사용할 수 있는 경우는 아래와 같다.
- 실제 시스템과 유사하게 작동하는 간단한 로직 검증이 필요한 경우. 이 객체는 프로덕션에서 사용하진 않는다
- 간단한 테스트 환경 구축이 필요한 경우 : 파일 입출력, DB 등의 의존성을 대체해서 간단한 메모리 기반 테스트를 만들 수 있다
참고로 위의 페이크를 쓸 수 있는 경우들을 보고 그냥 목을 써도 되지 않을까 생각할 수 있다. 그러나 페이크는 실제 동작을 테스트하거나 상태 변화를 검증할 때, 목은 상호작용에 초점을 두거나 호출 여부, 횟수, 순서 검증이 더 중요한 테스트에서 사용하는 게 더 적합할 수 있다.
페이크는 로직을 포함하기 때문에 실제 구현체를 간략화한 버전이라고 볼 수 있다. 그래서 DB의 페이크를 만들었다면 실제로 저장, 검색, 제거 등의 동작을 모사한다. 즉 상태 변화를 추적하면서 동작하기 때문에 상태 기반 테스트를 작성할 수 있게 도와준다.
목은 메서드 호출을 모사해서 대상 메서드가 호출됐는지, 몇 번 호출됐는지 등을 검증하는 행동 기반 테스트를 작성할 수 있게 도와준다. 그리고 의존성 간의 상호작용을 테스트해야 할 때 사용할 수 있는데, 상호작용은 아래의 경우를 의미한다.
- DB의 메서드가 호출됐는지
- API 요청이 성공 / 실패했는지
데이터의 저장, 조회보다 호출 자체가 검증 대상이기 때문에 페이크와 목은 서로 사용하는 경우가 다르다고 볼 수 있다. 이 내용을 표로 정리하면 아래와 같다.
구분 | 페이크 | 목 |
어떤 테스트에 사용하는가 | 상태 기반 테스트 (State-based testing) |
행동 기반 테스트 (Behavior-based testing) |
목적 | 객체의 상태 변화 | 객체 간의 상호작용 |
사용 시점 | 실제 동작을 테스트하고 상태변화(저장, 조회) 등을 검증할 때 |
메서드 호출, 호출 횟수, 호출할 때 사용한 매개변수를 검증할 때 |
예시 | 인메모리 DB를 사용한 CRUD 테스트 | DB 메서드 호출 여부, API 호출 성공 여부 검증 |
테스트 대상 | 실제 데이터 흐름, 로직이 중요한 테스트 | 의존성 간 상호작용(메서드 호출)이 중요한 테스트 |
장점 | 실제 동작을 간단하게 모사해서 테스트 성능을 높임 |
의존성 간 상호작용을 추적해서 행동 검증 가능 |
단점 | 상호작용 추적 불가능 | 실제 동작이 아닌 호출 여부만 확인 |
아래는 DB의 CRUD 동작을 테스트하기 위해 만든 FakeUserDatabase를 주입하고 테스트하는 간략한 예시다. 생략된 부분이 있으니 참고만 하자.
class FakeUserDatabase : UserDatabase {
private val users = mutableListOf<User>()
override suspend fun insertUser(user: User) {
users.add(user)
}
override suspend fun getUserById(userId: String): User? {
return users.firstOrNull { it.id == userId }
}
override suspend fun deleteUser(user: User) {
users.remove(user)
}
fun clearDatabase() {
users.clear()
}
}
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DatabaseModule::class]
)
object TestDatabaseModule {
@Provides
fun provideFakeDatabase(): UserDatabase {
return FakeUserDatabase()
}
}
@Test
fun testAddAndRetrieveUser() = runBlocking {
val user = User("1", "Alice", 25)
// 유저 추가
viewModel.addUser(user)
// 유저 조회
val retrievedUser = viewModel.getUser("1")
// 유저 정보 검증
assertNotNull(retrievedUser)
assertEquals("Alice", retrievedUser?.name)
assertEquals(25, retrievedUser?.age)
}
스파이를 사용할 수 있는 경우는 아래와 같다.
- 메서드 부분 검증 : 실제 메서드를 호출하면서 어떤 메서드의 동작을 감시하거나 호출 여부, 횟수를 추적해야 하는 경우
- 객체 행동의 일부분을 바꿔서 테스트해야 하는 경우
- 실제 객체의 상태 변화 확인 : 메서드 호출 시 객체 상태가 실제로 어떻게 변하는지 확인해야 하는 경우
DB 객체에 스파이를 붙여서 테스트 중 실제로 DB에 접근하는지 확인하거나 호출 횟수, 사용된 매개변수를 추적할 수 있다.
아래는 레포지토리를 사용하는 테스트에서 spy 레포지토리를 주입해서 loadUserData()를 감시하고 호출된 인자를 검증하는 예시 코드다.
val spyRepository = spyk(UserRepositoryImpl())
hiltRule.inject()
viewModel.loadUserData("testUser")
verify { spyRepository.getUser("testUser") }
결론은 본인이 어떤 테스트를 작성할 것이냐에 따라 뭘 사용할지가 정해진다. 무턱대고 한 종류만 사용하기보단 각각의 의미를 이해하고 잘 활용하면 더 좋은 테스트를 작성할 수 있을 거라고 생각된다.