Tlaster / PreCompose

Compose Multiplatform Navigation && State Management
https://tlaster.github.io/PreCompose/
MIT License
803 stars 47 forks source link

How to inject VM interface rather that concrete class using koin? #346

Open emanuelecastelli opened 3 days ago

emanuelecastelli commented 3 days ago

Hi, for testing purpose i need to inject/retrieve viewmodels inside composable functions using interfaces rather that concrete classes (Koin for DI). For mocking i use MocKMP, actually the only mocking lib in KMM env. It works well, but can mock just interfaces (with Mockito you can mock classes too).

Now i have something like this, but when testing koin can not find vm class injected. There isn't a way to find vm concrete class by it's interface in stateholder?

Thx

// Test.kt
// ....
@Mock
  lateinit var loginViewModel: ILoginViewModel

  private var testModule: Module = module {
      factory<ILoginViewModel>(qualifier = named("loginVM")) { loginViewModel }
  }
// ....
// Composable.kt
// ...
    val viewModel: ILoginViewModel = koinViewModel(LoginViewModel::class, named("loginVM"))
// ...
Tlaster commented 3 days ago

Have you call startKoin in your testing code? You can check out this docs for testing with koin: https://insert-koin.io/docs/reference/koin-test/testing/

emanuelecastelli commented 3 days ago

Have you call startKoin in your testing code? You can check out this docs for testing with koin: https://insert-koin.io/docs/reference/koin-test/testing/

Yeah, it's started correctly, in fact the error i get is

org.koin.core.error.NoBeanDefFoundException: No definition found for type 'viewmodels.LoginViewModel' and qualifier 'loginVM'. Check your Modules configuration and add missing type and/or qualifier!
    at org.koin.core.scope.Scope.throwDefinitionNotFound(Scope.kt:301)
    at org.koin.core.scope.Scope.resolveValue(Scope.kt:271)
    at org.koin.core.scope.Scope.resolveInstance(Scope.kt:233)
    at org.koin.core.scope.Scope.get(Scope.kt:212)
    at moe.tlaster.precompose.koin.KoinKt$resolveViewModel$1.invoke(Koin.kt:44)
    at moe.tlaster.precompose.koin.KoinKt$resolveViewModel$1.invoke(Koin.kt:43)
    at moe.tlaster.precompose.stateholder.StateHolder.getOrPut(StateHolder.kt:18)
....
Tlaster commented 3 days ago

You might need to try koinViewModel(ILoginViewModel::class, named("loginVM")) instead of koinViewModel(LoginViewModel::class, named("loginVM"))

emanuelecastelli commented 3 days ago

ILoginViewModel it's an interface and i can not inherit it from ViewModel as needed by koinViewModel

fun <T : ViewModel> koinViewModel(
    vmClass: KClass<T>,
//...
interface ILoginViewModel : IBaseViewModel {
    suspend fun getAppStartupCount(): Int?

    suspend fun addAppStartupCount()

    suspend fun resetAppStartupCount()
//...

Maybe i need some refactor on my side? Any suggestion is appreciated :)

Tlaster commented 3 days ago

Oops, my fault, I forgot the generic limitation of the koinViewModel. There're some workaround I can think of:

emanuelecastelli commented 3 days ago

Oops, my fault, I forgot the generic limitation of the koinViewModel. There're some workaround I can think of:

  • Use factory<LoginViewModel> instead of factory<ILoginViewModel> in your testModule definition.
  • Make your IBaseViewModel or ILoginViewModel a abstract class and extend from ViewModel.
  • If you're totally not care about Lifecycle things, you can just get<LoginViewModel> in your testing code.

Thx for reply. I need interface because MocKMP can mock just interfaces, so i can not use abstract classes. The problem is in composable function rather that in test: how can i retrieve from Koin the right VM instance and having it bound to lifecycle?

@Composable
fun LoginScreen() {

//    val viewModel: ILoginViewModel = getKoinInstance() -> not bound to lifecycle
//    val viewModel:ILoginViewModel = viewModel(LoginViewModel::class) {
//        LoginViewModel()
//    }  -> same as following solution

    val viewModel: ILoginViewModel = koinViewModel(LoginViewModel::class, named("loginVM")) // not retrieved from Koin DI cause i'm using interface

Im looking for an alternative lib for mocking, maybe with more luck :)

Tlaster commented 3 days ago

I starting to wonder how Koin it self will handle this use case in Android without PreCompose 🤔. There's still a workaround, if you look into this file, which is some copy&&pasting and little editing from koin, you can make your own koinViewModel function without the ViewModel generic limitation.

emanuelecastelli commented 3 days ago

I starting to wonder how Koin it self will handle this use case in Android without PreCompose 🤔. There's still a workaround, if you look into this file, which is some copy&&pasting and little editing from koin, you can make your own koinViewModel function without the ViewModel generic limitation.

Yeah this is the first idea that i had, but before start a pr i was looking for a pre baked solution 😀 I'll try and keep you informed 😉

emanuelecastelli commented 2 days ago

ok @Tlaster i resolved it using this fun:

@Composable
fun <T : IBaseViewModel> resolveViewModel(
    vmClass: KClass<T>,
    stateHolder: StateHolder = checkNotNull(LocalStateHolder.current) {
        "No StateHolder was provided via LocalStateHolder"
    },
    key: String? = null,
    scope: Scope = LocalKoinScope.current,
    qualifier: Qualifier? = null,
    parameters: ParametersDefinition? = null,
): T {
    return stateHolder.getOrPut(qualifier?.value ?: key ?: vmClass.canonicalName ?: "") {
        scope.get(vmClass, qualifier, parameters)
    }
}

I don't like very much the bounding to IBaseViewModel because maybe i will need to retrieve VM of other kind, but by now i can run tests and app with correctly injected/retrieved VM. Maybe you consider to include a fun like that in future?

In any case, thanks for the answers and for your work!