Android

[Android] 단위 테스트 시 Stub, Mock, Fake, Spy 선택 기준

참깨빵위에참깨빵_ 2024. 10. 11. 17:02
728x90
반응형

단위 테스트를 작성하다 보면 목과 스텁, 페이크, 스파이 중 뭘 선택해서 작성해야 할지 고민될 수 있다.

결론부터 보면 테스트의 목적에 따라 뭘 사용할지 달라진다고 할 수 있다.

 

뭘 목적으로 하는 테스트인가?

 

먼저 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") }

 

결론은 본인이 어떤 테스트를 작성할 것이냐에 따라 뭘 사용할지가 정해진다. 무턱대고 한 종류만 사용하기보단 각각의 의미를 이해하고 잘 활용하면 더 좋은 테스트를 작성할 수 있을 거라고 생각된다.

반응형