google / dagger

A fast dependency injector for Android and Java.
https://dagger.dev
Apache License 2.0
17.45k stars 2.02k forks source link

[Testing] Dagger does not support providing @AssistedFactory types #3200

Open LeonRa opened 2 years ago

LeonRa commented 2 years ago

Unless I'm doing something wrong, it seems that @HiltAndroidTest doesn't play well with @AssistedFactory.

In my use-case, I would like to replace the injected factory with one that I can configure to return a mock during instrumented tests. My setup uses MVVM, though it uses a command pattern for pushing routing emissions (and other single-fire events) to its View.

I have a Router that is capable of performing some Activity/Fragment navigation but requires an assisted parameter:

class SomeRouter
@AssistedInject
constructor(
  @Assisted @IdRes private val fragmentContainerId: Int,
  private val host: FragmentActivity,
) {

  fun navigateToSomeScreen() = /* ... */

  // Other routes omitted

  /** Assisted DI factory for this Router. */
  @ActivityScoped
  @AssistedFactory
  interface Factory {
    /** Creates a new instance of this Router. */
    fun create(
      @IdRes fragmentContainerId: Int,
    ): SomeRouter
  }
}

I have a ViewModel which emits routing commands to any subscribers using an RxJava Flowable:

@HiltViewModel
class SomeViewModel @Inject constructor() : ViewModel() {

  // Simplified from production code
  val routes: Flowable<SomeRouter.() -> Unit> = Flowable.just { navigateToSomeScreen() }
}

Both of these are then used by an Activity, which ties the ViewModel emissions to a Router created by using a property-injected Factory:

@AndroidEntryPoint
class SomeActivity : BaseActivity() {

  @Inject lateinit var routerFactory: SomeRouter.Factory

  private val viewModel: SomeViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val binding = SomeActivityBinding.inflate(layoutInflater)
    setContentView(binding.root)

    val router = routerFactory.create(binding.fragmentContainer.id)

    // Omitting dispose / re-subscription code
    viewModel.routes.subscribe { router.it() }
  }
}

The use case here is being able to write an instrumentation test which validates that when the ViewModel emits a route, the Activity propagates it to the Router. Using Mockk, this is what I have at the moment:

@HiltAndroidTest
class SomeActivityTest {

  @get:Rule var hiltRule = HiltAndroidRule(this)
  @get:Rule var activityScenarioRule = activityScenarioRule<SomeActivity>()

  private val mockRoute: SomeRouter.() -> Unit = mockk()
  private val mockRouter: SomeRouter = mockk {
    every { mockRoute.invoke(this@mockk) } returns Unit
  }  

  @BindValue val mockRouterFactory: SomeRouter.Factory = mockk {
    every { create(R.id.fragmentContainer) } returns mockRouter
  }
  @BindValue val mockViewModel: SomeViewModel = mockk(relaxed = true)

  @Before
  fun init() {
    hiltRule.inject()
  }

  @Test
  fun routeEmission_callsRouter() {
    every { mockViewModel.routes } returns Flowable.just(mockRoute)

    verify(exactly = 1) { mockRoute.invoke(mockRouter) }
  }
}

As is, this code fails with Dagger does not support providing @AssistedFactory types due to the @BindValue of the Router factory.

I did find #2301 and #2370 during my search for solutions, but I'd like to know if something has changed since then, given the strong push for Hilt over the last year. While my use-case may not be common, I can see others running into similar use-cases with classes like ViewModels requiring runtime parameters.

Thank you in advance!

bcorso commented 2 years ago

I did find #2301 and #2370 during my search for solutions, but I'd like to know if something has changed since then

Hi @LeonRa, the situation is still the same as in https://github.com/google/dagger/issues/2301#issuecomment-765729050.

As a workaround, could you move your navigateToSomeScreen logic into an @Inject class that you can mock instead? Something like:

class SomeRouter
@AssistedInject
constructor(
  @Assisted @IdRes private val fragmentContainerId: Int,
  private val navigator: Navigator
) {
  fun navigateToSomeScreen() = navigator.navigateTo(fragmentContainerId)

  // ...
}

// Replace this class with a mock instead of the factory
class Navigator @Inject constructor(...) {
  fun navigateTo(val fragmentContainerId: Int) = /* ... */ ;
}
LeonRa commented 2 years ago

Thank you for the speedy response @bcorso! This isn't ideal, but makes sense.

What would be the approach when the above delegation approach isn't so clear cut, let's say like an assisted VM?

class SomeViewModel
@AssistedInject
constructor(
  @Assisted private val param: SomeRuntimeParam,
  private val someDependency: SomeDependency,
) : ViewModel() {

  @AssistedFactory
  interface Factory {
    fun create(param: SomeRuntimeParam): SomeViewModel
  }
}
bcorso commented 2 years ago

The testing philosophy of Hilt is to try to use real dependency as much as possible (this may mean testing the overall result of an action, e.g. in the case of the Router you might test that the activity is displaying the expected fragment rather than trying to intercept the navigateToSomeScreen call).

In the case of SomeViewModel, it really depends what you're trying to mock out. Do you really need to mock out the entire view model?

If you really need to mock your assisted factory then you could probably use the approach in https://github.com/google/dagger/issues/2301#issuecomment-765729050 combined with @TestInstallIn to provide a fake for your factory.

LeonRa commented 2 years ago

The philosophy makes total sense, thank you for the link. I'll be sure to give my approach more thought.

In case you're curious, here is some context around my current experimentation: We already unit test ViewModels, Repositories and other classes in the model layer, so the thinking is to try validating the UI layer by providing it with completely mocked state. While this won't necessarily replace longer instrumentation / E2E tests, I'm interested in seeing if it can provide enough coverage to avoid the writing of more brittle multi-screen flows, setup of network fakes, and/or the addition of things like idling resources to production code.

You've been a great help so far and I'd love to hear your thoughts here. Absolutely no pressure though, as I realize I'm asking for more of your time.