Search code examples
androidkotlintestingdagger-hilt

Hilt Testing - Replace internal Hilt-Module in separate Android multi-module app


I have an Android app where the codebase is split into 2 different modules: App and Domain

Goal: I am attempting to use the Hilt provided testing functionality to replace a Domain internal dependency when creating App tests.

In Domain, I have an internal interface with an internal implementation, like below:

internal interface Database {
    fun add(value: String)
}

internal class DatabaseImpl @Inject constructor() : Database {
    override fun add(value: String) { ... }
}

The above guarantees that the Database can only be used inside Domain and cannot be accessed from elsewhere.

In Domain I have another interface (which is not internal), for use in App, with an internal implementation, like below:

interface LoginService {
    fun userLogin(username: String, password: String)
}

internal class LoginServiceImpl @Inject constructor(database: Database) {
    override fun userLogin(username: String, password: String) {
        // Does something with the Database in here
    }
}

In Domain I use Hilt to provide dependencies to App, like below:

@Module(includes = [InternalDomainModule::class]) // <- Important. Allows Hilt access to the dependencies provided by InternalDomainModule.
@InstallIn(SingletonComponent::class)
class DomainModule {
   ...
}

@Module
@InstallIn(SingletonComponent::class)
internal class InternalDomainModule {

    @Provides
    @Singleton
    fun provideDatabase() : Database = DatabaseImpl()

    @Provides
    @Singleton
    fun provideLoginService(database: Database) : LoginService = LoginServiceImpl(database)
}

This all works perfectly in isolating my implementations and only exposes a single interface outside of Domain.

However, when I need to provide fake implementations inside App using the Hilt guidelines, I am unable to replace the LoginService as I do not have access to InternalDomainModule (because it is internal to Domain only) and replacing DomainModule does not replace LoginService (as it is provided in another Hilt module, namely InternalDomainModule), like below:

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [DomainModule::class] // [InternalDomainModule::class] is impossible as inaccessible in **App**
)
class FakeModule {

    @Provides
    @Singleton
    fun provideFakeLoginService() : LoginService = FakeLoginServiceImpl() <- Something fake
}

The above leads to only DomainModule being replaced, not InternalDomainModule, which leads to LoginService being provided twice, which makes Hilt unhappy.

Making things not internal to Domain fixes the issue, but defeats the purpose of having a multi-module Android app with clear separations.


Solution

  • Solution 1:

    I would make the internal LoginService in some way different for Dagger than the public one. For example, add a qualifier to it:

    @Module(includes = [InternalDomainModule::class])
    @InstallIn(SingletonComponent::class)
    class DomainModule {
    
        @Provides
        @Singleton
        fun provideLoginService(
            @Named("Internal") internalLoginService: LoginService
        ) : LoginService = internalLoginService
    }
    
    @Module
    @InstallIn(SingletonComponent::class)
    internal class InternalDomainModule {
    
        @Provides
        @Singleton
        fun provideDatabase() : Database = DatabaseImpl()
    
        @Provides
        @Singleton
        @Named("Internal")
        fun provideLoginService(database: Database) : LoginService = LoginServiceImpl(database)
    }
    

    This way, it won't be duplicated in tests.

    Solution 2:

    Even better, you could implement a LoginServiceFactory:

    interface LoginServiceFactory {
        fun create(): LoginService
    }
    
    internal class LoginServiceFactoryImpl @Inject constructor(
        private val database: Database
    ) : LoginServiceFactory {
        override fun create(): LoginService =
            LoginServiceImpl(database)
    }
    

    Then your modules would look like this:

    @Module(includes = [InternalDomainModule::class])
    @InstallIn(SingletonComponent::class)
    class DomainModule {
    
        @Provides
        @Singleton
        fun provideLoginService(loginServiceFactory: LoginServiceFactory) : LoginService =
            loginServiceFactory.create()
    }
    
    @Module
    @InstallIn(SingletonComponent::class)
    internal class InternalDomainModule {
    
        @Provides
        @Singleton
        fun provideDatabase() : Database = DatabaseImpl()
    
        @Provides
        @Singleton
        fun provideLoginServiceFactory(database: Database) : LoginServiceFactory =
            LoginServiceFactoryImpl(database)
    }