Todo App Series: Mastering UI Tests with Jetpack Compose and Dagger Hilt
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.
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
}
- App Module Hilt Plugin: Activate Hilt in the app module for compile-time DI validation, enhancing error detection early on.
- Hilt Android Library: Integrates Hilt’s runtime, facilitating automatic dependency injection and simplifying dependency management with annotations like
@Inject
. - Hilt Compiler: Essential for processing Hilt annotations, automating DI code generation, ensuring type safety and correctness.
- Hilt Testing Library: Enables mocking of dependencies for isolated UI tests, crucial for reliable testing without shared state interference.
- Hilt Testing Compiler: Processes annotations in test code, ensuring consistent and maintainable test setups.
- 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
}
@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.@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.@Provides
Annotation: Marks a method as a provider of a specific dependency. Hilt will call these methods to obtain instances for injection.@Singleton
Annotation: Indicates that the provided instance should be a singleton within the scope defined by@InstallIn
. ForSingletonComponent
, it means the instance will be the same across the application lifecycle.@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.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.- Dao Creation: Provides the DAO for accessing the database. This method depends on the
AppDatabase
instance provided by Hilt, showcasing dependency chaining in DI. 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.- 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() {
....
@HiltViewModel
Annotation: Marks theViewModel
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.@Inject
Annotation: Signals Hilt to provide the specified dependencies (e.g.,TodoRepository
andCoroutineDispatcher
) directly intoMainViewModel
, bypassing the need for aViewModelFactory
. 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()
....
AndroidEntryPoint
Annotation: MarksMainActivity
as eligible for Hilt's automatic dependency injection, enabling seamless integration of required dependencies without manual setup- Replace Manual Instance Creation: Leveraging
by viewModels()
, Hilt automatically suppliesMainViewModel
, 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)
}
}
CustomTestRunner
ExtendsAndroidJUnitRunner
: TheCustomTestRunner
extendsAndroidJUnitRunner
, providing a hook to modify the application class used during testing. This modification is essential for integrating Hilt with our UI tests.newApplication
Extends Application: This method is overridden to specifyHiltTestApplication
as the application class.HiltTestApplication
is a part of Hilt's testing library, designed to replace the usual Application class during tests.- Super Application Call: By invoking
super.newApplication
withHiltTestApplication::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
}
- TestInstallIn Annotation: Marks
TestModule
as a Hilt module for testing that temporarily replacesTodoAppModule
, ensuring test-specific dependency injection. - Replace TodoAppModule:
TestModule
substitutesTodoAppModule
during tests, allowing for the use of test-specific dependencies. - 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)
....
- 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.
- 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.