Tests Unitaires avec Hilt dans une application android en clean architecture

Fabrice PITOISET
Publicis Sapient France
8 min readFeb 14, 2023

Dans tout projet nous mettons en place des tests unitaires afin de valider les différents cas d’usages de nos applications. Nous devons alors remplacer des dépendances, certains utilisent des classes permettant de renvoyer de fausses données mais le problème avec ces classes c’est qu’elles sont difficiles à maintenir et il faut remplacer les données de ces classes pour chaque cas.

Dans cet article on préfère “mocker” les dépendances et renvoyer les données désirées selon les cas.

On verra comment

  • remplacer des modules Hilt,
  • tester des view models avec robolectric,
  • tester des fragments avec robolectric et espresso
  • et en bonus tester l’application dans TestLab

Le projet source est disponible ici :

Installation des dépendances nécessaires pour les tests

Voici les dépendances de test à installer dans le projet

Hilt

dependencies {
// For Robolectric tests.
testImplementation 'com.google.dagger:hilt-android-testing:2.38.1'
// ...with Kotlin.
kaptTest 'com.google.dagger:hilt-android-compiler:2.38.1'
// ...with Java.
testAnnotationProcessor 'com.google.dagger:hilt-android-compiler:2.38.1'
// For instrumented tests.
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.38.1'
// ...with Kotlin
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.38.1'
// ...with Java.
androidTestAnnotationProcessor 'com.google.dagger:hilt-android-compiler:2.38.1'
}

Mockk

Robolectric

android {
testOptions {
unitTests {
includeAndroidResources = true
}
}
}

dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.8'
}

Espresso

androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'

TU de classes avec injection dans le constructeur

Hilt n’est pas nécessaire lors du test d’une classe qui utilise l’injection de constructeur, vous n’avez pas besoin d’utiliser Hilt pour instancier cette classe. Au lieu de cela, vous pouvez appeler directement un constructeur de classe en transmettant des dépendances fictives, comme vous le feriez si le constructeur n’était pas annoté :

@ExperimentalCoroutinesApi
@Test
fun `GIVEN success with dayoff WHEN call api THEN return success`() = runTest {

val dayOffApiService: DayOffApiService = mockk()

coEvery { dayOffApiService.getAll(any(), any()) } returns Response.success(
"{" +
" \"2021-01-01\": \"1er janvier\",\n" +
" \"2021-04-05\": \"Lundi de Pâques\",\n" +
" \"2021-05-01\": \"1er mai\",\n" +
" \"2021-05-08\": \"8 mai\",\n" +
" \"2021-05-13\": \"Ascension\",\n" +
" \"2021-05-24\": \"Lundi de Pentecôte\",\n" +
" \"2021-07-14\": \"14 juillet\",\n" +
" \"2021-08-15\": \"Assomption\",\n" +
" \"2021-11-01\": \"Toussaint\",\n" +
" \"2021-11-11\": \"11 novembre\",\n" +
" \"2021-12-25\": \"Jour de Noël\"\n" +
"}"
)

val dayOffRemoteDataSourceImpl =
DayOffRemoteDataSourceImpl(dayOffApiService = dayOffApiService)

val result = dayOffRemoteDataSourceImpl.getAll(ZoneDto.METROPOLE)
assertNotNull(result as NetworkStatus.Success)
assertEquals("14 juillet", result.data.dates["2021-07-14"])

}

TU avec remplacement de module

Dans le cas d’un TU avec un module injecté, vous pouvez le remplacer par un autre module factice lors du TU.

Voici un exemple de module avec une base de données Room, lors du TU on remplacera ce module par une base de données en mémoire et non sur disque.

Le module de src/main : base de données fichier database

@InstallIn(SingletonComponent::class)
@Module
object DatabaseModule {
@Provides
fun provideDayOffDatabase(@ApplicationContext context: Context): DayOffDatabase =
Room.databaseBuilder(context, DayOffDatabase::class.java, DATABASE_NAME)
.fallbackToDestructiveMigration()
.build()

@Provides
fun provideDayOffDao(dayOffDatabase: DayOffDatabase): DayOffDao =
dayOffDatabase.dayoffDao()
}

Pour le TU : on indique qu’on remplace le module ci-dessus “DatabaseModule” par celui du TU qui se trouvera dans (src/test)

@TestInstallIn(components = [SingletonComponent::class], replaces = [DatabaseModule::class])
@Module
object DatabaseModuleForTest {
@Singleton
@Provides
fun provideDayOffDatabaseInMemory(@ApplicationContext context: Context): DayOffDatabase =
Room.inMemoryDatabaseBuilder(context, DayOffDatabase::class.java)
.allowMainThreadQueries()
.fallbackToDestructiveMigration()
.build()

@Singleton
@Provides

fun provideDayOffDaodayOffDatabaseInMemory(dayOffDatabase: DayOffDatabase): DayOffDao =
dayOffDatabase.dayoffDao()

}

Ensuite dans le TU on peut alors tester l’enregistrement en base, lecture, suppression, etc en mettant l’annotation HiltAndroidTest et en injectant la base de données en mémoire et non celle sur le disque. Ici on teste via robolectric sur android 12 mais on peut rajouter d’autres versions android.

Via @Inject Hilt va injecter la base de données en mémoire et le localDataSource, dans l’exemple ci-dessous les ligne :

@Inject lateinit var database: DayOffDatabase

et

@Inject lateinit var localDataSourceImpl: LocalDataSource
@ExperimentalCoroutinesApi
@HiltAndroidTest
@Config(application = HiltTestApplication::class, sdk = [Config.OLDEST_SDK, Config.TARGET_SDK], manifest = Config.NONE)
@RunWith(RobolectricTestRunner::class)
class LocalDataSourceImplTest {
@Inject
lateinit var database: DayOffDatabase

@Inject
lateinit var localDataSourceImpl: LocalDataSource

@get:Rule
var hiltRule = HiltAndroidRule(this)

@ExperimentalCoroutinesApi
@Before
fun setUp() {
hiltRule.inject()
}

@After
fun after() {
database.close()
}
//Vos tests...

}

TU pour les view models

Pour les tests unitaires des viewmodels pas besoin de hilt pour les injections via le constructeur.

Exemple de viewmodel :

@HiltViewModel
class DayOffsViewModel @Inject constructor(
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
private val getDayOffsUseCase: GetDayOffsUseCase
) : ViewModel() {

fun getDayOffs(
zone: Zone = Zone.METROPOLE,
year: Int = LocalDate.now().year
): Flow<NetworkStatus<List<DayOff>>> = flow {
emitAll(getDayOffsUseCase(zone, year))
}.flowOn(coroutineDispatcherProvider.default)

}

On mock le use case utilisé dans le view model sur le dispatcher unconfined.

@RunWith(AndroidJUnit4::class)
class DayOffsViewModelTest {
private lateinit var dayOffsViewModel: DayOffsViewModel
private val getDayOffsUseCase: GetDayOffsUseCase = mockk()

@ExperimentalCoroutinesApi
@Test
fun testViewModel() = runTest {
coEvery {
getDayOffsUseCase.invoke(
any(),
any()
)
} returns flowOf(NetworkStatus.Loading, NetworkStatus.Success(emptyList()))

dayOffsViewModel = DayOffsViewModel(TestCoroutineDispatcherProvider(),
getDayOffsUseCase,)

val sequence = mutableSetOf<NetworkStatus<List<DayOff>>>()
dayOffsViewModel.getDayOffs().toSet(sequence)
val expected: List<NetworkStatus<List<DayOff>>> =
listOf(NetworkStatus.Loading, NetworkStatus.Success(emptyList()))
assertTrue(sequence.containsAll(expected))
}

}

TU instrumentalisé pour les fragments

Pour les tests UI on doit créer un runner spécifique pour Hilt

class HiltCustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

et changer le “instrumentation runner” par défaut dans build.gradle du module concerné

defaultConfig {
minSdk 21
targetSdk 31
testInstrumentationRunner "fr.pitdev.dayoff.presentation.HiltCustomTestRunner"
consumerProguardFiles "consumer-rules.pro"
testInstrumentationRunnerArguments clearPackageData: 'true'
}

Il faut aussi créer une fonction pour lancer le fragment dans un container Hilt. Pour cela il faut créer une activité dans le package debug avec l’annotation @AndroidEntryPoint qui servira de container pour les fragments.

@AndroidEntryPoint
class HiltTestActivity : AppCompatActivity()

et réécrire la fonction launchFragmentInHiltContainer via le source disponible ici :

/**
launchFragmentInContainer from the androidx.fragment:fragment-testing library
is NOT possible to use right now as it uses a hardcoded Activity under the hood
(i.e. [EmptyFragmentActivity]) which is not annotated with @AndroidEntryPoint.
*
As a workaround, use this function that is equivalent. It requires you to add
[HiltTestActivity] in the debug folder and include it in the debug AndroidManifest.xml file
as can be found in this project.
*
@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
*/
inline fun <reified T : Fragment> launchFragmentInHiltContainer(
fragmentArgs: Bundle? = null,
@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
crossinline action: Fragment.() -> Unit = {}
) {
val startActivityIntent = Intent.makeMainActivity(
ComponentName(
ApplicationProvider.getApplicationContext(),
HiltTestActivity::class.java
)
).putExtra(
"androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY",
themeResId
)
ActivityScenario.launch<HiltTestActivity>(startActivityIntent).onActivity { activity ->
val fragment: Fragment = activity.supportFragmentManager.fragmentFactory.instantiate(
Preconditions.checkNotNull(T::class.java.classLoader),
T::class.java.name
)
fragment.arguments = fragmentArgs
activity.supportFragmentManager
.beginTransaction()
.add(android.R.id.content, fragment, "")
.commitNow()
fragment.action()
}
}

Ensuite on peut écrire notre TU dans le package src/androidTest, ici on mock le view model via les annotations @BindValue @JvmField car il est injecté par field et non dans le constructeur.

On applique la règle HiltRule et Hilt injectera les modules avant chaque test.

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class DayOffsFragmentTest {
@BindValue
@JvmField
var dayOffsViewModel: DayOffsViewModel = mockk(relaxed = true)

@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)

@Before
fun init() {
val list =
listOf(DayOff(zone = Zone.METROPOLE, id = 1, date = LocalDate.now(), name = "TEST"))
val mutableStateFlow: MutableStateFlow<DayfOffsState> =
MutableStateFlow(DayfOffsState.Loaded(list))
every { dayOffsViewModel.uiState } returns mutableStateFlow.asStateFlow()
hiltRule.inject()
}

@Test
fun testData() {
launchFragmentInHiltContainer<DayOffsFragment>(
fragmentArgs = DayOffsFragmentArgs(param = DayOffViewModelParam()).toBundle()
) { //Ici on pourrait appeler des méthodes accessibles dans le fragment }
onView(withId(fr.pitdev.dayoff.presentation.R.id.dayoff_list)).check(matches(isDisplayed()))
}
}

TU robolectric pour les fragments

Pour les tests unitaires robolectric il suffit juste de faire la même chose mais en créant le test dans src/test du module concerné et en modifiant les annotations pour ajouter celles de robolectric et utiliser le runner robolectric. Ici on teste les versions android définies dans les fichiers gradle (minSdk et targetSdk).

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(
instrumentedPackages = [
// required to access final members on androidx.loader.content.ModernAsyncTask
"androidx.loader.content"
],
manifest = Config.NONE,
application = HiltTestApplication::class,
sdk = [Config.OLDEST_SDK, Config.TARGET_SDK]
)
class DayOffsFragmentTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)

@BindValue
@JvmField
var dayOffsViewModel: DayOffsViewModel = mockk(relaxed = true)

@Before
fun init() {
val list =
listOf(DayOff(zone = Zone.METROPOLE, id = 1, date = LocalDate.now(), name = "TEST"))
val mutableStateFlow: MutableStateFlow<DayfOffsState> =
MutableStateFlow(DayfOffsState.Loaded(list))
every { dayOffsViewModel.uiStateAsFlow } returns mutableStateFlow.asStateFlow()
hiltRule.inject()
}

@Test
fun testData() {
launchFragmentInHiltContainer<DayOffsFragment>(
fragmentArgs = DayOffsFragmentArgs(param = DayOffViewModelParam()).toBundle()
) { }
onView(withId(fr.pitdev.dayoff.presentation.R.id.dayoff_list)).check(matches(isDisplayed()))
}
}

TU de l’application avec TestLab

Pour l’activité principale il suffit de créer un test avec le ActivityScenario.

@HiltAndroidTest
class MainActivityTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Before
fun init() {
hiltRule.inject()
}
@Test
fun testApp() {
ActivityScenario.launch(MainActivity::class.java)
onView(withId(fr.pitdev.core.R.id.dayoff_list)).check(matches(isDisplayed()))
}
}

Ensuite vous générez l’apk ou le bundle et l’app android test

./gradlew app:bundleDebug app:assembleDebugAndroidTest

et de les uploader dans Firebase testlab ou via la CI (ici codemagic). On demande le test sur 2 appareils avec des API android différentes. Le script ci-dessous permet via codemagic de générer le bundle de l’application et l’apk des “androidTest” puis l’envoi des ces 2 fichiers à test lab via gcloud. TestLab exécutera ensuite les tests pour les appareils définis et téléchargera les résultats dans un bucket gcloud (ces résultats seront aussi visibles via la console Firebase)

  • Au préalable il faut générer un fichier compte de service via Firebase > Paramètres du projet > Comptes de service (et le crypter avant de l’importer dans codemagic)
  • Il faut aussi ajouter le rôle “Editeur” à l’utilisateur dans gcloud (IAM et administration)
  • et autoriser l’api “tests results” (Cloud Tool Results API) : celle-ci s’active automatiquement si vous exécutez la ligne de commande avant de passer par codemagic via le CLI gcloud
scripts:
name: Set Android SDK location
script: |
echo "sdk.dir=$ANDROID_SDK_ROOT" > "$FCI_BUILD_DIR/local.properties"
name: Set chmod +x gradlew
script: chmod +x gradlew
name: Pre-build
script: |
echo $CM_KEYSTORE_FILE | base64 --decode > $CM_BUILD_DIR/dayoff.keystore
echo $ANDROID_FIREBASE_JSON | base64 --decode > $CM_BUILD_DIR/app/google-services.json
name: assemble Android Test
script: ./gradlew app:bundleDebug app:assembleDebugAndroidTest
name: Run Firebase Test Lab tests
script: |
set -ex
#!/bin/sh
echo $GCLOUD_KEY_FILE > ./gcloud_key_file.json
gcloud auth activate-service-account --key-file=gcloud_key_file.json
gcloud --quiet config set project $FIREBASE_PROJECT
gcloud firebase test android run \
--type instrumentation \
--app $FCI_BUILD_DIR/app/build/outputs/bundle/debug/app-debug.aab \
--test $FCI_BUILD_DIR/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=oriole,version=31 \
--device model=redfin,version=30 \
--environment-variables coverage=true,coverageFile="/sdcard/coverage.ec" \
--directories-to-pull /sdcard \
--timeout 3m

publishing:
slack:
channel: '#general'
notify_on_build_start: true # To receive a notification when a build starts
notify:
success: true # To not receive a notification when a build succeeds
failure: true # To not receive a notification when a build fails
cache:
cache_paths:
~/.gradle/caches

Résultats des tests dans codemagic et dans la console Firebase.

Résultat des test UI de TestLab dans codemagic
Matrice des tests UI dans la console Firebase TestLab

Récapitulatif

Nous avons vu comment remplacer :

  • des modules Hilt pour certains tests,
  • tester des view models avec robolectric,
  • tester des fragments avec robolectric et espresso
  • tester l’application dans TestLab.

--

--