Android

[Android] koin 의 KoinAndroidUnitTestRule

sh1mj1 2024. 10. 23. 21:05
class KoinAndroidUnitTestRule(
    private val modules: List<Module>,
) : TestWatcher() {
    constructor(vararg modules: Module) : this(modules.toList())

    override fun starting(description: Description) {
        if (getKoinApplicationOrNull() == null) {
            startKoin {
                androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext)
                modules(modules)
            }
        } else {
            loadKoinModules(modules)
        }
    }

    override fun finished(description: Description) {
        stopKoin()
        // unloadKoinModules(modules)
    }
}

finished 에서 stopKoin 과 unloadKoinModules(modules) 했을 때으 ㅣ차이는??

@RunWith(AndroidJUnit4::class)
@Config(application = TestApplication::class)
class PokemonDetailActivityTest {
    @get:Rule
    val activityRule = activityScenarioRule<PokemonDetailActivity>()
    private val scenario get() = activityRule.scenario

    @get:Rule
    val koinTestRule =
        KoinAndroidUnitTestRule(
            testViewModelModule,
        )

    @Test
    fun Activity 실행 테스트()
    {
        scenario.onActivity { activity ->
            activity.shouldNotBeNull()
        }
    }

    @Test
    fun Activity 구성 변경 테스트()
    {
        // given
        var previewVm: PokemonDetailViewModel? = null

        scenario.onActivity { activity ->
            previewVm = activity.getViewModel()
        }

        // when
        scenario.recreate()

        scenario.onActivity {
            it.getViewModel<PokemonDetailViewModel>() shouldBe previewVm
        }
    }
}

이 액티비티 테스트에서 이런 식으로 두개의 테스트를 돌리면
하나의 테스트는 통과하고 하나의 테스트는 KoinApplication has not been started 가 되면서 실패하는 이유는?

이 상황에서 발생하는 에러는 KoinApplication has not been started 이다.
이 에러는 Koin 이 어떻게 시작되고 중지되는지에 관련된 문제이다.
특히, 이 문제는 KoinAndroidUnitTestRule 의 finished 메소드에서 stopKoin()의 동작과 관련이 있다.

stopKoin 과 unloadKoinModules()의 주요 차이점

  • stopKoin(): 이것은 Koin context를 완전히 종료하며 Koin application 자체도 포함된다. 이 메소드가 호출되면 전체 의존성 주입 설정 (모듈, 정의 등)이 파괴되고 Koin
    context가 더 이상 사용할 수 없다. 이것은 stopKoin() 이후에 Koin이 명시적으로 다시 startKoin()으로 재시작될 때까지 효과적으로 "꺼진" 상태임을 의미한다.
  • unloadKoinModules(modules): 이것은 전체 Koin context를 중지하지 않고 특정 모듈을 언로드한다. 이것은 특정 모듈의 정의를 Koin application에서 제거하지만
    Koin 환경을 유지한 채로 둔다.

문제점

너의 코드에서는 finished() 메소드에서 stopKoin()을 사용하고 있는데, 이것은 각 테스트 후에 Koin을 완전히 중지한다. 그러나 starting() 메소드에서는 Koin이 이전에 시작되지 않았을 때만
다시 시작된다. 이 로직은 여러 테스트를 실행할 때 문제를 일으킨다.

  1. 첫 번째 테스트:
    • Koin은 starting()에서 시작된다.
    • 테스트는 성공적으로 실행된다.
    • 테스트가 끝나면 stopKoin()이 호출되어 Koin을 완전히 종료한다.
  2. 두 번째 테스트:
    • 첫 번째 테스트 이후에 stopKoin()이 호출되었기 때문에 Koin은 완전히 중지되었다.
    • starting() 메소드는 getKoinApplicationOrNull()을 확인하지만 Koin이 중지되었기 때문에 null을 반환한다. 따라서 Koin을 다시 시작한다.
    • 테스트가 시작되기 전에 Koin이 완전히 사용 가능하지 않을 때 비동기 작업이나 타이밍 문제가 있는 경우 문제가 발생한다.
    • 이로 인해 두 번째 테스트에서 KoinApplication has not been started 오류가 발생한다.

하나는 통과하는데 다른 테스트들은 터지는 이유

stopKoin() 을 사용하고 있는데, 두번째 테스트에서 KoinApplication has not been started 가 발생하는 이유는 두번째 테스트가 실행되기 전에 Koin이 완전히 시작되지 않았기
때문이다.

Solution

1 stopKoin() 대신 unloadKoinModules(modules) 사용

완전히 Koin을 중지하는 대신 각 테스트 후에 테스트 모듈을 언로드할 수 있다. 이렇게 하면 Koin context가 테스트 간에 유지되지만 특정 모듈의 정의가 언로드되어 다음 테스트에 영향을 미치지 않는다.

override fun finished(description: Description) {
    unloadKoinModules(modules)
}

이 접근은 Koin이 테스트 간에 초기화된 상태를 유지하면서 이전 테스트에서 사용한 모듈을 지우므로 다음 테스트에서 적절한 설정이 가능하다.

2. 각 테스트에서 명시적으로 Koin 시작 및 중지

만약 각 테스트마다 Koin을 완전히 재시작하길 원한다면, KoinAndroidUnitTestRule을 조정하여 Koin이 매번 재시작되도록 할 수 있다. 이렇게 하면 여전히 stopKoin()을 사용할 수
있지만, 각 테스트 시작 시 Koin이 항상 새로 시작되도록 보장해야 한다.

override fun starting(description: Description) {
    stopKoin() // Ensure Koin is stopped before starting
    startKoin {
        androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext)
        modules(modules)
    }
}

이렇게 하면 각 테스트마다 Koin이 완전히 재시작되므로 잔류 상태나 부분적 초기화 문제를 방지할 수 있다.

Recommended Option

추천되는 옵션

I recommend using option 1, where you switch from stopKoin() to unloadKoinModules(modules). This is a cleaner
solution because it avoids the overhead of starting and stopping Koin for every test, and it ensures that the Koin
context stays alive across the test suite. By only unloading the specific test modules, you avoid the potential issues
that arise from completely shutting down Koin.
stopKoin() 대신 unloadKoinModules(modules) 사용하는 option 1 추천.

요약

  • finished()에서 stopKoin() 대신 unloadKoinModules(modules) 사용
  • 이 접근은 테스트 간에 Koin을 유지하면서 테스트별 모듈을 지우므로 테스트 간 영향을 줄인다.
  • 대신에, 각 테스트마다 Koin을 완전히 재시작하길 원한다면, 각 테스트 시작 시 stopKoin()을 항상 호출하도록 보장해야 한다. 그러나 이 방법은 약간 테스트가 느려질 수 있다.

로그 찍어보기

class KoinAndroidUnitTestRule(
    private val modules: List<Module>,
) : TestWatcher() {
    constructor(vararg modules: Module) : this(modules.toList())

    override fun starting(description: Description) {
        println("starting called")

        if (getKoinApplicationOrNull() == null) {
            println("startKoin called")
            startKoin {
                androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext)
                modules(modules)
            }
        } else {
            println("startKoin is not called just load koin modules")
            loadKoinModules(modules)
        }
    }

    override fun finished(description: Description) {
        println("finished called")
        stopKoin()
        println("stopKoin called")
        println("getKoinApplicationOrNull(): ${getKoinApplicationOrNull()}")
    }
}
이미지 이미지

이상하다 finished 호출, stopKoin 후에도 getKoinApplicationOrNull(): org.koin.core.KoinApplication@5a791f63
이 찍힘다.

코인에서는, KoinKoinApplication은 관련이 있지만 서로 다른 목적을 가지고 있다.
이 두 개념 사이의 차이를 이해하면 코인이 어떻게 응용 프로그램 전반에 걸쳐 의존성 주입을 관리하는지 명확해진다.

1. KoinApplication

  • 정의: KoinApplication은 Koin 의존성 주입 (DI) 시스템의 전체 구성을 나타낸다.
    DI 시스템의 컨텍스트를 설정하고 관리하는 책임이 있다. 모듈을 로드하고 로깅을 구성하며 필요한 다른 구성 요소를 초기화한다.
  • 역할: KoinApplication은 Koin 환경의 초기화 및 관리를 담당한다. Koin을 설정하고 모듈을 로드하며 DI 컨테이너를 설정하는
    애플리케이션의 구성을 유지한다.
  • 생명 주기: startKoin()가 호출될 때 KoinApplication 객체가 생성된다. 이 객체는 수명 동안 전역 Koin 컨텍스트를
    관리하고 모듈의 로드/언로드, Koin의 시작 및 기타 구성을 조정한다.
  • 기능:
    • Koin을 초기화한다.
    • 전체 의존성 주입 설정 (모듈, 로깅 등)을 보유한다.
    • 초기화 중에 인스턴스를 즉시 생성할 수 있다 (createEagerInstances()를 통해).
    • Koin 인스턴스 및 기타 구성 상태를 위한 컨테이너이다.

요약: KoinApplication은 Koin의 전체 설정 컨테이너로, Koin 시스템의 전반적인 설정 및 수명 주기를 관리한다.

2. Koin

  • Definition: Koin is the actual DI container that holds all the services and definitions you've registered in
    your modules. It's the object that is used to resolve dependencies during runtime.
  • Role: Koin manages the creation, retrieval, and lifecycle of objects defined in the modules. It's the "core" of
    the DI system that provides instances of the dependencies when requested. This is where all your services (singletons,
    factories, etc.) live and are resolved.
  • Lifecycle: The Koin object is instantiated when the KoinApplication is started. The Koin instance is held
    by KoinApplication, and it's what actually processes DI requests and provides instances.
  • Functionality:
    • It handles requests to inject dependencies.
    • It maintains the lifecycle of instances (e.g., singleton, factory).
    • It manages module loading and unloading at runtime.
    • It provides APIs to retrieve objects, such as get<T>() or inject<T>().
  • 정의: Koin은 실제 DI 컨테이너로, 모듈에 등록된 모든 서비스와 정의를 보유한다. 런타임 중에 의존성을 해결하는 데 사용되는 객체이다.
  • 역할: Koin은 모듈에서 정의된 객체의 생성, 검색 및 수명 주기를 관리한다. 요청 시 의존성의 인스턴스를 제공하는 DI 시스템의 "핵심"이다. 이곳에 모든 서비스 (싱글톤, 팩토리 등)가 존재하고
    해결된다.
  • 생명 주기:Koin 객체는 KoinApplication이 시작될 때 인스턴스화된다. Koin 인스턴스는 KoinApplication에 의해 보유되며, DI 요청을 처리하고 인스턴스를
    제공하는 역할을 한다.
  • 기능:
    • 의존성 주입 요청을 처리한다.
    • 인스턴스의 수명 주기를 유지한다 (예: 싱글톤, 팩토리).
    • 런타임에서 모듈 로드 및 언로드를 관리한다.
    • get<T>() 또는 inject<T>()와 같은 객체를 검색하는 API를 제공한다.
  • 요약: Koin은 애플리케이션이 실행되는 동안 의존성을 저장하고 해결하는 활성 DI 컨테이너이다.

실제 차이점

  • KoinApplication: Koin 환경의 초기화자 및 _관리자_이다. Koin을 구성하고 모듈을 로드하며 DI 시스템을 설정
  • Koin: Koin DI의 _엔진_으로, 실제로 앱의 의존성의 수명 주기와 해결을 처리한다.

비유

  • Koin을 공장으로 생각해보자:
  • KoinApplication은 _공장 설정_이다. 여기서 청사진 (모듈)이 로드되고 조립 라인이 구성된다.
  • Koin공장 자체이며, 필요할 때 실제 객체 (의존성)를 _생산_하고 _제공_하는 역할을 한다.

koin 의 activityViewModel vs androidx 의 activityViewModels

PokemonDetailSkillFragment 에서

import androidx.fragment.app.activityViewModels

private val activityViewModel: PokemonDetailViewModel by activityViewModels()

를(koin 의 activityViewModel) 써도 제대로 동작하고,

import org.koin.androidx.viewmodel.ext.android.activityViewModel

private val activityViewModel: PokemonDetailViewModel by activityViewModel<PokemonDetailViewModel>()

를(ViewModelProvider. Factory 에 직접 접근하는 FragmentViewModelLazy.kt 의 activityViewModel) 써도 제대로 동작해.

무슨 차이가 있고 어떤 것을 더 추천하니?

Great question! Both of these approaches—using activityViewModels() from Android’s Fragment API
and activityViewModel() from Koin—achieve the same result, which is sharing a ViewModel between a fragment and its
parent activity. However, there are some key differences between them, especially considering their usage within the
context of Koin and Android's native mechanisms. Let's break it down.

1. AndroidX의 activityViewModels()

import androidx.fragment.app.activityViewModels

private val activityViewModel: PokemonDetailViewModel by activityViewModels()
  • What it does: This is part of the AndroidX ViewModel library. It retrieves a ViewModel scoped to
    the Activity. If the Activity has already created the ViewModel, it will return the same instance to the
    fragment. Otherwise, it will create the ViewModel using the default ViewModelProvider unless you pass a custom
    factory.
  • How it works: The ViewModel will be retained across configuration changes (like screen rotations), and the
    fragment will share the same ViewModel instance with the activity.
  • Default factory behavior: If you don't provide a factory explicitly, activityViewModels() will use the
    default ViewModelProvider factory, which means it expects your ViewModel to have an empty constructor or one that
    is automatically handled by SavedStateHandle if using ViewModel with state.
  • Manual Factory: If your ViewModel requires dependencies (like DexRepository), you need to manually provide a
    factory, which can make dependency injection with Koin less convenient.
  • 무엇을 하는가?: 이것은 AndroidX ViewModel 라이브러리의 일부이다. Activity에 대한 범위가 지정된 ViewModel을 검색한다. Activity
    이미 ViewModel을 생성했다면, 동일한 인스턴스를 프래그먼트에 반환한다. 그렇지 않으면 기존의 ViewModelProvider를 사용하여 ViewModel을 생성한다.
  • 작동 방식: ViewModel은 구성 변경(화면 회전과 같은)을 통해 유지되며, 프래그먼트는 활동과 동일한 ViewModel 인스턴스를 공유한다.
  • 기본 팩토리 동작: 명시적으로 팩토리를 제공하지 않으면, activityViewModels()는 기본 ViewModelProvider 팩토리를 사용한다. 즉, ViewModel이 빈 생성자를
    가져야 하거나 ViewModel을 상태와 함께 사용할 때 SavedStateHandle에 의해 자동으로 처리되어야 한다.
  • 수동 팩토리: ViewModel에 의존성(예: DexRepository)이 필요한 경우, 의존성 주입이 덜 편리해질 수 있으므로 수동으로 팩토리를 제공해야 한다.

2. activityViewModel() from Koin

2. Koin의 activityViewModel()

import org.koin.androidx.viewmodel.ext.android.activityViewModel

private val activityViewModel: PokemonDetailViewModel by activityViewModel<PokemonDetailViewModel>()
  • 무엇을 하는가?: 이것은 Koin의 확장 함수로, 활동에 범위가 지정된 ViewModel을 검색한다. activityViewModels()와 유사하지만, Koin의 의존성 주입 시스템을 통합한다.
  • 작동 방식: Koin은 PokemonDetailViewModel에 선언된대로 의존성을 자동으로 생성하고 주입한다.
  • 수동 팩토리가 필요 없음: Koin은 의존성 주입을 처리하므로 수동으로 ViewModelFactory를 제공할 필요가 없다. 특히, ViewModel에 리포지토리, 로거 등과 같은 생성자 인수가
    있는 경우 유용하다.

Key Differences

Aspect activityViewModels() (AndroidX) activityViewModel() (Koin)
Dependency Injection No built-in support, you need to use a ViewModelProvider.Factory if dependencies are needed. Automatically resolves dependencies using Koin’s DI.
Factory Setup Requires manual handling for complex ViewModels (e.g., with constructor parameters). Koin handles this via the DI module, making it easier to inject dependencies.
Configuration Standard Android API, no external libraries needed. Requires Koin to be set up for the project.
Recommendation More flexible if you're not using a DI framework. Better if you're already using Koin for DI, simplifies ViewModel creation.

주요한 차이

Aspect activityViewModels() (AndroidX) activityViewModel() (Koin)
DI ViewModelProvider.Factory 를 사용할 필요가 있다. built-in 지원이 없다 koin 의 di 를 사용해서 자동으로 의존성 해결
팩토리 설정

Which One Should You Use?

Recommendation: Use activityViewModel() from Koin

  • If you're using Koin for dependency injection, I strongly recommend sticking with activityViewModel() from Koin.
    It allows you to leverage Koin's DI framework seamlessly, and you don’t have to worry about
    managing ViewModelProvider.Factory manually.
  • Cleaner code: You declare the dependencies in the Koin module (as you did in viewModelModule), and Koin
    automatically provides the PokemonDetailViewModel with the necessary DexRepository and AnalyticsLogger injected.
  • Less boilerplate: With activityViewModel(), Koin handles everything under the hood, making your code cleaner,
    especially for ViewModels with dependencies.

When to Use activityViewModels():

  • No DI framework: If you're not using any dependency injection framework (like Koin or Dagger), you would
    use activityViewModels() and manually provide a ViewModelFactory for complex ViewModels with dependencies.
  • Standard Android projects: If you're building a small project and don’t want to introduce a DI
    framework, activityViewModels() works fine, but you’ll need to manage the factory for dependency injection.

Example of DI with Koin:

Here’s how Koin simplifies things. Instead of worrying about how to provide the DexRepository and AnalyticsLogger:

val viewModelModule = module {
    viewModelOf(::PokemonDetailViewModel)  // Koin will resolve the dependencies
}

And in the fragment:

private val activityViewModel: PokemonDetailViewModel by activityViewModel()

Koin will take care of injecting DexRepository and AnalyticsLogger as declared in your module.

결론

  • Koin 사용자: activityViewModel()을 사용하라. Koin의 DI와 잘 통합되며, 의존성을 처리하고 코드베이스를 단순화한다.
  • DI를 사용하지 않는 사용자: AndroidX의 activityViewModels()를 사용하되, ViewModel에 의존성이 있는 경우 ViewModelProvider.Factory를 수동으로 관리해야 한다.

당신의 경우, 이미 Koin을 사용하고 있으므로 activityViewModel()을 사용하라. 프로젝트의 나머지 부분과 일관성이 있으며, 코드를 더 간단하게 유지할 수 있다.