Todo App Series: Mastering UI Tests with Jetpack Compose and Dagger Hilt

Ken Ruiz Inoue
Deuk
Published in
12 min readFeb 7, 2024

--

Introduction

Welcome to the latest installment of our Todo App series! This tutorial will enhance our UI testing strategy by incorporating the dependency injection (DI) pattern with Hilt. If you’ve stumbled upon this tutorial without reading the previous ones, fear not — We’ve prepared all you need to get started!

👀 Previous Session Recap: In our last session, we tackled UI testing for our Todo App. However, we encountered a snag: executing all UI tests simultaneously led to failures due to the shared state in Room DB among tests.

🎯 Today’s Objective: This tutorial aims to resolve the issues encountered with UI tests running concurrently. We’ll achieve this by integrating Hilt, allowing us to configure our project for DI and update our UI tests. By leveraging the Hilt testing library, we can ensure each test runs in an isolated environment, paving the way for more reliable testing outcomes.

Let’s embark on this journey to refine our testing strategy and achieve a fully configured testing suite using the dependency injection pattern.

Environment

  • Android Studio Hedgehog | 2023.1.1 Patch 2
  • Compose version: androidx.compose:compose-bom:2023.08.00
  • Dagger Hilt version: com.google.dagger:hilt-android:2.50
  • Pixel 6 Emulator API 30

Step 1: Preparation

You can still get up to speed if you’re new to this series or haven’t completed the previous tutorial. Download the project from here, and try running all the UI tests together. You’ll notice that some tests fail, illustrating the issue we aim to address in this tutorial.

Click Double-Play Icon to Run All Tests

Step 2: Dependencies Setup

The next step is to prepare our project for Hilt, the dependency injection framework. Hilt reduces the boilerplate of doing manual dependency injection in your project. Essentially, it automates a lot of the work that was previously done manually with Dagger. Hilt is built on top of Dagger and provides a standard way to incorporate Dagger dependency injection into an Android application.

Let’s add the Hilt plugin to our project-level build.gradle.kt to seamlessly integrate Hilt and ensure all modules within our project can utilize it effectively.

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
...
// Project Level Hilt Plugin
id("com.google.dagger.hilt.android") version "2.50" apply false
}

Project Level Hilt Plugin: This configuration declares the Hilt plugin with a specified version, extending Gradle’s build system to incorporate advanced features for our build process. The apply false directive makes the plugin accessible to all modules, allowing for flexible opt-in integration.

Now, open the app-level build.gradle.kt and add the following content.

plugins {
...
// 1. App Module Hilt Plugin
id("com.google.dagger.hilt.android")
}

android {
...
}

dependencies {
// 2. Hilt Android Library
implementation("com.google.dagger:hilt-android:2.50")
// 3. Hilt Compiler
kapt("com.google.dagger:hilt-android-compiler:2.50")
...
// 4. Hilt Testing Library
androidTestImplementation("com.google.dagger:hilt-android-testing:2.50")
// 5. Hilt Testing Compiler
kaptAndroidTest("com.google.dagger:hilt-compiler:2.50")
}

// 6. Correct Error Types
kapt {
correctErrorTypes = true
}
  1. App Module Hilt Plugin: Activate Hilt in the app module for compile-time DI validation, enhancing error detection early on.
  2. Hilt Android Library: Integrates Hilt’s runtime, facilitating automatic dependency injection and simplifying dependency management with annotations like @Inject.
  3. Hilt Compiler: Essential for processing Hilt annotations, automating DI code generation, ensuring type safety and correctness.
  4. Hilt Testing Library: Enables mocking of dependencies for isolated UI tests, crucial for reliable testing without shared state interference.
  5. Hilt Testing Compiler: Processes annotations in test code, ensuring consistent and maintainable test setups.
  6. Correct Error Types: Improves error handling for generics and complex dependencies, promoting a smoother integration process.

Once you’ve synchronized your project with these updates, you’ll find that the setup is now complete. With these dependencies integrated, our project now is capable to harness the full power of Hilt for dependency injection, significantly boosting our code’s architecture and maintainability.

Step 3: DI Application

In this step, we will set up our application to use Hilt by declaring and registering the TodoApplication class. Hilt needs an entry point to understand where to start the dependency injection process. This is achieved by extending the Application class and annotating it with @HiltAndroidApp.

Create the di directory. Inside, create a file named TodoApplication.kt with the following code.

// package YOUR_PACKAGE_NAME.di

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class TodoApplication : Application()

The TodoApplication class serves as the foundation for your application’s dependency graph. By annotating this class with @HiltAndroidApp, you are instructing Hilt to generate the necessary components for DI. This annotation also triggers Hilt’s code generation, leading to the creation of a base class that your application can use for injection.

For Hilt to recognize TodoApplication as the application context for dependency injection, it must be declared in the AndroidManifest.xml. This registration tells the Android system to use your TodoApplication at the launch of the app, ensuring that Hilt’s dependency injection framework is initialized at the start.

Open your AndroidManifest.xml file and update the application tag by specifying the android:name attribute with the fully qualified name of TodoApplication.

...

<application
android:name=".di.TodoApplication"
...
</application>

</manifest>

By completing this step, you’ve successfully integrated Hilt into your application’s lifecycle, enabling it to manage the dependencies of your app effectively. This setup is crucial for facilitating a scalable and maintainable codebase, where dependency management is handled systematically by Hilt.

Step 4: DI Module

In this step, start by creating a new Kotlin file named TodoAppModule.kt within the di directory of your project. This file will serve as a Dependency Injection (DI) module in Hilt, where you will define how to provide the necessary dependencies for your application.

// package YOUR_PACKAGE_NAME.di

import android.content.Context
import androidx.room.Room
// import YOUR_PACKAGE_NAME.data.AppDatabase
// import YOUR_PACKAGE_NAME.data.TodoDao
// import YOUR_PACKAGE_NAME.data.TodoRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton

// 1. Module Annotation
@Module
// 2. InstallIn Annotation
@InstallIn(SingletonComponent::class)
object TodoAppModule {

// 3. Provides Annotation
@Provides
// 4. Singleton Annotation
@Singleton
// 5. ApplicationContext Annotation
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase =
// 6. AppDatabase Creation
Room.databaseBuilder(context, AppDatabase::class.java, "todo-db").build()

// 7. Dao Creation
@Provides
@Singleton
fun provideTodoDao(appDatabase: AppDatabase): TodoDao = appDatabase.todoDao()

// 8. TodoRepository Creation
@Provides
@Singleton
fun provideTodoRepository(todoDao: TodoDao): TodoRepository = TodoRepository(todoDao)

// 9. Why Providing IO Dispatcher
@Provides
@Singleton
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
  1. @Module Annotation: Declares this object as a Module in Hilt. A Module is a collection of methods that provide dependencies. Hilt will use these methods to generate the necessary code for DI.
  2. @InstallIn Annotation: Specifies the Hilt component scope in which the module will be installed. SingletonComponent means that the provided dependencies will be singletons at the application level.
  3. @Provides Annotation: Marks a method as a provider of a specific dependency. Hilt will call these methods to obtain instances for injection.
  4. @Singleton Annotation: Indicates that the provided instance should be a singleton within the scope defined by @InstallIn. For SingletonComponent, it means the instance will be the same across the application lifecycle.
  5. @ApplicationContext Annotation: @ApplicationContext is used to inject the application context into the method. This is necessary when you need a context that lives throughout the entire lifecycle of the application, such as when creating a Room database.
  6. AppDatabase Creation: Creates an instance of the Room database for the application. This method uses the application context provided by Hilt to ensure the database is tied to the application’s lifecycle.
  7. Dao Creation: Provides the DAO for accessing the database. This method depends on the AppDatabase instance provided by Hilt, showcasing dependency chaining in DI.
  8. TodoRepository Creation: Offers a repository instance for the application. The repository abstracts the data layer and is a recommended best practice in Android development for data operations. It uses the DAO provided by Hilt.
  9. Why Providing IO Dispatcher: Provides the I/O dispatcher from Kotlin Coroutines. This dispatcher is optimized for disk and network IO off the main thread, ensuring that the application’s UI remains responsive. By providing it here, you make it available for injection wherever it’s needed in your app, promoting a clean architecture by separating execution contexts.

As you may noticed, we opted for separate providers for the database, DAO, and repository in our dependency injection setup, rather than bundling them into a single provider. This approach not only simplifies testing by allowing us to mock or replace specific components easily but also makes our code more modular and easier to maintain as our app grows and evolves.

Step 5: Enabling DI in Android Components

Alright, we’re at a really exciting point now! We’re about to ditch some old habits by updating MainViewModel.kt and MainActivity.kt to embrace Hilt’s magic fully. This step is all about letting Hilt handle the heavy lifting of dependency injection, making our code cleaner and our lives easier.

Update MainViewModel to utilize Hilt’s injection capabilities.

...
import dagger.hilt.android.lifecycle.HiltViewModel
...
import javax.inject.Inject

// 1. HiltViewModel Annotation
@HiltViewModel
// 2. Inject Annotation
class MainViewModel @Inject constructor(
private val repository: TodoRepository,
private val ioDispatcher: CoroutineDispatcher
) : ViewModel() {

....
  1. @HiltViewModel Annotation: Marks the ViewModel for Hilt’s DI, automating dependency injection and negating the need for manual component connections. It ensures Hilt prepares and delivers all necessary dependencies directly.
  2. @Inject Annotation: Signals Hilt to provide the specified dependencies (e.g., TodoRepository and CoroutineDispatcher) directly into MainViewModel, bypassing the need for a ViewModelFactory. This annotation highlights where Hilt should inject dependencies, streamlining the process.

Implementing these annotations, MainViewModel now directly receives its dependencies from Hilt’s DI container, removing the need for explicit argument passing in MainActivity or manual construction of dependencies.

With MainViewModel now fully integrated with Hilt, we turn our attention to MainActivity. The manual instantiation of dependencies, such as databases or repositories, is no longer necessary thanks to Hilt’s automated dependency injection. To enable this seamless integration, we use the @AndroidEntryPoint annotation.

...
import androidx.activity.viewModels
....
import dagger.hilt.android.AndroidEntryPoint

// 1. AndroidEntryPoint Annotation
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 2. Replace Manual Instance Creation
val mainViewModel: MainViewModel by viewModels()
....
  1. AndroidEntryPoint Annotation: Marks MainActivity as eligible for Hilt's automatic dependency injection, enabling seamless integration of required dependencies without manual setup
  2. Replace Manual Instance Creation: Leveraging by viewModels(), Hilt automatically supplies MainViewModel, eliminating manual creation and injecting dependencies seamlessly.

By marking MainActivity with @AndroidEntryPoint, we align it with Hilt’s DI mechanism, streamlining the dependency injection process across our application.

To ensure everything is set up correctly, it’s important to manually test the application post-integration. Running the application will help verify that all functionalities remain intact and that the transition to using Hilt for dependency injection has been successful.

Step 6: Fixing the Issue

With the Hilt setup in place to address the primary concern — ensuring our UI tests run in an isolated environment — we’ll now shift our focus towards leveraging Hilt to create this isolated test environment. This crucial step allows us to run all UI tests simultaneously without interference, thanks to dependency injection.

To accomplish this isolation, we’ll transition to a Hilt-enabled test environment by configuring a CustomTestRunner. This custom runner will override the default behavior to utilize Hilt’s testing support.

First, create a CustomTestRunner.kt in your androidTest package to instruct our tests to use Hilt’s test application.

// package YOUR_PACKAGE_NAME

import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication

// 1. CustomTestRunner Extends AndroidJUnitRunner
class CustomTestRunner : AndroidJUnitRunner() {
// 2. newApplication Extends Application
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
// 3. Super Application Call
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
  1. CustomTestRunner Extends AndroidJUnitRunner : The CustomTestRunner extends AndroidJUnitRunner, providing a hook to modify the application class used during testing. This modification is essential for integrating Hilt with our UI tests.
  2. newApplication Extends Application: This method is overridden to specify HiltTestApplication as the application class. HiltTestApplication is a part of Hilt's testing library, designed to replace the usual Application class during tests.
  3. Super Application Call: By invoking super.newApplication with HiltTestApplication::class.java.name, the test runner initializes tests with a Hilt-enabled application context.

Following the creation of CustomTestRunner, the next step involves configuring our app to recognize and utilize this custom runner for UI tests. This requires a small but crucial modification to the app’s build.gradle.kt file.

...

android {
...
defaultConfig {
...

// CustomTestRunner for Running Tests
testInstrumentationRunner = "YOUR_PACKAGE_NAME.CustomTestRunner"
...
}

CustomTestRunner for Running Tests: We explicitly tell the build system to use our CustomTestRunner for executing tests. This activates Hilt’s test environment, as the CustomTestRunner is tailored to bootstrap HiltTestApplication for dependency injection in tests.

Now we need to introduce a TestModule within the androidTestfolder. This module is designed specifically for testing, ensuring our UI tests utilize a controlled environment that mimics real-world scenarios without affecting the actual app’s data or state.

// package YOUR_PACKAGE_NAME

import android.content.Context
import androidx.room.Room
// import YOUR_PACKAGE_NAME.data.AppDatabase
// import YOUR_PACKAGE_NAME.data.TodoDao
// import YOUR_PACKAGE_NAME.data.TodoRepository
// import YOUR_PACKAGE_NAME.di.TodoAppModule
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton

@Module
// 1. TestInstallIn Annotation
@TestInstallIn(
components = [SingletonComponent::class],
// 2. Replace TodoAppModule
replaces = [TodoAppModule::class]
)
object TestModule {

@Provides
@Singleton
fun provideAppDatabase(
@ApplicationContext appContext: Context
): AppDatabase =
// 3. Test Env Room Database Implementation
Room.inMemoryDatabaseBuilder(appContext, AppDatabase::class.java)
.allowMainThreadQueries()
.build()

@Provides
@Singleton
fun provideTodoDao(appDatabase: AppDatabase): TodoDao = appDatabase.todoDao()

@Provides
@Singleton
fun provideTodoRepository(todoDao: TodoDao): TodoRepository = TodoRepository(todoDao)

@Provides
@Singleton
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
  1. TestInstallIn Annotation: Marks TestModule as a Hilt module for testing that temporarily replaces TodoAppModule, ensuring test-specific dependency injection.
  2. Replace TodoAppModule: TestModule substitutes TodoAppModule during tests, allowing for the use of test-specific dependencies.
  3. Test Environment Room Database Implementation: Uses an in-memory AppDatabase for isolated testing, enabling operations on the main thread for simplicity in tests, but not advised for actual app usage.

With the TestModule now in place, tests invoking Hilt for dependency injection will automatically utilize this module instead of the TodoAppModule. This redirection ensures that all tests have access to a consistent, isolated environment tailored for testing.

To cap off our setup for an isolated UI testing environment with Hilt, it’s essential to prepare our MainActivityTest for Hilt’s dependency injection.

...
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
...

// 1. HiltAndroidTest Annotation
@HiltAndroidTest
class MainActivityTest {

...

// 2. HiltAndroidRule Call
@get:Rule
var hiltRule = HiltAndroidRule(this)

....
  1. HiltAndroidTest Annotation: This annotation marks the test class for Hilt’s dependency injection, ensuring it receives all necessary dependencies just like the main application, but within a test-specific environment.
  2. HiltAndroidRule Call: A JUnit rule that initializes dependencies before each test, ensuring a consistent and error-free setup for dependency injection.

By integrating these two components, we ensure that MainActivityTest operates within a Hilt-enabled environment, leveraging the isolated dependencies we've configured for our UI tests.

To confirm everything is correctly set up, run all the UI tests simultaneously. You should see each test passes without interfering with others. Congratulations! You’ve successfully created an isolated testing environment using Hilt!

Wrapping Up

Congratulations on reaching the end of this tutorial! You’ve navigated the complexities of integrating Hilt into your Android project, transforming your UI testing strategy to achieve isolation and reliability. This journey not only equipped you with the skills to enhance your testing practices but also deepened your understanding of dependency injection with Hilt.

🔗 Access the Full Code: The full codebase for this session is just a click away. Explore it to solidify your understanding, experiment with the concepts discussed, or even use it as a springboard for your projects. Access the full code here.

👍 Feedback and Support: Did this tutorial spark your interest or help you overcome a challenge? We’d love to hear about your experience! Your feedback is not just welcome — it’s essential. It helps us improve and guides us in crafting content that meets your needs. So, if you found value in this tutorial, please consider leaving a clap or two, and don’t hesitate to follow us for more insightful content. Your support enhances our visibility and empowers us to reach and assist more developers like you.

Thank you for investing your time with us. We’re excited to see how you apply these techniques in your projects. Stay tuned for more tutorials that aim to refine your development skills and inspire your creativity. Until we meet again, happy coding!

Explore Further

Eager for more insights and updates? Join our vibrant community by subscribing to our newsletter! Simply click here to start receiving exclusive content that fuels your curiosity and keeps you ahead of the curve.

Craving a richer learning experience? Immerse yourself in our comprehensive Android Compose Tutorials Library. Our meticulously curated collection covers a wide array of UI design tactics through Jetpack Compose, complete with in-depth guides designed not only to elevate your technical skills but also to ignite your creative flair in Android development.

Deuk Services: Your Gateway to Leading Android Innovation

Are you looking to boost your business with top-tier Android solutions?Partner with Deuk services and take your projects to unparalleled heights.

--

--