This codelab is part of the Advanced Android in Kotlin course. You'll get the most value out of this course if you work through the codelabs in sequence, but it is not mandatory. All the course codelabs are listed on the Advanced Android in Kotlin codelabs landing page.
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:
You should be familiar with:
ViewModel
, LiveData
and the Navigation ComponentYou will use the following libraries and code concepts:
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.
To get started, download the code:
Alternatively, you can clone the Github repository for the code:
$ git clone https://github.com/googlecodelabs/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.
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:
The TO-DO app is based off of the popular Architecture Blueprints testing and architecture sample (using the reactive architecture version of the 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:
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.
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.
When thinking about a testing strategy, there are three related testing aspects:
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:
test
source set). Example: Testing single methods in view models and repositories.androidTest
source set) The suggested proportion of these tests is often represented by a pyramid, with the vast majority of tests being unit tests.
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:
End to end testing will be covered in the next lesson.
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.DefaultTaskRepository
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, DefaultTaskRepository
depends on other classes, such as LocalTaskDataSource
and RemoteTaskDataSource
, to function. Another way to say this is that LocalTaskDataSource
and RemoteTaskDataSource
are dependencies of DefaultTaskRepository
.
So every method in DefaultTaskRepository
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:
The solution to this is that when you're testing the repository, don't use the real networking or database code, but to 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.
In this step you are going to create a class called FakeDataSouce
, which will be a test double of a LocalDataSource
and RemoteDataSource
.
FakeDataSource
in the data/source package.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
.
TasksDataSource
interface.class TasksLocalDataSource internal constructor(
private val tasksDao: TasksDao,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }
object TasksRemoteDataSource : TasksDataSource { ... }
FakeDataSource
implement TasksDataSource
:class FakeDataSource : TasksDataSource {
}
Android Studio will complain that you haven't implemented required methods for TasksDataSource
.
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
DefaultTasksRepository
without needing to rely on a real database or network.FakeDataSource
constructor to create a var
called tasks
that is a MutableList<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:
getTasks
: If tasks
isn't null
, return a Success
result. If tasks
is null
, return an Error
result.deleteAllTasks
: clear the mutable tasks list.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.
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 |
DefaultTaskRepository
's constructor from taking in an Application
to taking in both data sources and the coroutine dispatcher (which you'll also need to swap for your tests - this is described in more detail in the third lesson section on coroutines).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 }
init
method. You no longer need to create the dependencies.DefaultTasksRepository.kt
// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
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!
Now that your code is using constructor dependency injection, you can use your fake data source to test your DefaultTasksRepository
.
DefaultTasksRepository
class name and select Generate, then Test.DefaultTasksRepositoryTest
in the test source set.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 }
FakeDataSource
member variables (one for each data source for your repository) and a variable for the DefaultTasksRepository
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
.
createRepository
and annotate it with @Before
.remoteTasks
and localTasks
lists.tasksRepository
, using the two fake data sources you just created and Dispatchers.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
)
}
Time to write a DefaultTasksRepository
test!
getTasks
method. Check that when you call getTasks
with true
(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:
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.
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.
@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.DefaultTasksRepositoryTest
, add runBlockingTest
so that it takes in your entire test as a "block" of codeThis 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))
}
}
getTasks_requestsAllTasksFromRemoteDataSource
test and confirm it works and the error is gone!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 in database, network, or the 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.
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
.
DefaultTasksRepository
and right-click on the class name. Then select Refactor -> Extract -> Interface.TasksRepository
.TasksRepository
interface should appear in the data/source package.And DefaultTasksRepository
now implements TasksRepository
.
Now that you have the interface, you can create the DefaultTaskRepository
test double.
FakeTestRepository.kt
and extend from the TasksRepository
interface.FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
}
You will be told you need to implement the interface 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.
FakeTestRepository
, add both a LinkedHashMap
variable representing the current list of tasks and a MutableLiveData
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 the tasksServiceData
and turn it into a list using tasksServiceData.values.toList()
and then return that as a Success
result.refreshTasks
—Updates the value of observableTasks
to be what is returned by getTasks()
.observeTasks
—Creates a coroutine using runBlocking
and run refreshTasks
, then returns observableTasks
.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
}
// Rest of class
}
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.
addTasks
method, which takes in a vararg
of tasks, adds each to the HashMap
, 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!
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.
You start with updating the classes and test related to the Tasks
screen.
TasksViewModel
.TasksViewModel
to take in TasksRepository
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
. Put the factory class in the same file as the TasksViewModel
, but you could also put it in its own file.
TasksViewModel
file, outside the class, add a TasksViewModelFactory
which takes in a plain TasksRepository
.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 you change how ViewModel
s are constructed. Now that you have the factory, use it wherever you construct your view model.
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))
}
Now instead of using the real repository in your view model tests, you can use the fake repository.
TasksViewModelTest
.FakeTestRepository
property in the TasksViewModelTest
.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
}
setupViewModel
method to make a FakeTestRepository
with three tasks, and then construct the tasksViewModel
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)
}
ApplicationProvider.getApplicationContext
code, you can also remove the @RunWith(AndroidJUnit4::class)
annotation.By using constructor dependency injection, you've now removed the DefaultTasksRepository
as a dependency and replaced it with your FakeTestRepository
in the tests.
Make the exact same changes for the TaskDetailFragment
and TaskDetailViewModel
. This will prepare the code for when you write TaskDetail
tests next.
TaskDetailViewModel
.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 }
TaskDetailViewModel
file, outside the class, add a TaskDetailViewModelFactory
.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)
}
TasksFragment
to use the factory.TasksFragment.kt
// REPLACE
private val viewModel by viewModels<TaskDetailViewModel>()
// WITH
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
You are now able to use a FakeTestRepository
instead of the real repository in TasksFragment
and TasksDetailFragment
.
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
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.
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 library androidx.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.
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.
taskdetail.TaskDetailFragment
.TaskDetailFragment
, as you've done before. Accept the default choices and put it in the androidTest source set (NOT the test
source set).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
large 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.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
).
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:
Bundle
, which represents the fragment arguments for the task that get passed into the fragment).launchFragmentInContainer
function creates a FragmentScenario
, 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.
A few things should happen.
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!
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:
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.
object
called ServiceLocator
.database
and repository
instance variables and set both to null
.@Volatile
because it could get used by multiple threads (@Volatile
is explained in detail here).Your code should look as a 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 be synchronized
on this
to avoid, in situations with multiple threads running, ever accidentally creating two repository instances.createTasksRepository
—Code for creating a new repository. Will call createTaskLocalDataSource
and create a new TasksRemoteDataSource
.createTaskLocalDataSource
—Code for creating a new local data source. Will call createDataBase
.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
}
}
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 my Application class.
TodoApplication
and create a val
for your repository and assign it a repository that is obtained using ServiceLocator.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
.
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.
TaskDetailFragement
and find the call to getRepository
at the top of the class. 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)
}
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)
}
StatisticsViewModel
and AddEditTaskViewModel
, update the code that acquires the repository to use the repository from the TodoApplication
.TasksFragment.kt
// REPLACE this code
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// WITH this code
private val tasksRepository = (application as TodoApplication).taskRepository
Since you only refactored, the app should run the same without issue.
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
.
androidTest
source set and make a data package. Right-click again and make a source package. FakeAndroidTestRepository.kt
.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() }
}
}
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.
ServiceLocator.kt
.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.
lock
with the Any
value.ServiceLocator.kt
private val lock = Any()
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
}
}
In this step, you use the ServiceLocator
.
TaskDetailFragmentTest
.lateinit TasksRepository
variable.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()
}
activeTaskDetails_DisplayedInUi()
in runBlockingTest
.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)
}
@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)
}
}
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.
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:
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.
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!):
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
.
2. ViewMatcher
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.
3. ViewAction
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 asserts 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
.
TaskDetailFragmentTest.kt
.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
// THEN
comment uses Espresso. Examine the test structure and the use of withId
and check to make assertions about how the detail page should look.Now write a test yourself.
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
}
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()))
}
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.
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 as DatePicker
and RecyclerView
. It also contains Accessibility checks and class called CountingIdlingResource
that is covered later.TasksFragment
.TasksFragment
class name and select Generate then Test. Create a test in the androidTest source set.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
.
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)
}
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.
NavController
.scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}
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.
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")
)
}
In summary, to test navigation you can:
NavController
mock.NavController
to the fragment.To see if you can write a navigation test yourself, try this task.
clickAddTaskButton_navigateToAddEditFragment
which checks that if you click on the + FAB, you navigate to the AddEditTaskFragment
.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)
)
)
}
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/googlecodelabs/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.
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:
androidTest
) to launch UI components. Udacity course:
Android developer documentation:
runBlocking
and runBlockingTest
FragmentScenario
Videos:
Other:
For links to other codelabs in this course, see the Advanced Android in Kotlin codelabs landing page.