1. Welcome
Introduction
This second testing codelab is all about test doubles: when to use them in Android, and how to implement them using dependency injection, the Service Locator pattern, and libraries. In doing this, you'll learn how to write:
- Repository unit tests
- Fragments and viewmodel integration tests
- Fragment navigation tests
What you should already know
You should be familiar with:
- Testing concepts covered in the first codelab: Writing and running unit tests on Android, using JUnit, Hamcrest, AndroidX test, Robolectric, as well as Testing LiveData
- The following core Android Jetpack libraries:
ViewModel
,LiveData
and the Navigation Component - Application architecture, following the pattern from the Guide to app architecture and Android Fundamentals codelabs
- The basics of coroutines on Android
What you'll learn
- How to plan a testing strategy
- How to create and use test doubles, namely fakes and mocks
- How to use manual dependency injection on Android for unit and integration tests
- How to apply the Service Locator Pattern
- How to test repositories, fragments, view models and the Navigation component
You will use the following libraries and code concepts:
What you'll do
- Write unit tests for a repository using a test double and dependency injection.
- Write unit tests for a view model using a test double and dependency injection.
- Write integration tests for fragments and their view models using Espresso UI testing framework.
- Write navigation tests using Mockito and Espresso.
2. App overview
In this series of codelabs, you'll be working with the TO-DO Notes app. The app allows you to write down tasks to complete and displays them in a list. You can then mark them as completed or not, filter them, or delete them.
This app is written in Kotlin, has a few screens, uses Jetpack components and follows the architecture from a Guide to app architecture. By learning how to test this app, you'll be able to test apps that use the same libraries and architecture.
Download the Code
To get started, download the code:
Alternatively, you can clone the Github repository for the code:
$ git clone https://github.com/google-developer-training/advanced-android-testing.git $ cd android-testing $ git checkout end_codelab_1
You can browse the code in the android-testing Github repository.
Take a moment to familiarize yourself with the code, following the instructions below.
Step 1: Run the sample app
Once you've downloaded the TO-DO app, open it in Android Studio and run it. It should compile. Explore the app by doing the following:
- Create a new task with the plus floating action button. Enter a title first, then enter additional information about the task. Save it with the green check FAB.
- In the list of tasks, click on the title of the task you just completed and look at the detail screen for that task to see the rest of the description.
- In the list or on the detail screen, check the checkbox of that task to set its status to Completed.
- Go back to the tasks screen, open the filter menu, and filter the tasks by Active and Completed status.
- Open the navigation drawer and click Statistics.
- Got back to the overview screen, and from the navigation drawer menu, select Clear completed to delete all tasks with the Completed status
Step 2: Explore the sample app code
The TO-DO app is based off of the Architecture Blueprints testing and architecture sample. The app follows the architecture from a Guide to app architecture. It uses ViewModels with Fragments, a repository, and Room. If you're familiar with any of the below examples, this app has a similar architecture:
- Android Kotlin Fundamentals training codelabs
- Advanced Android training codelabs
- Room with a View Codelab
- Android Sunflower Sample
- Developing Android Apps with Kotlin Udacity training course
It is more important that you understand the general architecture of the app than have a deep understanding of the logic at any one layer.
Here's the summary of packages you'll find:
Package: | ||
| The add or edit a task screen: UI layer code for adding or editing a task. | |
| The data layer: This deals with the data layer of the tasks. It contains the database, network, and repository code. | |
| The statistics screen: UI layer code for the statistics screen. | |
| The task detail screen: UI layer code for a single task. | |
| The tasks screen: UI layer code for the list of all tasks. | |
| Utility classes: Shared classes used in various parts of the app, e.g. for the swipe refresh layout used on multiple screens. |
Data layer (.data)
This app includes a simulated networking layer, in the remote package, and a database layer, in the local package. For simplicity, in this project the networking layer is simulated with just a HashMap
with a delay, rather than making real network requests.
The DefaultTasksRepository
coordinates or mediates between the networking layer and the database layer and is what returns data to the UI layer.
UI layer ( .addedittask, .statistics, .taskdetail, .tasks)
Each of the UI layer packages contains a fragment and a view model, along with any other classes that are required for the UI (such as an adapter for the task list). The TaskActivity
is the activity that contains all of the fragments.
Navigation
Navigation for the app is controlled by the Navigation component. It is defined in the nav_graph.xml
file. Navigation is triggered in the view models using the Event
class; the view models also determine what arguments to pass. The fragments observe the Event
s and do the actual navigation between screens.
3. Concept: Testing Strategy
In this codelab, you will learn how to test repositories, view models, and fragments using test doubles and dependency injection. Before you dive into what those are, it's important to understand the reasoning that will guide what and how you will write these tests.
This section covers some best practices of testing in general, as they apply to Android.
The Testing Pyramid
When thinking about a testing strategy, there are three related testing aspects:
- Scope—How much of the code does the test touch? Tests can run on a single method, across the entire application, or somewhere in between.
- Speed—How fast does the test run? Test speeds can vary from milli-seconds to several minutes.
- Fidelity—How "real-world" is the test? For example, if part of the code you're testing needs to make a network request, does the test code actually make this network request, or does it fake the result? If the test actually talks with the network, this means it has higher fidelity. The trade-off is that the test could take longer to run, could result in errors if the network is down, or could be costly to use.
There are inherent trade-offs between these aspects. For example, speed and fidelity are a trade-off—the faster the test, generally, the less fidelity, and vice versa. One common way to divide automated tests is into these three categories:
- Unit tests—These are highly focused tests that run on a single class, usually a single method in that class. If a unit test fails, you can know exactly where in your code the issue is. They have low fidelity since in the real world, your app involves much more than the execution of one method or class. They are fast enough to run every time you change your code. They will most often be locally run tests (in the
test
source set). Example: Testing single methods in view models and repositories. - Integration tests—These test the interaction of several classes to make sure they behave as expected when used together. One way to structure integration tests is to have them test a single feature, such as the ability to save a task. They test a larger scope of code than unit tests, but are still optimized to run fast, versus having full fidelity. They can be run either locally or as instrumentation tests, depending on the situation. Example: Testing all the functionality of a single fragment and view model pair.
- End to end tests (E2e)—Test a combination of features working together. They test large portions of the app, simulate real usage closely, and therefore are usually slow. They have the highest fidelity and tell you that your application actually works as a whole. By and large, these tests will be instrumented tests (in the
androidTest
source set) Example: Starting up the entire app and testing a few features together.
The suggested proportion of these tests is often represented by a pyramid, with the vast majority of tests being unit tests.
Architecture and Testing
Your ability to test your app at all the different levels of the testing pyramid is inherently tied to your app's architecture. For example, an extremely poorly-architected application might put all of its logic inside one method. You might be able to write an end to end test for this, since these tests tend to test large portions of the app, but what about writing unit or integration tests? With all of the code in one place, it's hard to test just the code related to a single unit or feature.
A better approach would be to break down the application logic into multiple methods and classes, allowing each piece to be tested in isolation. Architecture is a way to divide up and organize your code, which allows easier unit and integration testing. The TO-DO app that you'll be testing follows a particular architecture:
In this lesson, you'll see how to test parts of the above architecture, in proper isolation:
- First you'll unit test the repository.
- Then you'll use a test double in the view model, which is necessary for unit testing and integration testing the view model.
- Next, you'll learn to write integration tests for fragments and their view models.
- Finally, you'll learn to write integration tests that include the Navigation component.
End to end testing will be covered in the next lesson.
4. Task: Make a Fake Data Source
When you write a unit test for a part of a class (a method or a small collection of methods), your goal is to only test the code in that class.
Testing only code in a specific class or classes can be tricky. Let's look at an example. Open the data.source.DefaultTasksRepository
class in the main
source set. This is the repository for the app, and is the class which you will be writing unit tests for next.
Your goal is to test only the code in that class. Yet, DefaultTasksRepository
depends on other classes, such as TasksLocalDataSource
and TasksRemoteDataSource
, to function. Another way to say this is that TasksLocalDataSource
and TasksRemoteDataSource
are dependencies of DefaultTasksRepository
.
So every method in DefaultTasksRepository
calls methods on data source classes, which in turn call methods in other classes to save information to a database or communicate with the network.
For example, take a look at this method in DefaultTasksRepo
.
suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>> {
if (forceUpdate) {
try {
updateTasksFromRemoteDataSource()
} catch (ex: Exception) {
return Result.Error(ex)
}
}
return tasksLocalDataSource.getTasks()
}
getTasks
is one of the most "basic" calls you might make to your repository. This method includes reading from an SQLite database and making network calls (the call to updateTasksFromRemoteDataSource
). This involves a lot more code than just the repository code.
Here are some more specific reasons why testing the repository is hard:
- You need to deal with thinking about creating and managing a database to do even the simplest tests for this repository. This brings up questions like "should this be a local or instrumented test?" and if you should be using AndroidX Test to get a simulated Android environment.
- Some parts of the code, such as networking code, can take a long time to run, or occasionally even fail, creating long running, flaky tests.
- Your tests could lose their ability to diagnose which code is at fault for a test failure. Your tests could start testing non-repository code, so, for example, your supposed "repository" unit tests could fail because of an issue in some of the dependant code, such as the database code.
Test Doubles
The solution to this is that when you're testing the repository, don't use the real networking or database code, but instead use a test double. A test double is a version of a class crafted specifically for testing. It is meant to replace the real version of a class in tests. It's similar to how a stunt double is an actor who specializes in stunts, and replaces the real actor for dangerous actions.
Here are some types of test doubles:
Fake | A test double that has a "working" implementation of the class, but it's implemented in a way that makes it good for tests but unsuitable for production. |
Mock | A test double that tracks which of its methods were called. It then passes or fails a test depending on whether it's methods were called correctly. |
Stub | A test double that includes no logic and only returns what you program it to return. A |
Dummy | A test double that is passed around but not used, such as if you just need to provide it as a parameter. If you had a |
Spy | A test double which also keeps tracks of some additional information; for example, if you made a |
For more information on test doubles, check out Testing on the Toilet: Know Your Test Doubles.
The most common test doubles used in Android are Fakes and Mocks.
In this task, you're going to create a FakeDataSource
test double to unit test DefaultTasksRepository
decoupled from the actual data sources.
Step 1: Create the FakeDataSource class
In this step you are going to create a class called FakeDataSouce
, which will be a test double of a LocalDataSource
and RemoteDataSource
.
- In the test source set, right click select New -> Package.
- Make a data package with a source package inside.
- Create a new class called
FakeDataSource
in the data/source package.
Step 2: Implement TasksDataSource Interface
To be able to use your new class FakeDataSource
as a test double, it must be able to replace the other data sources. Those data sources are TasksLocalDataSource
and TasksRemoteDataSource
.
- Notice how both of these implement the
TasksDataSource
interface.
class TasksLocalDataSource internal constructor(
private val tasksDao: TasksDao,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }
object TasksRemoteDataSource : TasksDataSource { ... }
- Make
FakeDataSource
implementTasksDataSource
:
class FakeDataSource : TasksDataSource {
}
Android Studio will complain that you haven't implemented required methods for TasksDataSource
.
- Use the quick-fix menu and select Implement members.
- Select all of the methods and press OK.
Step 3: Implement the getTasks method in FakeDataSource
FakeDataSource
is a specific type of test double called a fake. A fake is a test double that has a "working" implementation of the class, but it's implemented in a way that makes it good for tests but unsuitable for production. "Working" implementation means that the class will produce realistic outputs given inputs.
For example, your fake data source won't connect to the network or save anything to a database—instead it will just use an in-memory list. This will "work as you might expect" in that methods to get or save tasks will return expected results, but you could never use this implementation in production, because it's not saved to the server or a database.
A FakeDataSource
- lets you test the code in
DefaultTasksRepository
without needing to rely on a real database or network. - provides a "real-enough" implementation for tests.
- Change the
FakeDataSource
constructor to create avar
calledtasks
that is aMutableList<Task>?
with a default value of an empty mutable list.
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }
This is the list of tasks that "fakes" being a database or server response. For now, the goal is to test the repository's getTasks
method. This calls the data source's getTasks
, deleteAllTasks
and saveTask
methods.
Write a fake version of these methods:
- Write
getTasks
: Iftasks
isn'tnull
, return aSuccess
result. Iftasks
isnull
, return anError
result. - Write
deleteAllTasks
: clear the mutable tasks list. - Write
saveTask
: add the task to the list.
Those methods, implemented for FakeDataSource
, look like the code below.
override suspend fun getTasks(): Result<List<Task>> {
tasks?.let { return Success(ArrayList(it)) }
return Error(
Exception("Tasks not found")
)
}
override suspend fun deleteAllTasks() {
tasks?.clear()
}
override suspend fun saveTask(task: Task) {
tasks?.add(task)
}
Here are the import statements if needed:
import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
This is similar to how the actual local and remote data sources work.
5. Task: Write a Test Using Dependency Injection
In this step, you're going to use a technique called manual dependency injection so that you can use the fake test double you just created.
The main issue is that you have a FakeDataSource
, but it's unclear how you use it in the tests. It needs to replace the TasksRemoteDataSource
and the TasksLocalDataSource
, but only in the tests. Both the TasksRemoteDataSource
and TasksLocalDataSource
are dependencies of DefaultTasksRepository
, meaning that DefaultTasksRepositories
requires or "depends" on these classes to run.
Right now, the dependencies are constructed inside the init
method of DefaultTasksRepository
.
DefaultTasksRepository.kt
class DefaultTasksRepository private constructor(application: Application) {
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
// Some other code
init {
val database = Room.databaseBuilder(application.applicationContext,
ToDoDatabase::class.java, "Tasks.db")
.build()
tasksRemoteDataSource = TasksRemoteDataSource
tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
}
// Rest of class
}
Because you are creating and assigning taskLocalDataSource
and tasksRemoteDataSource
inside DefaultTasksRepository
, they are essentially hard-coded. There is no way to swap in your test double.
What you want to do instead, is provide these data sources to the class, instead of hard-coding them. Providing dependencies is known as dependency injection. There are different ways to provide dependencies, and therefore different types of dependency injection.
Constructor Dependency Injection allows you to swap in the test double by passing it into the constructor.
No injection | Injection |
Step 1: Use Constructor Dependency Injection in DefaultTasksRepository
- Change the
DefaultTasksRepository
's constructor from taking in anApplication
to taking in both data sources and the coroutine dispatcher.
DefaultTasksRepository.kt
// REPLACE
class DefaultTasksRepository private constructor(application: Application) { // Rest of class }
// WITH
class DefaultTasksRepository(
private val tasksRemoteDataSource: TasksDataSource,
private val tasksLocalDataSource: TasksDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }
- Because you passed the dependencies in, remove the
init
method. You no longer need to create the dependencies. - Also delete the old instance variables. You're defining them in the constructor:
DefaultTasksRepository.kt
// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
- Finally, update the
getRepository
method to use the new constructor:
DefaultTasksRepository.kt
companion object {
@Volatile
private var INSTANCE: DefaultTasksRepository? = null
fun getRepository(app: Application): DefaultTasksRepository {
return INSTANCE ?: synchronized(this) {
val database = Room.databaseBuilder(app,
ToDoDatabase::class.java, "Tasks.db")
.build()
DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
INSTANCE = it
}
}
}
}
You are now using constructor dependency injection!
Step 2: Use your FakeDataSource in your tests
Now that your code is using constructor dependency injection, you can use your fake data source to test your DefaultTasksRepository
.
- Right-click on the
DefaultTasksRepository
class name and select Generate, then Test. - Follow the prompts to create
DefaultTasksRepositoryTest
in the test source set. - At the top of your new
DefaultTasksRepositoryTest
class, add the member variables below to represent the data in your fake data sources.
DefaultTasksRepositoryTest.kt
private val task1 = Task("Title1", "Description1")
private val task2 = Task("Title2", "Description2")
private val task3 = Task("Title3", "Description3")
private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
private val localTasks = listOf(task3).sortedBy { it.id }
private val newTasks = listOf(task3).sortedBy { it.id }
- Create three variables, two
FakeDataSource
member variables (one for each data source for your repository) and a variable for theDefaultTasksRepository
which you will test.
DefaultTasksRepositoryTest.kt
private lateinit var tasksRemoteDataSource: FakeDataSource
private lateinit var tasksLocalDataSource: FakeDataSource
// Class under test
private lateinit var tasksRepository: DefaultTasksRepository
Make a method to set up and initialize a testable DefaultTasksRepository
. This DefaultTasksRepository
will use your test double, FakeDataSource
.
- Create a method called
createRepository
and annotate it with@Before
. - Instantiate your fake data sources, using the
remoteTasks
andlocalTasks
lists. - Instantiate your
tasksRepository
, using the two fake data sources you just created andDispatchers.Unconfined
.
The final method should look like the code below.
DefaultTasksRepositoryTest.kt
@Before
fun createRepository() {
tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
// Get a reference to the class under test
tasksRepository = DefaultTasksRepository(
// TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
// this requires understanding more about coroutines + testing
// so we will keep this as Unconfined for now.
tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
)
}
Step 3: Write DefaultTasksRepository getTasks() Test
Time to write a DefaultTasksRepository
test!
- Write a test for the repository's
getTasks
method. Check that when you callgetTasks
withtrue
(meaning that it should reload from the remote data source) that it returns data from the remote data source (as opposed to the local data source).
DefaultTasksRepositoryTest.kt
@Test
fun getTasks_requestsAllTasksFromRemoteDataSource(){
// When tasks are requested from the tasks repository
val tasks = tasksRepository.getTasks(true) as Success
// Then tasks are loaded from the remote data source
assertThat(tasks.data, IsEqual(remoteTasks))
}
You will get an error when you call
getTasks:
Step 4: Add runBlockingTest
The coroutine error is expected because getTasks
is a suspend
function and you need to launch a coroutine to call it. For that, you need a coroutine scope. To resolve this error, you're going to need to add some gradle dependencies for handling launching coroutines in your tests.
- Add the required dependencies for testing coroutines to the test source set by using
testImplementation
.
app/build.gradle
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
Don't forget to sync!
kotlinx-coroutines-test
is the coroutines test library, specifically meant for testing coroutines. To run your tests, use the function runBlockingTest
. This is a function provided by the coroutines test library. It takes in a block of code and then runs this block of code in a special coroutine context which runs synchronously and immediately, meaning actions will occur in a deterministic order. This essentially makes your coroutines run like non-coroutines, so it is meant for testing code.
Use runBlockingTest
in your test classes when you're calling a suspend
function. You'll learn more about how runBlockingTest
works and how to test coroutines in the next codelab in this series.
- Add the
@ExperimentalCoroutinesApi
above the class. This expresses that you know you're using an experimental coroutine api (runBlockingTest
) in the class. Without it, you'll get a warning. - Back in your
DefaultTasksRepositoryTest
, addrunBlockingTest
so that it takes in your entire test as a "block" of code
This final test looks like the code below.
DefaultTasksRepositoryTest.kt
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.hamcrest.core.IsEqual
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {
private val task1 = Task("Title1", "Description1")
private val task2 = Task("Title2", "Description2")
private val task3 = Task("Title3", "Description3")
private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
private val localTasks = listOf(task3).sortedBy { it.id }
private val newTasks = listOf(task3).sortedBy { it.id }
private lateinit var tasksRemoteDataSource: FakeDataSource
private lateinit var tasksLocalDataSource: FakeDataSource
// Class under test
private lateinit var tasksRepository: DefaultTasksRepository
@Before
fun createRepository() {
tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
// Get a reference to the class under test
tasksRepository = DefaultTasksRepository(
// TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
// this requires understanding more about coroutines + testing
// so we will keep this as Unconfined for now.
tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
)
}
@Test
fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest {
// When tasks are requested from the tasks repository
val tasks = tasksRepository.getTasks(true) as Success
// Then tasks are loaded from the remote data source
assertThat(tasks.data, IsEqual(remoteTasks))
}
}
- Run your new
getTasks_requestsAllTasksFromRemoteDataSource
test and confirm it works and the error is gone!
6. Task: Set up a Fake Repository
You've just seen how to unit test a repository. In these next steps, you're going to again use dependency injection and create another test double—this time to show how to write unit and integration tests for your view models.
Unit tests should only test the class or method you're interested in. This is known as testing in isolation, where you clearly isolate your "unit" and only test the code that is part of that unit.
So TasksViewModelTest
should only test TasksViewModel
code—it should not test code in the database, network, or repository classes. Therefore for your view models, much like you just did for your repository, you'll create a fake repository and apply dependency injection to use it in your tests.
In this task, you apply dependency injection to view models.
Step 1. Create a TasksRepository Interface
The first step towards using constructor dependency injection is to create a common interface shared between the fake and the real class.
How does this look in practice? Look at TasksRemoteDataSource
, TasksLocalDataSource
and FakeDataSource
, and notice that they all share the same interface: TasksDataSource
. This allows you to say in the constructor of DefaultTasksRepository
that you take in a TasksDataSource
.
DefaultTasksRepository.kt
class DefaultTasksRepository(
private val tasksRemoteDataSource: TasksDataSource,
private val tasksLocalDataSource: TasksDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
This is what allows us to swap in your FakeDataSource
!
Next, make an interface for DefaultTasksRepository
, as you did for the data sources. It needs to include all of the public methods (public API surface) of DefaultTasksRepository
.
- Open
DefaultTasksRepository
and right-click on the class name. Then select Refactor -> Extract -> Interface.
- Choose Extract to separate file.
- In the Extract Interface window, change the interface name to
TasksRepository
. - In the Members to form interface section, check all members except the two companion members and the private methods.
- Click Refactor. The new
TasksRepository
interface should appear in the data/source package.
And DefaultTasksRepository
now implements TasksRepository
.
- Run your app (not the tests) to make sure everything is still in working order.
Step 2. Create FakeTestRepository
Now that you have the interface, you can create the DefaultTasksRepository
test double.
- In the test source set, in data/source create the Kotlin file and class
FakeTestRepository.kt
and extend from theTasksRepository
interface.
FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
}
You will be told you need to implement the interface methods.
- Hover over the error until you see the suggestion menu, then click and select Implement members.
- Select all of the methods and press OK.
Step 3. Implement FakeTestRepository methods
You now have a FakeTestRepository
class with "not implemented" methods. Similar to how you implemented the FakeDataSource
, the FakeTestRepository
will be backed by a data structure, instead of dealing with a complicated mediation between local and remote data sources.
Note that your FakeTestRepository
doesn't need to use FakeDataSource
s or anything like that; it just needs to return realistic fake outputs given inputs. You will use a LinkedHashMap
to store the list of tasks and a MutableLiveData
for your observable tasks.
- In
FakeTestRepository
, add both aLinkedHashMap
variable representing the current list of tasks and aMutableLiveData
for your observable tasks.
FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()
private val observableTasks = MutableLiveData<Result<List<Task>>>()
// Rest of class
}
Implement the following methods:
getTasks
—This method should take thetasksServiceData
and turn it into a list usingtasksServiceData.values.toList()
and then return that as aSuccess
result.refreshTasks
—Updates the value ofobservableTasks
to be what is returned bygetTasks()
.observeTasks
—Creates a coroutine usingrunBlocking
and runrefreshTasks
, then returnsobservableTasks
.
For your test double, use runBlocking
. runBlocking
, which is a closer simulation of what a "real" implementation of the repository would do, and it's preferable for Fakes, so that their behavior more closely matches the real implementation.
When you're in test classes, meaning classes with @Test
functions , use runBlockingTest
to get deterministic behavior.
Below is the code for those methods.
FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()
private val observableTasks = MutableLiveData<Result<List<Task>>>()
override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
return Result.Success(tasksServiceData.values.toList())
}
override suspend fun refreshTasks() {
observableTasks.value = getTasks()
}
override fun observeTasks(): LiveData<Result<List<Task>>> {
runBlocking { refreshTasks() }
return observableTasks
}
override suspend fun completeTask(task: Task) {
val completedTask = task.copy(isCompleted = true)
tasksServiceData[task.id] = completedTask
refreshTasks()
}
// Rest of class
}
Step 4. Add a method for testing to addTasks
When testing, it is better to have some Tasks
already in your repository. You could call saveTask
several times, but to make this easier, add a helper method specifically for tests that lets you add tasks.
- Add the
addTasks
method, which takes in avararg
of tasks, adds each to theHashMap
, and then refreshes the tasks.
FakeTestRepository.kt
fun addTasks(vararg tasks: Task) {
for (task in tasks) {
tasksServiceData[task.id] = task
}
runBlocking { refreshTasks() }
}
At this point you have a fake repository for testing with a few of the key methods implemented. Next, use this in your tests!
7. Task: Use the Fake Repository inside a ViewModel
In this task you use a fake class inside of a ViewModel
. Use constructor dependency injection, to take in the two data sources via constructor dependency injection by adding a TasksRepository
variable to the TasksViewModel
's constructor.
This process is a little different with view models because you don't construct them directly. For example:
class TasksFragment : Fragment() {
private val viewModel by viewModels<TasksViewModel>()
// Rest of class...
}
As in the code above, you're using the viewModel's
property delegate which creates the view model. To change how the view model is constructed, you'll need to add and use a ViewModelProvider.Factory
. If you're not familiar with ViewModelProvider.Factory
, you can learn more about it here.
Step 1. Make and use a ViewModelFactory in TasksViewModel
You start with updating the classes and test related to the Tasks
screen.
- Open
TasksViewModel
. - Change the constructor of
TasksViewModel
to take inTasksRepository
instead of constructing it inside the class.
TasksViewModel.kt
// REPLACE
class TasksViewModel(application: Application) : AndroidViewModel(application) {
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// Rest of class
}
// WITH
class TasksViewModel( private val tasksRepository: TasksRepository ) : ViewModel() {
// Rest of class
}
Since you changed the constructor, you now need to use a factory to construct TasksViewModel
. For convenience you can put the factory class in the same file as TasksViewModel
, but you could also put it in its own file.
- At the bottom of the
TasksViewModel
file, outside the class, add aTasksViewModelFactory
which takes in a plainTasksRepository
.
TasksViewModel.kt
@Suppress("UNCHECKED_CAST")
class TasksViewModelFactory (
private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>) =
(TasksViewModel(tasksRepository) as T)
}
This is the standard way to change how ViewModel
s are constructed. Now that you have the factory, use it wherever you construct your view model.
- Update
TasksFragment
to use the factory.
TasksFragment.kt
// REPLACE
private val viewModel by viewModels<TasksViewModel>()
// WITH
private val viewModel by viewModels<TasksViewModel> {
TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
- Run your app code and make sure everything is still working!
Step 2. Use FakeTestRepository inside TasksViewModelTest
Now instead of using the real repository in your view model tests, you can use the fake repository.
- Open up
TasksViewModelTest
. It's under the tasks folder in the test source set. - Add a
FakeTestRepository
property in theTasksViewModelTest
.
TaskViewModelTest.kt
@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {
// Use a fake repository to be injected into the viewmodel
private lateinit var tasksRepository: FakeTestRepository
// Rest of class
}
- Update the
setupViewModel
method to make aFakeTestRepository
with three tasks, and then construct thetasksViewModel
with this repository.
TasksViewModelTest.kt
@Before
fun setupViewModel() {
// We initialise the tasks to 3, with one active and two completed
tasksRepository = FakeTestRepository()
val task1 = Task("Title1", "Description1")
val task2 = Task("Title2", "Description2", true)
val task3 = Task("Title3", "Description3", true)
tasksRepository.addTasks(task1, task2, task3)
tasksViewModel = TasksViewModel(tasksRepository)
}
- Because you are no longer using the AndroidX Test
ApplicationProvider.getApplicationContext
code, you can also remove the@RunWith(AndroidJUnit4::class)
annotation. - Run your tests, make sure they all still work!
By using constructor dependency injection, you've now removed the DefaultTasksRepository
as a dependency and replaced it with your FakeTestRepository
in the tests.
Step 3. Also update TaskDetailFragment and ViewModel
Make the same changes for the TaskDetailFragment
and TaskDetailViewModel
. This will prepare the code for when you write TaskDetail
tests next.
- Open
TaskDetailViewModel
. - Update the constructor:
TaskDetailViewModel.kt
// REPLACE
class TaskDetailViewModel(application: Application) : AndroidViewModel(application) {
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// Rest of class
}
// WITH
class TaskDetailViewModel(
private val tasksRepository: TasksRepository
) : ViewModel() { // Rest of class }
- At the bottom of the
TaskDetailViewModel
file, outside the class, add aTaskDetailViewModelFactory
.
TaskDetailViewModel.kt
@Suppress("UNCHECKED_CAST")
class TaskDetailViewModelFactory (
private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>) =
(TaskDetailViewModel(tasksRepository) as T)
}
- Update
TaskDetailFragment
to use the factory.
TaskDetailFragment.kt
// REPLACE
private val viewModel by viewModels<TaskDetailViewModel>()
// WITH
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
- Run your code and make sure everything is working.
You are now able to use a FakeTestRepository
instead of the real repository in TasksFragment
and TaskDetailFragment
.
8. Task: Launch a Fragment from a Test
Next you'll write integration tests to test your fragment and view-model interactions. You'll find out if your view model code appropriately updates your UI. To do this you use
- the ServiceLocator pattern
- the Espresso and Mockito libraries
Integration tests test the interaction of several classes to make sure they behave as expected when used together. These tests can be run either locally (test
source set) or as instrumentation tests (androidTest
source set).
In your case you'll be taking each fragment and writing integration tests for the fragment and view model to test the main features of the fragment.
Step 1. Add Gradle Dependencies
- Add the following gradle dependencies.
app/build.gradle
// Dependencies for Android instrumented unit tests
androidTestImplementation "junit:junit:$junitVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Testing code should not be included in the main code.
// Once https://issuetracker.google.com/128612536 is fixed this can be fixed.
implementation "androidx.fragment:fragment-testing:$fragmentVersion"
implementation "androidx.test:core:$androidXTestCoreVersion"
These dependencies include:
junit:junit
—JUnit, which is necessary for writing basic test statements.androidx.test:core
—Core AndroidX test librarykotlinx-coroutines-test
—The coroutines testing libraryandroidx.fragment:fragment-testing
—AndroidX test library for creating fragments in tests and changing their state.
Since you'll be using these libraries in your androidTest
source set, use androidTestImplementation
to add them as dependencies.
Step 2. Make a TaskDetailFragmentTest class
The TaskDetailFragment
shows information about a single task.
You'll start by writing a fragment test for the TaskDetailFragment
since it has fairly basic functionality compared to the other fragments.
- Open
taskdetail.TaskDetailFragment
. - Generate a test for
TaskDetailFragment
, as you've done before. Accept the default choices and put it in the androidTest source set (NOT thetest
source set).
- Add the following annotations to the
TaskDetailFragmentTest
class.
TaskDetailFragmentTest.kt
@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {
}
The purpose of these annotation is:
@MediumTest
—Marks the test as a "medium run-time" integration test (versus@SmallTest
unit tests and@LargeTest
end-to-end tests). This helps you group and choose which size of test to run.@RunWith(AndroidJUnit4::class)
—Used in any class using AndroidX Test.
Step 3. Launch a fragment from a test
In this task, you're going to launch TaskDetailFragment
using the AndroidX Testing library. FragmentScenario
is a class from AndroidX Test that wraps around a fragment and gives you direct control over the fragment's lifecycle for testing. To write tests for fragments, you create a FragmentScenario
for the fragment you're testing (TaskDetailFragment
).
- Copy this test into
TaskDetailFragmentTest
.
TaskDetailFragmentTest.kt
@Test
fun activeTaskDetails_DisplayedInUi() {
// GIVEN - Add active (incomplete) task to the DB
val activeTask = Task("Active Task", "AndroidX Rocks", false)
// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
}
This code above:
- Creates a task.
- Creates a
Bundle
, which represents the fragment arguments for the task that get passed into the fragment). - The
launchFragmentInContainer
function creates aFragmentScenario
, with this bundle and a theme.
This is not a finished test yet, because it's not asserting anything. For now, run the test and observe what happens.
- This is an instrumented test, so make sure the emulator or your device is visible.
- Run the test.
A few things should happen.
- First, because this is an instrumented test, the test will run on either your physical device (if connected) or an emulator.
- It should launch the fragment.
- Notice how it doesn't navigate through any other fragment or have any menus associated with the activity - it is just the fragment.
Finally, look closely and notice that the fragment says "No data" as it doesn't successfully load up the task data.
Your test both needs to load up the TaskDetailFragment
(which you've done) and assert the data was loaded correctly. Why is there no data? This is because you created a task, but you didn't save it to the repository.
@Test
fun activeTaskDetails_DisplayedInUi() {
// This DOES NOT save the task anywhere
val activeTask = Task("Active Task", "AndroidX Rocks", false)
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
}
You have this FakeTestRepository
, but you need some way to replace your real repository with your fake one for your fragment. You'll do this next!
9. Task: Make a ServiceLocator
In this task, you'll provide your fake repository to your fragment using a ServiceLocator
. This will allow you to write your fragment and view model integration tests.
You can't use constructor dependency injection here, as you did before, when you needed to provide a dependency to the view model or repository. Constructor dependency injection requires that you construct the class. Fragments and activities are examples of classes that you don't construct and generally don't have access to the constructor of.
Since you don't construct the fragment, you can't use constructor dependency injection to swap the repository test double (FakeTestRepository
) to the fragment. Instead, use the Service Locator pattern. The Service Locator pattern is an alternative to Dependency Injection. It involves creating a singleton class called the "Service Locator", whose purpose is to provide dependencies, both for the regular and test code. In the regular app code (the main
source set), all of these dependencies are the regular app dependencies. For the tests, you modify the Service Locator to provide test double versions of the dependencies.
Not using Service Locator | Using a Service Locator |
For this codelab app, do the following:
- Create a Service Locator class that is able to construct and store a repository. By default it constructs a "normal" repository.
- Refactor your code so that when you need a repository, use the Service Locator.
- In your testing class, call a method on the Service Locator which swaps out the "normal" repository with your test double.
Step 1. Create the ServiceLocator
Let's make a ServiceLocator
class. It'll live in the main source set with the rest of the app code because it's used by the main application code.
Note: The ServiceLocator
is a singleton, so use the Kotlin object
keyword for the class.
- Create the file ServiceLocator.kt in the top level of the main source set.
- Define an
object
calledServiceLocator
. - Create
database
andrepository
instance variables and set both tonull
. - Annotate the repository with
@Volatile
because it could get used by multiple threads (@Volatile
is explained in detail here).
Your code should look as shown below.
object ServiceLocator {
private var database: ToDoDatabase? = null
@Volatile
var tasksRepository: TasksRepository? = null
}
Right now the only thing your ServiceLocator
needs to do is know how to return a TasksRepository
. It'll return a pre-existing DefaultTasksRepository
or make and return a new DefaultTasksRepository
, if needed.
Define the following functions:
provideTasksRepository
—Either provides an already existing repository or creates a new one. This method should besynchronized
onthis
to avoid, in situations with multiple threads running, ever accidentally creating two repository instances.createTasksRepository
—Code for creating a new repository. Will callcreateTaskLocalDataSource
and create a newTasksRemoteDataSource
.createTaskLocalDataSource
—Code for creating a new local data source. Will callcreateDataBase
.createDataBase
—Code for creating a new database.
The completed code is below.
ServiceLocator.kt
object ServiceLocator {
private var database: ToDoDatabase? = null
@Volatile
var tasksRepository: TasksRepository? = null
fun provideTasksRepository(context: Context): TasksRepository {
synchronized(this) {
return tasksRepository ?: createTasksRepository(context)
}
}
private fun createTasksRepository(context: Context): TasksRepository {
val newRepo = DefaultTasksRepository(TasksRemoteDataSource, createTaskLocalDataSource(context))
tasksRepository = newRepo
return newRepo
}
private fun createTaskLocalDataSource(context: Context): TasksDataSource {
val database = database ?: createDataBase(context)
return TasksLocalDataSource(database.taskDao())
}
private fun createDataBase(context: Context): ToDoDatabase {
val result = Room.databaseBuilder(
context.applicationContext,
ToDoDatabase::class.java, "Tasks.db"
).build()
database = result
return result
}
}
Step 2. Use ServiceLocator in Application
You're going to make a change to your main application code (not your tests) so that you create the repository in one place, your ServiceLocator
.
It's important that you only ever make one instance of the repository class. To ensure this, you'll use the Service locator in the TodoApplication class.
- At the top level of your package hierarchy, open
TodoApplication
and create aval
for your repository and assign it a repository that is obtained usingServiceLocator.provideTaskRepository
.
TodoApplication.kt
class TodoApplication : Application() {
val taskRepository: TasksRepository
get() = ServiceLocator.provideTasksRepository(this)
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(DebugTree())
}
}
Now that you have created a repository in the application, you can remove the old getRepository
method in DefaultTasksRepository
.
- Open
DefaultTasksRepository
and delete the companion object.
DefaultTasksRepository.kt
// DELETE THIS COMPANION OBJECT
companion object {
@Volatile
private var INSTANCE: DefaultTasksRepository? = null
fun getRepository(app: Application): DefaultTasksRepository {
return INSTANCE ?: synchronized(this) {
val database = Room.databaseBuilder(app,
ToDoDatabase::class.java, "Tasks.db")
.build()
DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
INSTANCE = it
}
}
}
}
Now everywhere you were using getRepository
, use the application's taskRepository
instead. This ensures that instead of making the repository directly, you are getting whatever repository the ServiceLocator
provided.
- Open
TaskDetailFragement
and find the call togetRepository
at the top of the class. - Replace this call with a call that gets the repository from
TodoApplication
.
TaskDetailFragment.kt
// REPLACE this code
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
// WITH this code
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
- Do the same for
TasksFragment
.
TasksFragment.kt
// REPLACE this code
private val viewModel by viewModels<TasksViewModel> {
TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
// WITH this code
private val viewModel by viewModels<TasksViewModel> {
TasksViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
- For
StatisticsViewModel
andAddEditTaskViewModel
, update the code that acquires the repository to use the repository from theTodoApplication
.
// REPLACE this code
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// WITH this code
private val tasksRepository = (application as TodoApplication).taskRepository
- Run your application (not the test)!
Since you only refactored, the app should run the same without issue.
Step 3. Create FakeAndroidTestRepository
You already have a FakeTestRepository
in the test source set. You cannot share test classes between the test
and androidTest
source sets by default. So, you need to make a duplicate FakeTestRepository
class in the androidTest
source set, and call it FakeAndroidTestRepository
.
- Right-click the
androidTest
source set and make a data.source package. - Make a new class in this source package called
FakeAndroidTestRepository.kt
. - Copy the following code to that class.
FakeAndroidTestRepository.kt
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.runBlocking
import java.util.LinkedHashMap
class FakeAndroidTestRepository : TasksRepository {
var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()
private var shouldReturnError = false
private val observableTasks = MutableLiveData<Result<List<Task>>>()
fun setReturnError(value: Boolean) {
shouldReturnError = value
}
override suspend fun refreshTasks() {
observableTasks.value = getTasks()
}
override suspend fun refreshTask(taskId: String) {
refreshTasks()
}
override fun observeTasks(): LiveData<Result<List<Task>>> {
runBlocking { refreshTasks() }
return observableTasks
}
override fun observeTask(taskId: String): LiveData<Result<Task>> {
runBlocking { refreshTasks() }
return observableTasks.map { tasks ->
when (tasks) {
is Result.Loading -> Result.Loading
is Error -> Error(tasks.exception)
is Success -> {
val task = tasks.data.firstOrNull() { it.id == taskId }
?: return@map Error(Exception("Not found"))
Success(task)
}
}
}
}
override suspend fun getTask(taskId: String, forceUpdate: Boolean): Result<Task> {
if (shouldReturnError) {
return Error(Exception("Test exception"))
}
tasksServiceData[taskId]?.let {
return Success(it)
}
return Error(Exception("Could not find task"))
}
override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
if (shouldReturnError) {
return Error(Exception("Test exception"))
}
return Success(tasksServiceData.values.toList())
}
override suspend fun saveTask(task: Task) {
tasksServiceData[task.id] = task
}
override suspend fun completeTask(task: Task) {
val completedTask = Task(task.title, task.description, true, task.id)
tasksServiceData[task.id] = completedTask
}
override suspend fun completeTask(taskId: String) {
// Not required for the remote data source.
throw NotImplementedError()
}
override suspend fun activateTask(task: Task) {
val activeTask = Task(task.title, task.description, false, task.id)
tasksServiceData[task.id] = activeTask
}
override suspend fun activateTask(taskId: String) {
throw NotImplementedError()
}
override suspend fun clearCompletedTasks() {
tasksServiceData = tasksServiceData.filterValues {
!it.isCompleted
} as LinkedHashMap<String, Task>
}
override suspend fun deleteTask(taskId: String) {
tasksServiceData.remove(taskId)
refreshTasks()
}
override suspend fun deleteAllTasks() {
tasksServiceData.clear()
refreshTasks()
}
fun addTasks(vararg tasks: Task) {
for (task in tasks) {
tasksServiceData[task.id] = task
}
runBlocking { refreshTasks() }
}
}
Step 4. Prepare your ServiceLocator for Tests
Okay, time to use the ServiceLocator
to swap in test doubles when testing. To do that, you need to add some code to your ServiceLocator
code.
- Open
ServiceLocator.kt
. - Mark the setter for
tasksRepository
as@VisibleForTesting
. This annotation is a way to express that the reason the setter is public is because of testing.
ServiceLocator.kt
@Volatile
var tasksRepository: TasksRepository? = null
@VisibleForTesting set
Whether you run your test alone or in a group of tests, your tests should run exactly the same. What this means is that your tests should have no behavior that is dependent on one another (which means avoiding sharing objects between tests).
Since the ServiceLocator
is a singleton, it has the possibility of being accidentally shared between tests. To help avoid this, create a method that properly resets the ServiceLocator
state between tests.
- Add an instance variable called
lock
with theAny
value.
ServiceLocator.kt
private val lock = Any()
- Add a testing-specific method called
resetRepository
which clears out the database and sets both the repository and database to null.
ServiceLocator.kt
@VisibleForTesting
fun resetRepository() {
synchronized(lock) {
runBlocking {
TasksRemoteDataSource.deleteAllTasks()
}
// Clear all data to avoid test pollution.
database?.apply {
clearAllTables()
close()
}
database = null
tasksRepository = null
}
}
Step 5. Use your ServiceLocator
In this step, you use the ServiceLocator
.
- Open
TaskDetailFragmentTest
. - Declare a
lateinit TasksRepository
variable. - Add a setup and a tear down method to set up a
FakeAndroidTestRepository
before each test and clean it up after each test.
TaskDetailFragmentTest.kt
private lateinit var repository: TasksRepository
@Before
fun initRepository() {
repository = FakeAndroidTestRepository()
ServiceLocator.tasksRepository = repository
}
@After
fun cleanupDb() = runBlockingTest {
ServiceLocator.resetRepository()
}
- Wrap the function body of
activeTaskDetails_DisplayedInUi()
inrunBlockingTest
. - Save
activeTask
in the repository before launching the fragment.
repository.saveTask(activeTask)
The final test looks like this code below.
TaskDetailFragmentTest.kt
@Test
fun activeTaskDetails_DisplayedInUi() = runBlockingTest{
// GIVEN - Add active (incomplete) task to the DB
val activeTask = Task("Active Task", "AndroidX Rocks", false)
repository.saveTask(activeTask)
// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
}
- Annotate the whole class with
@ExperimentalCoroutinesApi
.
When finished, the code will look like this.
TaskDetailFragmentTest.kt
@MediumTest
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {
private lateinit var repository: TasksRepository
@Before
fun initRepository() {
repository = FakeAndroidTestRepository()
ServiceLocator.tasksRepository = repository
}
@After
fun cleanupDb() = runBlockingTest {
ServiceLocator.resetRepository()
}
@Test
fun activeTaskDetails_DisplayedInUi() = runBlockingTest{
// GIVEN - Add active (incomplete) task to the DB
val activeTask = Task("Active Task", "AndroidX Rocks", false)
repository.saveTask(activeTask)
// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
}
}
- Run the
activeTaskDetails_DisplayedInUi()
test.
Much like before, you should see the fragment, except this time, because you properly set up the repository, it now shows the task information.
10. Task: Writing your first Integration Test with Espresso
In this step, you'll use the Espresso UI testing library to complete your first integration test. You have structured your code so you can add tests with assertions for your UI. To do that, you'll use the Espresso testing library.
Espresso helps you:
- Interact with views, like clicking buttons, sliding a bar, or scrolling down a screen.
- Assert that certain views are on screen or are in a certain state (such as containing particular text, or that a checkbox is checked, etc.).
Step 1. Note Gradle Dependency
You'll already have the main Espresso dependency since it is included in Android projects by default.
app/build.gradle
dependencies {
// ALREADY in your code
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
// Other dependencies
}
androidx.test.espresso:espresso-core
—This core Espresso dependency is included by default when you make a new Android project. It contains the basic testing code for most views and actions on them.
Step 2. Turn off animations
Espresso tests run on a real device and thus are instrumentation tests by nature. One issue that arises is animations: If an animation lags and you try to test if a view is on screen, but it's still animating, Espresso can accidentally fail a test. This can make Espresso tests flaky.
For Espresso UI testing, it's best practice to turn animations off (also your test will run faster!):
- On your testing device, go to Settings > Developer options.
- Disable these three settings: Window animation scale, Transition animation scale, and Animator duration scale.
Step 3. Look at an Espresso test
Before you write an Espresso test, take a look at some Espresso code.
onView(withId(R.id.task_detail_complete_checkbox)).perform(click()).check(matches(isChecked()))
What this statement does is find the checkbox view with the id task_detail_complete_checkbox
, clicks it, then asserts that it is checked.
The majority of Espresso statements are made up of four parts:
onView
onView
is an example of a static Espresso method that starts an Espresso statement. onView
is one of the most common ones, but there are other options, such as onData
.
withId(R.id.task_detail_title_text)
withId
is an example of a ViewMatcher
which gets a view by its ID. There are other view matchers which you can look up in the documentation.
perform(click())
The perform
method which takes a ViewAction
. A ViewAction
is something that can be done to the view, for example here, it's clicking the view.
check(matches(isChecked()))
check
which takes a ViewAssertion
. ViewAssertion
s check or assert something about the view. The most common ViewAssertion
you'll use is the matches
assertion. To finish the assertion, use another ViewMatcher
, in this case isChecked
.
Note that you don't always call both perform
and check
in an Espresso statement. You can have statements that just make an assertion using check
or just do a ViewAction
using perform
.
- Open
TaskDetailFragmentTest.kt
. - Update the
activeTaskDetails_DisplayedInUi
test.
TaskDetailFragmentTest.kt
@Test
fun activeTaskDetails_DisplayedInUi() = runBlockingTest{
// GIVEN - Add active (incomplete) task to the DB
val activeTask = Task("Active Task", "AndroidX Rocks", false)
repository.saveTask(activeTask)
// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
// THEN - Task details are displayed on the screen
// make sure that the title/description are both shown and correct
onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_title_text)).check(matches(withText("Active Task")))
onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
// and make sure the "active" checkbox is shown unchecked
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(not(isChecked())))
}
Here are the import statements, if needed:
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.hamcrest.core.IsNot.not
- Everything after the
// THEN
comment uses Espresso. Examine the test structure and the use ofwithId
and check to make assertions about how the detail page should look. - Run the test and confirm it passes.
Step 4. Optional, Write your own Espresso Test
Now write a test yourself.
- Create a new test called
completedTaskDetails_DisplayedInUi
and copy this skeleton code.
TaskDetailFragmentTest.kt
@Test
fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
// GIVEN - Add completed task to the DB
// WHEN - Details fragment launched to display task
// THEN - Task details are displayed on the screen
// make sure that the title/description are both shown and correct
}
- Looking at the previous test, complete this test.
- Run and confirm the test passes.
The finished completedTaskDetails_DisplayedInUi
should look like this code.
TaskDetailFragmentTest.kt
@Test
fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
// GIVEN - Add completed task to the DB
val completedTask = Task("Completed Task", "AndroidX Rocks", true)
repository.saveTask(completedTask)
// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(completedTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
// THEN - Task details are displayed on the screen
// make sure that the title/description are both shown and correct
onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_title_text)).check(matches(withText("Completed Task")))
onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
// and make sure the "active" checkbox is shown unchecked
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isChecked()))
}
11. Task: Using Mockito to write Navigation tests
In this last step you'll learn how to test the Navigation component, using a different type of test double called a mock, and the testing library Mockito.
In this codelab you've used a test double called a fake. Fakes are one of many types of test doubles. Which test double should you use for testing the Navigation component?
Think about how navigation happens. Imagine pressing one of the tasks in the TasksFragment
to navigate to a task detail screen.
Here's code in TasksFragment
that navigates to a task detail screen when it is pressed.
TasksFragment.kt
private fun openTaskDetails(taskId: String) {
val action = TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment(taskId)
findNavController().navigate(action)
}
The navigation occurs because of a call to the navigate
method. If you needed to write an assert statement, there isn't a straightforward way to test whether you've navigated to TaskDetailFragment
. Navigating is a complicated action that doesn't result in a clear output or state change, beyond initializing TaskDetailFragment
.
What you can assert is that the navigate
method was called with the correct action parameter. This is exactly what a mock test double does—it checks whether specific methods were called.
Mockito is a framework for making test doubles. While the word mock is used in the API and name, it is not for just making mocks. It can also make stubs and spies.
You will be using Mockito to make a mock NavigationController
which can assert that the navigate method was called correctly.
Step 1. Add Gradle Dependencies
- Add the gradle dependencies.
app/build.gradle
// Dependencies for Android instrumented unit tests
androidTestImplementation "org.mockito:mockito-core:$mockitoVersion"
androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito:$dexMakerVersion"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"
org.mockito:mockito-core
—This is the Mockito dependency.dexmaker-mockito
—This library is required to use Mockito in an Android project. Mockito needs to generate classes at runtime. On Android, this is done using dex byte code, and so this library enables Mockito to generate objects during runtime on Android.androidx.test.espresso:espresso-contrib
—This library is made up of external contributions (hence the name) which contain testing code for more advanced views, such asDatePicker
andRecyclerView
. It also contains Accessibility checks and class calledCountingIdlingResource
that is covered later.
Step 2. Create TasksFragmentTest
- Open
TasksFragment
. - Right-click on the
TasksFragment
class name and select Generate then Test. Create a test in the androidTest source set. - Copy this code to the
TasksFragmentTest
.
TasksFragmentTest.kt
@RunWith(AndroidJUnit4::class)
@MediumTest
@ExperimentalCoroutinesApi
class TasksFragmentTest {
private lateinit var repository: TasksRepository
@Before
fun initRepository() {
repository = FakeAndroidTestRepository()
ServiceLocator.tasksRepository = repository
}
@After
fun cleanupDb() = runBlockingTest {
ServiceLocator.resetRepository()
}
}
This code looks similar to the TaskDetailFragmentTest
code you wrote. It sets up and tears down a FakeAndroidTestRepository
. Add a navigation test to test that when you click on a task in the task list, it takes you to the correct TaskDetailFragment
.
- Add the test
clickTask_navigateToDetailFragmentOne
.
TasksFragmentTest.kt
@Test
fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))
// GIVEN - On the home screen
val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
}
- Use Mockito's
mock
function to create a mock.
TasksFragmentTest.kt
val navController = mock(NavController::class.java)
To mock in Mockito, pass in the class you want to mock.
Next, you need to associate your NavController
with the fragment. onFragment
lets you call methods on the fragment itself.
- Make your new mock the fragment's
NavController
.
scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}
- Add the code to click on the item in the
RecyclerView
that has the text "TITLE1".
// WHEN - Click on the first list item
onView(withId(R.id.tasks_list))
.perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("TITLE1")), click()))
RecyclerViewActions
is part of the espresso-contrib
library and lets you perform Espresso actions on a RecyclerView.
- Verify that
navigate
was called, with the correct argument.
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")
Mockito's verify
method is what makes this a mock—you're able to confirm the mocked navController
called a specific method (navigate
) with a parameter (actionTasksFragmentToTaskDetailFragment
with the ID of "id1").
The complete test looks like this:
@Test
fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))
// GIVEN - On the home screen
val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
val navController = mock(NavController::class.java)
scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}
// WHEN - Click on the first list item
onView(withId(R.id.tasks_list))
.perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("TITLE1")), click()))
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")
)
}
- Run your test!
In summary, to test navigation you can:
- Use Mockito to create a
NavController
mock. - Attach that mocked
NavController
to the fragment. - Verify that navigate was called with the correct action and parameter(s).
Step 3. Optional, write clickAddTaskButton_navigateToAddEditFragment
To see if you can write a navigation test yourself, try this task.
- Write the test
clickAddTaskButton_navigateToAddEditFragment
which checks that if you click on the + FAB, you navigate to theAddEditTaskFragment
.
The answer is below.
TasksFragmentTest.kt
@Test
fun clickAddTaskButton_navigateToAddEditFragment() {
// GIVEN - On the home screen
val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
val navController = mock(NavController::class.java)
scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}
// WHEN - Click on the "+" button
onView(withId(R.id.add_task_fab)).perform(click())
// THEN - Verify that we navigate to the add screen
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToAddEditTaskFragment(
null, getApplicationContext<Context>().getString(R.string.add_task)
)
)
}
12. Solution code
Click here to see a diff between the code you started and the final code.
To download the code for the finished codelab, you can use the git command below:
$ git clone https://github.com/google-developer-training/advanced-android-testing.git $ cd android-testing $ git checkout end_codelab_2
Alternatively you can download the repository as a Zip file, unzip it, and open it in Android Studio.
13. Summary
This codelab covered how to set up manual dependency injection, a service locator, and how to use fakes and mocks in your Android Kotlin apps. In particular:
- What you want to test and your testing strategy determine the kinds of test you are going to implement for your app. Unit tests are focused and fast. Integration tests verify interaction between parts of your program. End-to-end tests verify features, have the highest fidelity, are often instrumented, and may take longer to run.
- The architecture of your app influences how hard it is to test.
- To isolate parts of your app for testing, you can use test doubles. A test double is a version of a class crafted specifically for testing. For example, you fake getting data from a database or the internet.
- Use dependency injection to replace a real class with a testing class, for example, a repository or a networking layer.
- Use instrumented testing (
androidTest
) to launch UI components. - When you can't use constructor dependency injection, for example to launch a fragment, you can often use a service locator. The Service Locator pattern is an alternative to Dependency Injection. It involves creating a singleton class called the "Service Locator", whose purpose is to provide dependencies, both for the regular and test code.
14. Learn more
Samples:
- Official Testing Sample - This is the official testing sample, which is based off of the same TO-DO Notes app used here. Concepts in this sample go beyond what is covered in the three testing codelabs.
- Sunflower demo - This is the main Android Jetpack sample which also makes use of the Android testing libraries
- Espresso testing samples
Udacity course:
Android developer documentation:
- Guide to app architecture
runBlocking
andrunBlockingTest
FragmentScenario
- Espresso
- Mockito
- JUnit4
- AndroidX Test Library
- AndroidX Architecture Components Core Test Library
- Source sets
- Test from the command line
- Dependency Injection on Android
Videos:
- An Opinionated Guide to Dependency Injection on Android (Android Dev Summit ‘19)
- Build Testable Apps for Android (Google I/O'19)
- Fragments: Past, Present, and Future (Android Dev Summit ‘19) - Testing and Fragments section
Other:
15. Next codelab
Start the next lesson: 5.3: Survey of Testing Topics