Search code examples
androiddependency-injectiondagger-hilt

How to easily replace production Hilt modules with fakes in all tests


My Android production is code full of Hilt modules that install various production implementations:

@Module
@InstallIn(ApplicationComponent.class)
public abstract class TimeModule {...}
@Module
@InstallIn(ApplicationComponent.class)
public abstract class DatabaseModule {...}

In all my instrumented tests, I would like to replace those bindings with fakes. My test codebase includes modules that bind fake implementations, but having two modules provide the same class obviously causes compile-time errors.

The Hilt documentation recommends using @UninstallModule(), but that means I'd have to add UninstallModule for every single production module in every single test. That seems like the wrong approach.

How would one normally replace production modules with fake modules? And is there a way to install modules from another module like Guice does, so I could remove @InstallIn from all my production modules and instead simply have one ProductionModule that installs all the individual modules? That would make it easier to just uninstall one module in tests.


Solution

  • How would one normally replace production modules with fake modules?

    Probably how it's normally done, is like the documentation said with the UninstallModule annotation. But here is an alternative, which I like to use, using build flavors:

    I like to organize my project, so there are mock and live flavors. And there are 3 folders inside my app module: src/main/kotlin with Activities, Fragments etc..., src/mock/kotlin where my fake bindings live, and finally src/live/kotlin where my real production bindings live.

    Here's the relevant config from my app level build.gradle.kts:

    android {
      productFlavors {
        flavorDimensions("environment")
        register("mock") {
          dimension = "environment"
        }
    
        register("dev") {
          dimension = "environment"
        }
    
        register("prod") {
          dimension = "environment"
        }
    
        sourceSets {
          getByName("mock").java.srcDir("src/mock/kotlin")
          getByName("dev").java.srcDir("src/live/kotlin")
          getByName("prod").java.srcDir("src/live/kotlin")
        }
      }
    }
    
    

    Project structure overview

    Project structure overview

    Inside the live InteractorModule:

    @Module
    @InstallIn(ApplicationComponent::class)
    abstract class InteractorModule {
      @Binds
      abstract fun bindTodoInteractor(interactor: TodoInteractorImpl): TodoInteractor
    }
    

    Inside the FakeInteractorsModule:

    @Module
    @InstallIn(ApplicationComponent::class)
    abstract class InteractorModule {
      @Binds
      abstract fun bindTodoInteractor(interactor: TodoInteractorFake): TodoInteractor
    }
    

    So now you can use the build variant tab to change between the real and the mock implementations of your interfaces. So if you want to use your fakes inside your instrumentation tests use the mock flavor while running the tests.

    One upside of this method, is that by changing the build variant, you can swich between your instrumentation tests using your fakes to using the live implementations. Conversly, you can use your fake implementations inside the actuall application, which can be nice, if you just want to try out the app with mock data.

    I hope this helped a bit to at least promote some ideas, on how you can solve this "two implementations for one interface" dilemma!