Table of contents
- About the sample project
- Making your code testable
- Writing Unit tests
- Writing Integration tests
- Writing Jetpack Compose UI tests
- Conclusion
Testing is quite a fascinating topic in software development. On the one hand, writing tests takes time, and it might not feel as rewarding - you cannot show a test to a client as you can a new feature. On the other hand, writing tests is an integral part of a project and shouldn't be treated less seriously than the code that makes the wheels turn.
Apart from actually testing the code functionality, there are many other benefits to writing tests. Tests can act as supplementary documentation that explains the expected behavior of the software. Tests can be a safety net and confidence boosters during complicated refactors and rewrites. Moreover, tests - or the inability to write them - can shine a light on architectural flaws, inciting developers to step up their game and write better code.
That said, even though we will talk a bit about theory, the main goal of this article is to see how Android testing works in practice. We will take a small sample project without any tests and, in the end, have a fully tested application.
Since we will be covering all aspects of testing an Android application, this article is not meant to be consumed in one sitting. There is too much information for that, especially for junior developers. The goal of this article is to create a comprehensive guide to Android testing, which can be studied and referred to as needed.
About the sample project
The project for today's purposes will be a sample app named Boredom Buster.
The source code can be found on GitHub.
This app uses the free BoredAPI to fetch a random activity and allows the user to save this activity to a database as a favorite for later browsing.
The app consists of two screens - an activity screen where the user can generate a random activity and save it to a database and a second screen where the user can see a list of all saved activities and delete them if they wish.
I have used the MVVM design pattern for the app with the following tech stack:
- Jetpack Compose with Navigation
- Jetpack Lifecycle Components
- Retrofit with Moshi converter
- Room
- Hilt
If you would like to follow along with this article, the repository has a branch start
, which doesn't have any tests and will be our starting point. The branch finish
contains all the tests that we will write in this article.
Note: We will be using the new Coroutines Test API, which was released in
kotlinx.coroutines 1.6.0
.
Making your code testable
This will be a very practical article, but before we start, I would like to spend a few moments on a topic that can't be ignored when talking about testing software.
In most cases, writing tests should be straightforward. If you cannot find an easy way to test your code, chances are that your code has some structural flaws.
That is where architecture comes in.
Today we will not focus on architecture, but keep in mind that it is an integral part of writing good tests.
If you are not familiar with the Android App architecture, a good place to start is the official guide to app architecture from Google. However, for a more advanced understanding of architecture, I strongly recommend studying Uncle Bob's Clean Architecture approach. His blog post on the topic was so popular that Uncle Bob wrote a whole book on the subject, and I consider it a must-read for any software developer.
The app we will be testing today uses a Clean Architecture approach adapted for Android.
Writing Unit tests
On that note, let's open up our project and start writing some tests.
First, we will tackle Unit tests that test individual small parts of our application - methods, functions, and classes.
Testing ViewModels with StateFlow
In Android, one of the most important parts to test is the ViewModels, and that is where we will start.
Our project has two ViewModels - one for the Activity screen (I don't see any reason why this can be confusing to Android developers, right?) that is responsible for fetching and displaying a random activity, and one for the Favorites screens displaying a list of saved activities.
Let's first open up NewActivityViewModel
and look at its structure.
We have two public methods:
loadNewActivity(): Unit
- a method that fetches a random activity from the API and updates the UI state;setIsFavorite(Activity, Boolean): Unit
- a method that either saves an activity to the database or deletes it.
We also have a publicly exposed StateFlow<NewActivityUiState>
, which emits the current UI state for the Activity screen.
Note: We are using a
StateFlow
in thisViewModel
, but for the sake of covering more testing examples, theFavoritesViewModel
exposes its UI state via aLiveData
.
One important thing to note here is that NewActivityViewModel
also calls loadNewActivity()
in its init()
block to immediately display a random activity to the user.
Test scenarios
Given that, here are the scenarios we would like to test to make sure our ViewModel works correctly:
- When creating a
NewActivityViewModel
, the initial UI state isLoading
; - Creating a
NewActivityViewModel
triggers the emission of a newSuccess
UI state; - Creating a
NewActivityViewModel
triggers the emission of a newError
UI state in case of error; - If Activity is already saved, the UI state's
isFavorite
is set totrue
; - Calling
loadNewActivity()
successfully reloads the existing UI state with a new activity; - Calling
setIsFavorite(activity, true)
callsSaveActivity
use case; - Calling
setIsFavorite(activity, false)
callsDeleteActivity
use case.
Creating a test
To create a test, you can either create a test file manually or make an Android Studio generate one for you.
To generate a test, you can press Alt + Insert
on Windows or Cmd + N
on Mac anywhere in the target file to open the Generate
popup and select Test
.
This will open the Create Test popup:
Projects generated by Android Studio automatically have the necessary dependencies to run tests with JUnit4
, so we will use that. JUnit5
is not fully supported for Android and requires some more setting up, so we will ignore it. JUnit4
is more than enough for our testing purposes.
There are some other options that this popup allows us to select, but we will ignore them for now and do everything manually as we see fit.
Clicking OK
will prompt us to choose a destination directory:
We have two options where to put our new test:
androidTest
- a directory for instrumented Android tests, which run on either a physical or emulated Android device. This directory is for tests that depend on the Android framework to run. Most commonly those are UI tests or database tests.test
- a directory for local unit tests or integrations tests that don't require the Android framework to run.
With that in mind, we will be using the test
directory since we don't need anything from the Android framework.
Note: This is one of the main reasons why your ViewModels should not depend on Android
Context
. But there are also other considerations. Here is a good article for further research.
Clicking OK
will generate or new test class, which is quite barebones for now, but we will create a separate test for every scenario outlined above.
In JUnit
you create a test by annotating the test method with a @Test
annotation.
Also, naming your test methods can be different from naming regular methods, depending on the convention you prefer. It is a good practice to write explicit method names, describing the expected behavior of the test. It also acts as complementary documentation for your code.
In Kotlin, we can use backticks to write method names in “plain text", which I prefer for naming tests.
However, there is a catch. Using this kind of naming is not allowed in Android projects. Therefore, if you want to use the same naming convention in your test
and androidTest
folders, you should probably stick with the regular naming.
That said, to illustrate that this is also a very popular approach in testing, we will use this naming in our non-Android tests.
With that in mind, let's create a test for the first scenario:
@Test
fun `creating a viewmodel exposes loading ui state`() {
}
The tests usually follow the AAA
pattern, which stands for Arrange
- Act
- Assert
.
In this first scenario, we won't have an Act
part since we will be testing the initial UI state immediately after creating the NewActivityViewModel
.
However, we cannot just create an instance of NewActivityViewModel
since it depends on a bunch of use cases. In this test, we are focusing on the NewActivityViewModel
class only and don't care about all other parts of the app and how our app interacts with them. We will test that later with integration tests, which, as the name suggests, are meant to test integration between different components.
Fakes vs. Mocks
There are two common ways to solve this problem - by either using fakes or mocks. Fakes are working implementations of dependencies that are written with testing in mind, while mocks are test doubles that can be programmed to behave as you like and require a mocking framework, such as Mockito.
It is always a hot topic which approach is better. However, it is important to highlight that both approaches have pros and cons.
I prefer to use fakes as much as possible. I mainly use mocks when I test the number or order of interactions, which is much easier to do with mocks. In general, being too dependent on mocks can make you miss opportunities to have better integration tests when testing how multiple components work together. Or even highlight flaws in the architecture if your components depend on concrete implementations and it is not possible to create a fake.
Your components should be decoupled for cleaner code and easier testing. In most cases, components should depend on interfaces, not concrete implementations.
Creating fake dependencies
We will discuss how to use both fakes and mocks in this article, but let's start with fakes since they don't require additional dependencies and are also recommended by Google.
We will be reusing our fakes, so let's create a new package eu.maxkim.boredombuster.activity.fake
, where we will put all our fakes.
To test our ViewModel
we will only need fake use cases, so let’s put all of them together in a new package .fake.usecase
.
First we need a fake GetRandomActivity
use case, so let's create a new class FakeGetRandomActivity
, which implements that interface.
Here is our newly created fake:
class FakeGetRandomActivity : GetRandomActivity {
override suspend fun invoke(): Result<Activity> {
TODO("Not yet implemented")
}
}
As you can see, since the data comes from the network, our use case returns a wrapper Result<T>
, which can either be a Success
or Error
. Naturally, we would want to test both those scenarios, so let's make sure our fake supports that. The easiest way to implement that is to just pass a Boolean
argument in a constructor, which will change the behavior of our fake:
class FakeGetRandomActivity(
private val isSuccessful: Boolean = true
) : GetRandomActivity {
...
}
With that out of the way, we will also need some dummy test objects to play around with in our tests. To make things cleaner, let's create a new TestModels.kt
file to store them.
For now let's add two test activities:
//TestModels.kt
val activity1 = Activity(
name = "Learn to dance",
type = Activity.Type.Recreational,
participantCount = 2,
price = 0.1f,
accessibility = 0.2f,
key = "112233",
link = "www.dance.com"
)
val activity2 = Activity(
name = "Pet a dog",
type = Activity.Type.Relaxation,
participantCount = 1,
price = 0.0f,
accessibility = 0.1f,
key = "223344",
link = "www.dog.com"
)
Now we can finish our fake:
class FakeGetRandomActivity(
private val isSuccessful: Boolean = true
) : GetRandomActivity {
override suspend fun invoke(): Result<Activity> {
return if (isSuccessful) {
Result.Success(activity1)
} else {
Result.Error(RuntimeException("Boom..."))
}
}
}
Another fake that we will use in the first test scenario is IsActivitySaved
use case.
When getting a random activity from API, we use this use case to check whether or not the user has already saved this activity to display its state correctly.
Later on, we will be testing whether or not our ViewModel correctly displays the favorite status, so we will make this fake flexible as well:
class FakeIsActivitySaved(
private val isActivitySaved: Boolean = false
) : IsActivitySaved {
override suspend fun invoke(key: String): Boolean {
return isActivitySaved
}
}
We won't use the two remaining use cases in the first test scenario, so let's just create them without any logic and return to them when we need them:
class FakeSaveActivity : SaveActivity {
override suspend fun invoke(activity: Activity) {
TODO("Not yet implemented")
}
}
class FakeDeleteActivity : DeleteActivity {
override suspend fun invoke(activity: Activity) {
TODO("Not yet implemented")
}
}
First test scenario
With that out of the way, we can get back to writing our test. Let's create an instance of a NewActivityViewModel
using our fakes:
@Test
fun `creating a viewmodel exposes loading ui state`() {
// Arrange
val viewModel = NewActivityViewModel(
FakeGetRandomActivity(),
FakeSaveActivity(),
FakeDeleteActivity(),
FakeIsActivitySaved()
)
}
Now we want to test that after creating the viewModel
the initial value of the uiState
is NewActivityUiState.Loading
.
Here are a couple of things to keep in mind when writing this first test.
First, StateFlow
always has a value
or a state (hence the name), so we can access it synchronously with viewModel.uiState.value
. We don't need to be inside a coroutine to do that.
Second, people use many different libraries in their tests for assertions, but we will use the standard assertion functions that come with Kotlin and JUnit to make things easier.
With that in mind we can finish our test:
@Test
fun `creating a viewmodel exposes loading ui state`() {
// Arrange
val viewModel = NewActivityViewModel(
FakeGetRandomActivity(),
FakeSaveActivity(),
FakeDeleteActivity(),
FakeIsActivitySaved()
)
// Assert
assert(viewModel.uiState.value is NewActivityUiState.Loading)
}
Now, if we run this test, we will get an error:
Exception in thread "Test worker" java.lang.IllegalStateException:
Module with the Main dispatcher had failed to initialize.
For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
This happens because creating a new instance of NewActivityViewModel
immediately calls loadNewActivity()
method, which launches a new coroutine in the viewModelScope
.
This scope uses the Dispatchers.Main.immediate
dispatcher, and since Dispatcher.Main
usually comes with the framework responsible for drawing UI and otherwise is not readily available, we will have to supply our own dispatcher.
Luckily, this is very easy to do, and the error message even gives us a hint. We can use Dispatchers.setMain()
method from kotlinx-coroutines-test
module.
Let's add it to our project:
// project build.gradle
coroutines_test_version = '1.6.1'
// module build.gradle
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_test_version"
Now we can set the StandartTestDispatcher
, which comes from the kotlinx-coroutines-test
library, as our Dispatchers.Main
. It is a good practice to set and reset the Dispatchers.Main
before and after each test, and for that, we can use @Before
and @After
annotations that come with JUnit
.
@ExperimentalCoroutinesApi
class NewActivityViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
...
}
Note: We also need to add either
@ExperimentalCoroutinesApi
or@OptIn(ExperimentalCoroutinesApi::class)
to the class since most of the stuff insidekotlinx-coroutines-test
is experimental.
Since we can set the Dispatchers.Main
ourselves in the tests, we can easily test coroutine scopes that use this dispatcher (like viewModelScope
or lifecycleScope
) without injecting a test scope. However, we must replace the scopes that use other dispatchers with a TestScope
. We will look into those scenarios a bit later.
If we run our test now, it finally passes, and we have our first working test!
Creating test rules
It took us a while to get our first test running, but we are still in the process of setting up, so test writing will speed up as we go.
One of the things that we can do to speed it up even more, is to create a Rule
that will be responsible for setting and resetting the Dispatchers.Main
. This way, we can re-use this logic in other test files.
Let's create a new package eu.maxkim.boredombuster.util
with a new class CoroutineRule
.
To get the @Before
and @After
behavior, we can extend an abstract TestWatcher
class, which comes with starting
and finished
methods.
After overriding these methods we get:
@ExperimentalCoroutinesApi
class CoroutineRule(
val testDispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description?) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description?) {
Dispatchers.resetMain()
}
}
With our CoroutineRule
ready, let's go back to our test and replace the @Before
and @After
logic with the rule. For that, we need to add the following to our test class:
@get:Rule
val coroutineRule = CoroutineRule()
@get:Rule
annotation comes fromJUnit
.
With that in place, here is what our test file looks like now:
@ExperimentalCoroutinesApi
class NewActivityViewModelTest {
@get:Rule
val coroutineRule = CoroutineRule()
@Test
fun `creating a viewmodel exposes loading ui state`() {
// Arrange
val viewModel = NewActivityViewModel(
FakeGetRandomActivity(),
FakeSaveActivity(),
FakeDeleteActivity(),
FakeIsActivitySaved()
)
// Assert
assert(viewModel.uiState.value is NewActivityUiState.Loading)
}
}
Run the test to make sure that everything works as before.
Understanding test dispatchers after Kotlin Coroutines 1.6
Before we move on to our second test scenario, I would like to talk about why this test works. One might assume that this test should be flaky since creating the NewActivityViewModel
loads a new activity immediately and changes the UI state to NewActivityUiState.Success
.
However, we confidently check the UI state and expect it always to be NewActivityUiState.Loading
.
The secret here is the StandardTestDispatcher()
. Coroutines launched on this dispatcher are not executed immediately, which is a big difference compared to how testing coroutines worked before kotlinx.coroutines 1.6.0
.
Here is an excerpt from StandardTestDispatcher()
documentation:
/**
* In practice, this means that [launch] or [async] blocks
* will not be entered immediately (unless they are
* parameterized with [CoroutineStart.UNDISPATCHED]),
* and one should either call [TestCoroutineScheduler.runCurrent] to
* run these pending tasks, which will block until there are
* no more tasks scheduled at this point in time, or, when
* inside [runTest], call [yield] to yield the (only) thread
* used by [runTest] to the newly-launched coroutines.
*/
This means that the coroutine that we launch inside the loadNewActivity()
method is not executed unless we explicitly specify it, which we will do for our next test scenario.
If we need our tests to run blocking as they did in the old testing API, which might be useful during a migration to the new API, we can replace the StandardTestDispatcher()
with the UnconfinedTestDispatcher()
. Using this dispatcher gives us a much closer behavior to runBlockingTest
from the old testing API, since it executes all launched coroutines eagerly.
For learning purposes, you can try replacing the StandardTestDispatcher()
in the CoroutineRule
with the UnconfinedTestDispatcher()
, which will make our test fail. In that case, when we make our assertion, the UI state will already be NewActivityUiState.Success
. However, if you do change it, don't forget to change it back!
Remaining test scenarios
Second test scenario
Now that we better understand how testing coroutines work, we can start writing our next test.
We will test that after an instance of NewActivityViewModel
is created and all the coroutines have successfully finished, the UI state is NewActivityUiState.Success
.
We prepare our viewModel
an also an expectedUiState
that we should get if everything works correctly:
@Test
fun `creating a viewmodel updates ui state to success after loading`() {
// Arrange
val viewModel = NewActivityViewModel(
FakeGetRandomActivity(),
FakeSaveActivity(),
FakeDeleteActivity(),
FakeIsActivitySaved()
)
val expectedUiState = NewActivityUiState.Success(activity1, false)
}
Now we need to execute our coroutine that loads a new activity from the FakeGetRandomActivity
use case. There are many ways to achieve that, but for now, we will go with the easiest and most straightforward way to do it, which is calling runCurrent()
on a TestCoroutineScheduler
used by our TestDispatcher
. You can also use advanceUntilIdle()
to get the same result. There is a difference between these two functions, but we will discuss this difference and how the scheduler and dispatcher are connected a bit later. We can access the TestDispatcher
and the TestCoroutineScheduler
from our CoroutineRule
.
After that, we can do our assertions and expect the test to pass.
Here is the finished test:
@Test
fun `creating a viewmodel updates ui state to success after loading`() {
// Arrange
val viewModel = NewActivityViewModel(
FakeGetRandomActivity(),
FakeSaveActivity(),
FakeDeleteActivity(),
FakeIsActivitySaved()
)
val expectedUiState = NewActivityUiState.Success(activity1, false)
// Act
coroutineRule.testDispatcher.scheduler.runCurrent()
// Assert
val actualState = viewModel.uiState.value
assertEquals(actualState, expectedUiState)
}
Third test scenario
We should also test that the UI state correctly updates to NewActivityUiState.Error
if something goes wrong. With all the things we have already discussed, writing this test should be a breeze. All we have to do is to make our fake return an error.
Here is the final result:
@Test
fun `creating a viewmodel updates ui state to error in case of failure`() {
// Arrange
val viewModel = NewActivityViewModel(
FakeGetRandomActivity(isSuccessful = false), // our fake will return an error
FakeSaveActivity(),
FakeDeleteActivity(),
FakeIsActivitySaved()
)
// Act
coroutineRule.testDispatcher.scheduler.runCurrent()
// Assert
val currentState = viewModel.uiState.value
assert(currentState is NewActivityUiState.Error)
}
Fourth test scenario
In this scenario, we want to test that, if the activity is already saved, the UI state's isFavorite
flag is set to true
. Given everything we already know, this shouldn't be that hard.
Here is the final test:
@Test
fun `if activity is already saved, ui state's isFavorite is set to true`() {
// Arrange
val viewModel = NewActivityViewModel(
FakeGetRandomActivity(), // our fake will return an error
FakeSaveActivity(),
FakeDeleteActivity(),
FakeIsActivitySaved(isActivitySaved = true)
)
val expectedUiState = NewActivityUiState.Success(activity1, true)
// Act
coroutineRule.testDispatcher.scheduler.runCurrent()
// Assert
val actualState = viewModel.uiState.value
assertEquals(actualState, expectedUiState)
}
Fifth test scenario
This scenario is a bit harder since we will be testing whether the new activity loads and replaces the current one when calling loadNewActivity()
explicitly.
First of all, let's change our FakeGetRandomActivity
to be able to return a new activity when called a second time. The easiest way to do that is to expose a property, which will be returned by the fake if set:
class FakeGetRandomActivity(
private val isSuccessful: Boolean = true
) : GetRandomActivity {
var activity: Activity? = null
override suspend fun invoke(): Result<Activity> {
return if (isSuccessful) {
Result.Success(activity ?: activity1)
} else {
Result.Error(RuntimeException("Boom..."))
}
}
}
We cannot pass the activity in the constructor since we will need to swap it on the same instance.
With that in place, we can now write a test for this scenario:
@Test
fun `calling loadNewActivity() updates ui state with a new activity`() {
// Arrange
val fakeGetRandomActivity = FakeGetRandomActivity()
val viewModel = NewActivityViewModel(
fakeGetRandomActivity,
FakeSaveActivity(),
FakeDeleteActivity(),
FakeIsActivitySaved()
)
// this can be omitted, but it is nice to not have any pending tasks
coroutineRule.testDispatcher.scheduler.runCurrent()
val expectedUiState = NewActivityUiState.Success(activity2, false)
fakeGetRandomActivity.activity = activity2
// Act
viewModel.loadNewActivity()
coroutineRule.testDispatcher.scheduler.runCurrent()
// Assert
val actualState = viewModel.uiState.value
assertEquals(actualState, expectedUiState)
}
We are not testing the state of our view model after the first call to loadNewActivity()
since we already have a dedicated test for that. Therefore, we are only interested in the second call.
Also, in general, Unit tests should be as small as possible, aiming to assert only one scenario. That said, if you want to throw in another assertion and test the UI state before and after, nobody will call the police on you.
Sixth scenario
In this scenario, we will be testing whether or not calling setIsFavorite(activity, true)
method interacts with the correct use case, and as I have mentioned before, testing interactions is what mocks are good for.
However, we will have plenty of time to play around with mocks later, so for this ViewModel
, let's see how we can test interaction with fakes, so that we have both options.
To do that, we need to introduce a state to our fake, which will change after we invoke it. After that, we will use this state in our assertions.
Here is what our FakeSaveActivity
looks like after the changes:
class FakeSaveActivity : SaveActivity {
var wasCalled = false
private set
override suspend fun invoke(activity: Activity) {
wasCalled = true
}
}
Note: We are using
private set
to make sure that the only way to change the state is to call theinvoke()
method
With that in place, we can write our test as follows:
@Test
fun `calling setIsFavorite(true) triggers SaveActivity use case`() {
// Arrange
val fakeSaveActivity = FakeSaveActivity()
val viewModel = NewActivityViewModel(
FakeGetRandomActivity(),
fakeSaveActivity,
FakeDeleteActivity(),
FakeIsActivitySaved()
)
// Act
viewModel.setIsFavorite(activity1, true)
coroutineRule.testDispatcher.scheduler.runCurrent()
// Assert
assert(fakeSaveActivity.wasCalled)
}
Seventh scenario
The test for setIsFavorite(activity, false)
is exactly the same, so here is the final result:
@Test
fun `calling setIsFavorite(false) triggers DeleteActivity use case`() {
// Arrange
val fakeDeleteActivity = FakeDeleteActivity()
val viewModel = NewActivityViewModel(
FakeGetRandomActivity(),
FakeSaveActivity(),
fakeDeleteActivity,
FakeIsActivitySaved()
)
// Act
viewModel.setIsFavorite(activity1, false)
coroutineRule.testDispatcher.scheduler.runCurrent()
// Assert
assert(fakeDeleteActivity.wasCalled)
}
Other scenarios
There are still some untested scenarios left in the NewActivityViewModel
. For example, testing that calling setIsFavorite()
also changes the UI state appropriately. However, these tests are very similar to what we have already discussed and don't provide any educational value to include in this article.
That said, in the finish
branch of this project, I will include as many of these tests as possible, so you can reference them if you wish.
Bonus: Testing Flows with Turbine
In the above test scenarios, we had the pleasure of dealing with a StateFlow
, which allows us to access its value
outside a coroutine. Of course, we could have also used first()
, but that is a suspend function, and we would have needed a coroutine for that. That said, when testing Android apps, there will be a lot of scenarios where you will need a coroutine (plenty of those examples later on), and maybe you will want to collect multiple values from a Flow
for assertion.
Testing hot flows like StateFlow
(they never finish) might be tricky in these scenarios.
And that's where Turbine comes in.
It is a small library that makes testing Flow
s really easy and intuitive.
To illustrate how it works, let's use Turbine to rewrite the test where we call loadNewActivity()
the second time.
Since this is a bonus section, I will not discuss most of the used concepts and will focus on the Turbine library. However, we will cover everything else later in the article, so feel free to return to this section later if something doesn't make sense.
First, we have to add it to the dependencies:
// project build.gradle
turbine_version = '0.8.0'
// module build.gradle
testImplementation "app.cash.turbine:turbine:$turbine_version"
Now we can test our Flow
s in the following manner:
flowOf("one", "two").test {
assertEquals("one", awaitItem())
assertEquals("two", awaitItem())
awaitComplete()
}
With that in mind, let's test the following scenario again.
- We want the initial state of UI to be
Loading
; - Then, after the first load completes, we expect the state to change to
Success
with the corresponding activity; - Calling
loadNewActivity()
again should change the state back toLoading
and finish with anotherSuccess
containing the second activity.
The Turbine's test()
function is a suspend function, so we will need to run our test with the runTest
coroutine builder (again, if you don't know what it is, please return to this bonus section later).
Also, we will have to collect our flow with the test()
function in a separate coroutine so it is not suspending the whole test.
With those details out of the way, here is how we can test this scenario with Turbine:
@Test
fun `calling loadNewActivity() twice goes through expected ui states`() = runTest {
val fakeGetRandomActivity = FakeGetRandomActivity()
val viewModel = NewActivityViewModel(
fakeGetRandomActivity,
FakeSaveActivity(),
FakeDeleteActivity(),
FakeIsActivitySaved()
)
assert(viewModel.uiState.value is NewActivityUiState.Loading)
launch {
viewModel.uiState.test {
with(awaitItem()) {
assert(this is NewActivityUiState.Success)
assertEquals((this as NewActivityUiState.Success).activity, activity1)
}
assert(awaitItem() is NewActivityUiState.Loading)
with(awaitItem()) {
assert(this is NewActivityUiState.Success)
assertEquals((this as NewActivityUiState.Success).activity, activity2)
}
// this is not necessary for this specific test
// but better safe than sorry
// especially when dealing with hot flows
cancelAndIgnoreRemainingEvents()
}
}
// runs the initial loading
runCurrent()
// prepares and runs the second loading
fakeGetRandomActivity.activity = activity2
viewModel.loadNewActivity()
runCurrent()
}
This is just a brief introduction to Turbine. It has much more to offer when testing different scenarios with Flow
s. So keep it in mind if you need to write more complex tests.
With that out of the way, let's get back to testing our app!
Testing ViewModels with LiveData
Now let's take a look at how we can test LiveData
.
In the app, we have a second ViewModel
, which is a good candidate for that - the FavoritesViewModel
.
This ViewModel
is quite small. There are only three scenarios:
- The view model exposes the list of activities from
LiveData
as a List UI state; - The view model exposes an empty list from
LiveData
as an Empty UI state; - Calling
deleteActivity()
triggers interaction with the correct use case.
Create a test file for the FavoritesViewModel
, and let's get started.
First scenario and introduction to Mockito
The first thing that we need to consider when testing LiveData
is that it also heavily relies on the main UI thread. We already know how to deal with this issue when testing coroutines. We have made a custom CoroutineRule
which sets and resets the main thread using the Dispatchers.setMain()
and Dispatchers.resetMain()
methods.
This time around, we don't have to do anything ourselves since folk at Google kindly provided us with a solution to this problem.
There is a dedicated package for testing Architecture Components, which we can add to our project:
// project build.gradle
arch_testing_version = '2.1.0'
// module build.gradle
testImplementation "androidx.arch.core:core-testing:$arch_testing_version"
The class that we are interested in is InstantTaskExecutorRule
, which does exactly what our CoroutineRule
does, only for Architecture components.
/**
* A JUnit Test Rule that swaps the background executor
* used by the Architecture Components with a
* different one which executes each task synchronously.
* <p>
* You can use this rule for your host side tests that
* use Architecture Components.
*/
public class InstantTaskExecutorRule extends TestWatcher {
...
}
If you look at its implementation, you will find a very familiar pattern. It also overrides starting
and finished
methods of TestWatcher
to change and reset the executor.
Now let's add this rule to our FavoritesViewModelTest
:
class FavoritesViewModelTest {
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
}
One important thing to address is that, unlike StateFlow
, LiveData
doesn't have a mandatory initial value. Moreover, LiveData
is written in Java, and its value
doesn't always make sense in Kotlin.
For example, our LiveData
type is LiveData<FavoritesUiState>
, so one would assume that the value
is non-nullable. However, that is not the case.
If we create a new instance of FavoritesViewModel
and immediately check viewModel.uiStateLiveData.value
it will be null
.
So to properly test our LiveData
, we have to replicate the observer pattern.
There are a couple of ways to do it. There is a way suggested by Google in their codelabs, but for me, honestly, it looks like a hassle. If you want to go that route, that logic can be simplified and made more elegant. That would be just like using fakes with a state. But we already did that, so let's take a different approach and use mocks for this one.
The most popular mock library out there is Mockito, and you will probably use it in your tests one way or another, so let's add it to our project:
// project build.gradle
mockito_kotlin_version = '4.0.0'
// module build.gradle
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockito_kotlin_version"
Note: We are using
mockito-kotlin
instead ofmockito-core
to have some cool Kotlin methods.
Now we can start using Mockito in our tests. To create mocks by using Kotlin's mock()
function (more on that a bit later) or the equivalent Java's Mockito.mock()
, we don't need to do anything else.
However, if we want to use Mockito's annotations to initiate mocks, we need to either run the test with MockitoJUnitRunner
:
@RunWith(MockitoJUnitRunner::class)
class MyTest {
@Mock
private lateinit var mockComponent: Component
}
or add a Mockito Rule
to it:
class MyTest {
@get:Rule
val mockitoRule: MockitoRule = MockitoJUnit.rule()
@Mock
private lateinit var mockComponent: Component
}
Note: We specify the explicit type
MockitoRule
becauseMockitoJUnit.rule()
returns a platform type, and we want to make the compiler happy.
The FavoritesViewModelTest
has two dependencies. We already have a fake for DeleteActivity
, and we can easily make a fake for GetFavoriteActivities
. But since we are in the Mockito world right now, let's use mocks this time.
Mockito is quite a flexible library, and it gives us options on how we can approach things, but I find that the easiest way to create mocks in our tests is to use the mock()
method from mockito-kotlin
library:
/**
* Creates a mock for [T].
*/
inline fun <reified T : Any> mock(): T
This is a simplified Kotlin way to call Java's:
public static <T> T mock(Class<T> classToMock, MockSettings mockSettings)
Now that we know how to create a mock, let's add the mocks for our use cases to the test class:
class FavoritesViewModelTest {
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
private val mockGetFavoriteActivities: GetFavoriteActivities = mock()
private val mockDeleteActivity: DeleteActivity = mock()
}
One concern that can arise with mocks is whether or not we should reset our mocks after each test to avoid a dirty state. The answer is - folk at JUnit
made sure that this is not an issue. JUnit
will create a new test class instance each time it runs a test. You can read about it in this StackOverlow thread. It can also be easily tested by setting the state of a mock in one test and trying to access it from another. Spoiler alert: it will not work.
With those worries out of the way, let's write our test and arrange some data. First, we need a LiveData
object that our mock will return and a list we expect to observe.
@Test
fun `the view model maps list of activities to list ui state`() {
// Arrange
val liveDataToReturn = MutableLiveData<List<Activity>>()
.apply { value = listOf(activity1, activity2) }
val expectedList = listOf(activity1, activity2)
}
Note: We should not feed
expectedList
directly into ourLiveData
because expected and actual values will be the same reference, which can muddy the test result in some cases.
To set up our mock, we will use Mockito's:
inline fun <T> whenever(methodCall: T): OngoingStubbing<T>
together with
infix fun <T> OngoingStubbing<T>.doReturn(t: T): OngoingStubbing<T>
Kotlin's doReturn()
method just calls Java's thenReturn()
method. Therefore, we can use those two interchangeably.
Here is how it looks in our code:
whenever(mockGetFavoriteActivities.invoke()).doReturn(liveDataToReturn)
Note: We have to set up our mock before creating a
ViewModel
instance. Otherwise,ViewModel
'sMediatorLiveData
that does the mapping won't have any sources and will crash with aNullPointerException
.
With everything set up, we can now create an instance of our ViewModel
and the arranging part is done:
@Test
fun `the view model maps list of activities to list ui state`() {
// Arrange
val liveDataToReturn = MutableLiveData<List<Activity>>()
.apply { value = listOf(activity1, activity2) }
val expectedList = listOf(activity1, activity2)
whenever(mockGetFavoriteActivities.invoke()).doReturn(liveDataToReturn)
val viewModel = FavoritesViewModel(
mockGetFavoriteActivities,
mockDeleteActivity
)
}
Now we need an Observer
to observe our LiveData
and something that can capture the observed data and preserve it in a state for us to use in our assertions. This is similar to how Google approaches this problem in the Codelab mentioned above, but we can use an ArgumentCaptor<T>
from Mockito to do this for us.
/**
* Use it to capture argument values for further assertions.
*/
public class ArgumentCaptor<T>
We can create an Observer
mock just like we did with the use cases:
private val activityListObserver: Observer<FavoritesUiState> = mock()
However, for an ArgumentCaptor
, we need a real instance. There are two ways to do it (as far as I know). The first one is mentioned in the documentation and uses a static builder method:
public static <U, S extends U> ArgumentCaptor<U> forClass(Class<S> clazz)
Here we need to pass a Class<S>
as an argument, and in our case, we would write:
ArgumentCaptor.forClass(FavoritesUiState::class.java)
However, the second option is easier and more elegant for my taste.
We can use the @Captor
annotation to generate the captor for us:
@Captor
private lateinit var activityListCaptor: ArgumentCaptor<FavoritesUiState>
Now, since we will be using a Mockito annotation to create this mock, we will need to either run our test with the MockitoJUnitRunner
or add a MockitoRule
as explained above.
I will go with the MockitoJUnitRunner
:
@RunWith(MockitoJUnitRunner::class)
class FavoritesViewModelTest {
...
}
And here are the new mocks in our file:
private val activityListObserver: Observer<FavoritesUiState> = mock()
@Captor
private lateinit var activityListCaptor: ArgumentCaptor<FavoritesUiState>
With that settled, we can now finish our test.
We need to start observing the LiveData
with our mock Observer
:
// Act
viewModel.favoriteActivityLiveData.observeForever(activityListObserver)
Note: We are using
observeForever
since we don't have aLifecycleOwner
.
Assertion part might seem a bit scary at first, but it is really straightforward if you break it down:
// Assert
verify(activityListObserver, times(1)).onChanged(activityListCaptor.capture())
assert(activityListCaptor.value is FavoritesUiState.List)
val actualList = (activityListCaptor.value as FavoritesUiState.List).activityList
assertEquals(actualList, expectedList)
We are verifying that our mock Observer
’s onChanged
method is called once, and we are using the activityListCaptor
to capture the passed value.
After that, we can access the activityListCaptor.value
, which is a FavoritesUiState
, and compare its activityList
to expectedList
.
Here is the finished test:
@Test
fun `the view model maps list of activities to list ui state`() {
// Arrange
val liveDataToReturn = MutableLiveData<List<Activity>>()
.apply { value = listOf(activity1, activity2) }
val expectedList = listOf(activity1, activity2)
whenever(mockGetFavoriteActivities.invoke()).doReturn(liveDataToReturn)
val viewModel = FavoritesViewModel(
mockGetFavoriteActivities,
mockDeleteActivity
)
// Act
viewModel.favoriteActivityLiveData.observeForever(activityListObserver)
// Assert
verify(activityListObserver, times(1)).onChanged(activityListCaptor.capture())
assert(activityListCaptor.value is FavoritesUiState.List)
val actualList = (activityListCaptor.value as FavoritesUiState.List).activityList
assertEquals(actualList, expectedList)
}
Second test scenario
The second scenario is very similar to the first one, but we will be returning an empty list from our mock's LiveData
and asserting that the FavoritesUiState
is Empty
.
There is nothing new here, so here is the finished test:
@Test
fun `the view model maps empty list of activities to empty ui state`() {
// Arrange
val liveDataToReturn = MutableLiveData<List<Activity>>()
.apply { value = listOf() }
whenever(mockGetFavoriteActivities.invoke()).doReturn(liveDataToReturn)
val viewModel = FavoritesViewModel(
mockGetFavoriteActivities,
mockDeleteActivity
)
// Act
viewModel.uiStateLiveData.observeForever(activityListObserver)
// Assert
verify(activityListObserver, times(1)).onChanged(activityListCaptor.capture())
assert(activityListCaptor.value is FavoritesUiState.Empty)
}
Third test scenario
In this scenario, we will be testing whether calling deleteActivity()
interacts with the correct use case. We have already done a similar test, so let's do this one using mocks to have both options.
The Arrange
and Act
parts in this test are quite straight forward:
@Test
fun `calling deleteActivity() interacts with the correct use case`() {
// Arrange
val viewModel = FavoritesViewModel(
mockGetFavoriteActivities,
mockDeleteActivity
)
// Act
viewModel.deleteActivity(activity1)
}
The first problem, however, is that deleteActivity()
launches a coroutine in the viewModelScope
, and we need to set the Dispatcher.Main
. But we already know how to fix that.
Let's add our CoroutineRule
to this test class:
@get:Rule
val coroutineRule = CoroutineRule()
The second problem presents itself if we try to verify that the deleteActivity()
method interacted with our mockDeleteActivity
. We have seen how to check for the interactions in the previous test, so we would write:
// Assert
verify(mockDeleteActivity, times(1)).invoke(activity1)
The problem here is that our use case's invoke()
method is suspend
, so we need a coroutine.
We have managed to avoid using suspend
functions until now, but those are pretty common in Android applications.
Since kotlinx.coroutines 1.6.0
the way to get into the coroutine world in tests is by using runTest
coroutine builder. It replaced the runBlockingTest
, which is now deprecated.
By wrapping our test with runTest
we won't have any more compilation errors, but don't forget to add runCurrent()
or advanceUntilIdle()
after calling deleteActivity()
to make sure the launched coroutine completes its job.
Note: We can also use the
yield()
suspend function when inside arunTest
block to get the same result. But its name is not as explicit asrunCurrent()
oradvanceUntilIdle()
and it requires a good understanding of how coroutines work to use comfortably. Therefore, we will not use it in the examples.
Here is the finished test:
@Test
fun `calling deleteActivity() interacts with the correct use case`() = runTest {
// Arrange
val viewModel = FavoritesViewModel(
mockGetFavoriteActivities,
mockDeleteActivity
)
// Act
viewModel.deleteActivity(activity1)
advanceUntilIdle() // works the same as runCurrent() in this case
// Assert
verify(mockDeleteActivity, times(1)).invoke(activity1)
}
Testing Room database
With ViewModel
testing out of the way, the next important area of an app to test is repositories. In our app, the ActivityRepository
just delegates data operations to its data sources, so it would make sense to test those.
That said, let's first test the ActivityLocalDataSource
and the Room database.
The recommended way to test Room is on an Android device since we need a Context
to create a database. Therefore, we will be creating our new test class in the androidTest
folder instead of the test
one as we did with previous tests.
Let's open up ActivityLocalDataSourceImpl
and create a test file for it in the androidTest
folder.
First things first, we will be testing the Room database, which is AppDatabase
in this app, as well as the ActivityDao
. And we will want to create a new database for each test and close()
it after we have done testing it. We can use methods annotated with the @Before
and @After
for that:
class ActivityLocalDataSourceImplTest {
private lateinit var activityDao: ActivityDao
private lateinit var database: AppDatabase
@Before
fun createDb() {
}
@After
fun closeDb() {
}
}
We won't be reusing this logic outside this test class, so there is no need to create a separate Rule
.
To create an instance of a Room database, we will need an application Context
.
We can get one using:
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
One thing to mention is that InstrumentationRegistry
has this written in its documentation:
/**
* It is generally not recommended for direct use by most tests.
*/
However, it is used in the auto-generated test that Android Studio kindly provides when creating a new project. Those are very conflicting messages from Google if you ask me.
Anyways, there is also another way to do it:
ApplicationProvider.getApplicationContext<Context>()
Which uses InstrumentationRegistry
under the hood, so its essentially the same thing:
public static <T extends Context> T getApplicationContext() {
return (T) InstrumentationRegistry.getInstrumentation()
.getTargetContext()
.getApplicationContext();
}
You can use whichever approach you want.
Now we can use this Context
to create an instance of a Room database. That said, we don't want to create a real database every time we run a test - that would be a bit of an overkill. Luckily, Room provides us with inMemoryDatabaseBuilder
API, which builds an in-memory version of the database.
This is all we need to set up our createDb()
method:
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.build()
activityDao = database.activityDao()
}
And we should close()
it after testing:
@After
@Throws(IOException::class)
fun closeDb() {
database.close()
}
With our test database all set up, we can start writing our tests.
Test scenarios
There are three scenarios we would like to test here:
- We can save an activity to the database;
- We can delete an activity from the database;
- We can observe the activity list
LiveData
.
First test scenario
Let's create a first test that checks if we can save an activity to the database and access it.
We will need to use runTest
since we will be using suspend
functions, but we don't need our CoroutineRule
since we are not in the UI layer anymore and won't be using Dispatchers.Main
. The TestScope
provided by runTest
will be enough. We also don't need to call runCurrent()
or advanceUntilIdle()
since we are not launching any new coroutines, and the TestScope
will wait for its direct children to complete.
Also, keep in mind that files and dependencies are not shared between test
and androidTest
. Therefore, we cannot use the test models we have set up before.
For the sake of not bloating this article even more (can it even be considered an article at this point?) let's just create a new file inside androidTest
with our test models:
// TestAndroidModels.kt
val androidActivity1 = Activity(
name = "Learn to dance",
type = Activity.Type.Recreational,
participantCount = 2,
price = 0.1f,
accessibility = 0.2f,
key = "112233",
link = "www.dance.com"
)
val androidActivity2 = Activity(
name = "Pet a dog",
type = Activity.Type.Relaxation,
participantCount = 1,
price = 0.0f,
accessibility = 0.1f,
key = "223344",
link = "www.dog.com"
)
If you want to reuse the files, which is a good idea, you can take a look at this StackOverflow question.
Also, as we have discussed previously, we will use regular method naming for androidTest
tests.
And here is the finished test:
@Test
fun canSaveActivityToTheDbAndReadIt() = runTest {
// Arrange
val activityLocalDataSource = ActivityLocalDataSourceImpl(activityDao)
// Act
activityLocalDataSource.saveActivity(androidActivity1)
// Assert
assert(activityLocalDataSource.isActivitySaved(androidActivity1.key))
}
Note: I did say that
test
andandroidTest
modules do not share dependencies, but somehowandroidTest
knows aboutorg.jetbrains.kotlinx:kotlinx-coroutines-test
. It comes fromandroidx.compose.ui:ui-test-junit4
, which is automatically added when creating a new Compose project in Android Studio.
Second test scenario
The second scenario is about deleting an activity from the database.
It is a very similar test to the previous one, so let's just get it out of the way:
@Test
fun canDeleteActivityFromTheDb() = runTest {
// Arrange
val activityLocalDataSource = ActivityLocalDataSourceImpl(activityDao)
activityLocalDataSource.saveActivity(androidActivity1)
// Act
activityLocalDataSource.deleteActivity(androidActivity1)
// Assert
assert(!activityLocalDataSource.isActivitySaved(androidActivity1.key))
}
Note:
saveActivity()
is a part ofArrange
now. We have a separate test to test that functionality.
Third test scenario
This scenario is more complicated, but it should be a breeze given everything we have learned so far.
We will be testing the LiveData
coming straight from the database.
We already know what we need to set up to test LiveData
, but first, we need to make sure that we have all required dependencies in androidTest
module.
We will be using InstantTaskExecutorRule
, so let's add:
// module build.gradle
androidTestImplementation "androidx.arch.core:core-testing:$arch_testing_version"
We will also need Mockito. However, the regular mockito-kotlin
library we used before cannot mock objects when running on an Android VM. So we need a different library to use Mockito in our Android instrumented tests - mockito-android
.
Let's add it to our dependencies:
// project build.gradle
mockito_android_version = '4.3.1'
// module build.gradle
androidTestImplementation "org.mockito:mockito-android:$mockito_android_version"
And just to be able to use Kotlin Mockito functions, let's add mockito-kotlin
as well:
androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockito_kotlin_version"
Now, with all the dependencies sorted out, we can set up our test file, just as we did in the FavoritesViewModel
:
@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class ActivityLocalDataSourceImplTest {
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
private val activityListObserver: Observer<List<Activity>> = mock()
@Captor
private lateinit var activityListCaptor: ArgumentCaptor<List<Activity>>
...
}
And we can now test our LiveData
scenario:
@Test
fun canSaveActivityToTheDbAndObserveTheLiveData() = runTest {
// Arrange
val activityLocalDataSource = ActivityLocalDataSourceImpl(activityDao)
val expectedList = listOf(androidActivity1, androidActivity2)
// Act
activityLocalDataSource.getActivityListLiveData()
.observeForever(activityListObserver)
activityLocalDataSource.saveActivity(androidActivity1)
activityLocalDataSource.saveActivity(androidActivity2)
// Assert
verify(activityListObserver, times(3)).onChanged(activityListCaptor.capture())
assertEquals(activityListCaptor.value, expectedList)
}
In this test, we start observing the database when there are no entries. Therefore, our first onChanged
event will fire with an empty list. We expect our LiveData
to be updated after every save, hence onChanged
should be triggered three times. And the final list should contain both activities.
Testing Retrofit with MockWebServer
With the local data source out of the way, it is now time to test our ActivityRemoteDataSource
.
We won't be testing Retrofit itself since we have no business testing 3rd party libraries in our code. We will, however, test that our ActivityRemoteDataSource
correctly returns expected results, depending on the server response.
For that, we need a server. And folk at square have exactly what we need - a MockWebServer library.
Unlike Room tests, these tests will not depend on the Android framework. Therefore, we will be creating them in the test
module.
Let's add a MockWebServer dependency to that:
// project build.gradle
okhttp_version = '4.9.3'
// module build.gradle
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"
Note: MockWebServer is a part of the
okhttp3
library.
After that, we can open up ActivityRemoteDataSourceImpl
class and create a new test for it in the test
module.
Now, let's add some test responses to a new file TestResponses.kt
to keep things tidy:
// TestResponses.kt
val successfulResponse = """
{
"activity": "Go to a music festival with some friends",
"type": "social",
"participants": 4,
"price": 0.4,
"link": "",
"key": "6482790",
"accessibility": 0.2
}
""".trimIndent()
val errorResponse = "I am not a json :o"
Let's also add a new Activity
model to our TestModels.kt
file, which corresponds to the successful response:
// TestModels.kt
val responseActivity = Activity(
name = "Go to a music festival with some friends",
type = Activity.Type.Social,
participantCount = 4,
price = 0.4f,
accessibility = 0.2f,
key = "6482790",
link = ""
)
Creating a MockWebServer
is very easy. We just call its constructor. But we have to set up a new instance of Retrofit. I assume that you already know how to do that, so we will not dive into that in detail.
Here is our test file after all the preparations:
@ExperimentalCoroutinesApi
class ActivityRemoteDataSourceImplTest {
private lateinit var mockWebServer: MockWebServer
private lateinit var apiClient: ActivityApiClient
private val client = OkHttpClient.Builder().build()
private val moshi: Moshi = Moshi.Builder()
.add(ActivityTypeAdapter())
.build()
@Before
fun createServer() {
mockWebServer = MockWebServer()
apiClient = Retrofit.Builder()
.baseUrl(mockWebServer.url("/")) // setting a dummy url
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi).asLenient())
.build()
.create(ActivityApiClient::class.java)
}
@After
fun shutdownServer() {
mockWebServer.shutdown()
}
}
We can test responses by creating a MockResponse
and programming it in any way we like.
After that, we can enqueue()
this response on our mockWebServer
.
And that is all we need to start testing our ActivityRemoteDataSource
.
We will have two testing scenarios:
- Successful response returns
Result.Success
with an activity; - Error response returns
Result.Error
.
First test scenario
In the first test, we will test that the correct JSON is parsed successfully into an Activity
.
We will need to create a MockResponse
that will return our prepared json body and enqueue it. Apart from that, the rest of the test should be really familiar at this point:
@Test
fun `correct response is parsed into success result`() = runTest {
// Arrange
val response = MockResponse()
.setBody(successfulResponse)
.setResponseCode(200)
mockWebServer.enqueue(response)
val activityRemoteDataSource = ActivityRemoteDataSourceImpl(apiClient)
val expectedActivity = responseActivity
// Act
val result = activityRemoteDataSource.getActivity()
// Assert
assert(result is Result.Success)
assertEquals((result as Result.Success).data, expectedActivity)
}
Second test scenario
To be honest, we can even write two tests in this scenario. One when the response is successful, but JSON is malformed. And the other when the response itself has failed.
These tests will be almost identical to the previous test. The difference is a malformed JSON and 400 response code when we want to simulate a faulty response.
Here are these two tests:
@Test
fun `malformed response returns json error result`() = runTest {
// Arrange
val response = MockResponse()
.setBody(errorResponse)
.setResponseCode(200)
mockWebServer.enqueue(response)
val activityRemoteDataSource = ActivityRemoteDataSourceImpl(apiClient)
// Act
val result = activityRemoteDataSource.getActivity()
// Assert
assert(result is Result.Error)
assert((result as Result.Error).error is JsonDataException)
}
@Test
fun `error response returns http error result`() = runTest {
// Arrange
val response = MockResponse()
.setBody(errorResponse)
.setResponseCode(400)
mockWebServer.enqueue(response)
val activityRemoteDataSource = ActivityRemoteDataSourceImpl(apiClient)
// Act
val result = activityRemoteDataSource.getActivity()
// Assert
assert(result is Result.Error)
assert((result as Result.Error).error is HttpException)
}
And that is our ActivityRemoteDataSource
fully tested. The MockWebServer
is a really valuable tool for testing these scenarios, so take advantage of that.
Testing components with injected CoroutineScope and CoroutineDispatcher
Another important testing case that often comes up in Android development is when we have a component that uses either a custom CoroutineScope
or switches to a different CoroutineDispatcher
.
CoroutineScope
s in the presentation layer (viewModelScope
and lifecycleScope
) use Dispatchec.Main
, and we can easily set our own main dispatcher with Dispatchers.setMain()
. It is enough for testing coroutines that run on those scopes.
However, if we use, for example, an application-scoped CoroutineScope
that runs on Dispatchers.Default
, we cannot just replace Dispatchers.Default
with our own TestDispatcher
.
The same applies to switching to a different CoroutineDispatcher
with the withContext()
function. For example, if we want to do some network calls on Dispatchers.IO
or heavy calculations on Dispatchers.Default
.
Note: Libraries like Retrofit and Room switch to a correct dispatcher under the hood, so there is no need to explicitly switch dispatchers for those libraries. Moreover, it can slow down the execution of your requests and queries. However, in the sample project's
ActivityRepositoryImpl
, I do switch a dispatcher so we can have this test scenario.
Now, before we continue, I want to emphasize that, like any other dependency, you should never hardcode your CoroutineScope
s and CoroutineDispatcher
s. The exception can be made for scopes using Dispathers.Main
or switching to Dispatchers.Main
since it will not hinder testing for the reasons discussed above. Still, even then it is better to keep the code clean and use dependency injection.
With that out of the way, let's open the ActivityRepositoryImpl
class. I have altered this repository a bit so it has both CoroutineScope
and CoroutineDispatcher
as dependencies, and we can see how we can test it.
When testing scenarios with multiple scopes and dispatchers, the most important thing to keep in mind is that during a test all those scopes and dispatchers must share the same instance of a TestCoroutineScheduler
.
Here is a quick overview of the dependencies between TestScope
, TestDispatcher
and TestCoroutineScheduler
:
val testScheduler: TestCoroutineScheduler = TestCoroutineScheduler()
val testDispatcher: TestDispatcher = StandardTestDispatcher(testScheduler)
val testScope: TestScope = TestScope(testDispatcher)
While passing arguments to any of these constructors is optional, the main takeaway here is that you can pass a TestCoroutineScheduler
to a TestDispatcher
, and you can pass a TestDispatcher
to a TestScope
.
That said, given the flexibility of the coroutines API, there are many different ways to make all of our test coroutines run on the same TestCoroutineScheduler
.
For example, we can create our own set of these objects as we did in the example above and pass them to our components as needed, while also passing either the TestCoroutineScheduler
or the TestDispatcher
to the runTest
coroutine builder (won't work with TestScope
since CoroutineScope
is not a CoroutineContext.Element
) if needed:
@Test
fun `my test` = runTest(testDispatcher) {
...
}
But in most cases, it is enough to use the TestScope
provided by the runTest
coroutine builder, with all its underlying elements.
If we look at the ActivityRepositoryImpl
constructor, we need both a CoroutineScope
and a CoroutineDispatcher
.
Getting a TestScope
is quite easy, since the receiver of the runTest
coroutine builder is a TestScope
that is automatically created for us:
@Test
fun `my test`() = runTest {
val activityRepository = ActivityRepositoryImpl(
appScope = this,
....
)
}
Passing the CoroutineDispatcher
to our repository is a bit trickier since we don't have access to it. However, we do have access to the CoroutineContext
. So if you are familiar with how things work in the coroutine world, getting the TestDispatcher
is quite straightforward:
@Test
fun `my test`() = runTest {
val testDispatcher = coroutineContext[ContinuationInterceptor] as TestDispatcher
val activityRepository = ActivityRepositoryImpl(
appScope = this,
ioDispatcher = testDispatcher,
....
)
}
If that line hurts your brain and you have no idea how it works, there are two options.
If you are curious and want to understand what that syntax does, I can refer you to my article about the CoroutineContext
.
However, if you think that this is ugly and understandably don't want to see such code again, there is another way we can provide a TestDispatcher
to our repository.
As you remember, we have mentioned that the most important part of coroutine testing is the TestCoroutineScheduler
. Luckily, a TestScope
exposes its scheduler for us to use as we please.
That said, we can just create a new StandardTestDispatcher
using the provided TestCoroutineScheduler
, which will work just as fine:
@Test
fun `my test`() = runTest {
val activityRepository = ActivityRepositoryImpl(
appScope = this,
ioDispatcher = StandardTestDispatcher(testScheduler),
...
)
}
Note:
testScheduler
is a property of theTestScope
, which is a receiver forrunTest
.
Test scenarios
Now that we know how to resolve the dependencies, let's write some tests for the following scenarios:
- Calling
getNewActivity()
returns a result after switching the context; - Calling
getNewActivityInANewCoroutine
calls remote data source in a separate coroutine.
We can go either with fakes or mocks for these tests, but I prefer creating fakes to have them available and not be too dependent on mocks.
Here are the fakes that I will be using:
class FakeActivityLocalDataSource : ActivityLocalDataSource {
override suspend fun saveActivity(activity: Activity) {
// Save
}
override suspend fun deleteActivity(activity: Activity) {
// Delete
}
override suspend fun isActivitySaved(key: String): Boolean {
return false
}
override fun getActivityListLiveData(): LiveData<List<Activity>> {
return MutableLiveData()
}
}
class FakeActivityRemoteDataSource : ActivityRemoteDataSource {
var getActivityWasCalled = false
private set
override suspend fun getActivity(): Result<Activity> {
getActivityWasCalled = true
return Result.Success(activity1)
}
}
These fakes are nothing special. We have already covered all the concepts.
First test scenario
Now that we have done all the preparations, writing the first test should be really easy:
@Test
fun `getNewActivity() returns a result after switching the context`() = runTest {
// Arrange
val activityRepository = ActivityRepositoryImpl(
appScope = this,
ioDispatcher = StandardTestDispatcher(testScheduler),
remoteDataSource = FakeActivityRemoteDataSource(),
localDataSource = FakeActivityLocalDataSource()
)
val expectedActivity = activity1
// Act
val result = activityRepository.getNewActivity()
// Assert
assert(result is Result.Success)
assertEquals((result as Result.Success).data, expectedActivity)
}
Second test scenario
The second scenario is a bit more tricky, but only because I threw in a delay(1000)
into the coroutine to illustrate an important concept. Without it, it would be the same test scenario as others where we launch a new coroutine. We would call activityRepository.getNewActivityInANewCoroutine()
and either runCurrent()
or advanceUntilIdle()
immediately after.
However, when we have a delay()
, the runCurrent()
method will no longer work.
Physically, a runTest
block skips all time delays to execute tests immediately in real-time. However, a TestCoroutineScheduler
still keeps track of the virtual time, allowing us to test time-sensitive scenarios.
And that is where an important distinction between runCurrent()
and advanceUntilIdle()
comes in:
/**
* Runs the tasks that are scheduled to execute at this moment of virtual time.
*/
@ExperimentalCoroutinesApi
public fun runCurrent()
The runCurrent()
function runs all the current tasks without advancing the virtual time.
/**
* Runs the enqueued tasks in the specified order,
* advancing the virtual time as needed until there are no more
* tasks associated with the dispatchers linked to this scheduler.
*/
@ExperimentalCoroutinesApi
public fun advanceUntilIdle() {
While the advanceUntilIdle
function, as its name implies, will advance the virtual time until there are no more tasks.
Note: You can use the
advanceTimeBy(delayTimeMillis: Long)
function to test time-sensitive scenarios.
With that information in mind, we can now finish writing our test:
@Test
fun `getNewActivityInANewCoroutine correctly calls remote data source`() = runTest {
// Arrange
val fakeRemoteRepository = FakeActivityRemoteDataSource()
val activityRepository = ActivityRepositoryImpl(
appScope = this,
ioDispatcher = StandardTestDispatcher(testScheduler),
remoteDataSource = fakeRemoteRepository,
localDataSource = FakeActivityLocalDataSource()
)
// Act
activityRepository.getNewActivityInANewCoroutine()
advanceUntilIdle()
// Assert
assert(fakeRemoteRepository.getActivityWasCalled)
}
Wrapping up Unit tests
We have covered most of the topics that will allow you to write any Unit test from now on.
There are still some untested components left in this project, but we will not explicitly cover them in this article since they will be very similar and repetitive. That said, I will include as many tests as it makes sense to write in the finish
branch of the project. So you can check that out for further reference.
Writing Integration tests
I feel like there is often confusion about what integration tests are. I have seen people confusing them with Android instrumented tests and, if my memory serves me right, it was even mentioned somewhere in Google documentation that integration tests should be run on a real or emulated device.
To clear the air before we move on, integration tests (again, it's in the name!) are testing the integration between two or more components of an application. And that is their main difference from unit tests, which test some local functionality of a component while replacing all dependencies with mocks and fakes. Depending on the test scenario, we can run them either locally or on a device. That said, it can even be argued that if we are testing how our subject under test is interacting with the mocks, it is a small integration test of sorts.
But let's not get bogged down too much with the semantics.
They are not as important as a properly tested application.
Testing how it all works together
The information we have already discussed while writing unit tests is more than enough to write almost any test. So in this section, we will use everything we have learned so far and write a test that will test everything from a view model to a data source.
We will be testing that when the loadNewActivity()
method is called, the ActivityApiClient
appropriately calls its getActivity()
method.
The approach here is the same as with the Unit tests we have been writing all this time. The only difference is that most of our dependencies will be real implementations.
That said, we will mock the ActivityApiClient
since we already have dedicated tests with MockWebServer for that.
Let's create a new test for NewActivityViewModel
in the test
folder and call it NewActivityViewModelIntegrationTest
to distinguish it from the other test class.
Next, we will create instances of real implementations all the way from the presentation layer to the framework layer, with mocks for ActivityApiClient
and ActivityDao
:
@ExperimentalCoroutinesApi
class NewActivityViewModelIntegrationTest {
private val testScheduler = TestCoroutineScheduler()
private val testDispatcher = StandardTestDispatcher(testScheduler)
private val testScope = TestScope(testDispatcher)
@get:Rule
val coroutineRule = CoroutineRule(testDispatcher)
private val mockApiClient: ActivityApiClient = mock()
private val mockActivityDao: ActivityDao = mock()
private val remoteDataSource = ActivityRemoteDataSourceImpl(mockApiClient)
private val localDataSource = ActivityLocalDataSourceImpl(mockActivityDao)
private val activityRepository = ActivityRepositoryImpl(
appScope = testScope,
ioDispatcher = testDispatcher,
remoteDataSource = remoteDataSource,
localDataSource = localDataSource
)
private val getRandomActivity = GetRandomActivityImpl(activityRepository)
private val saveActivity = SaveActivityImpl(activityRepository)
private val deleteActivity = DeleteActivityImpl(activityRepository)
private val isActivitySaved = IsActivitySavedImpl(activityRepository)
}
To illustrate other options (and avoid creating all the dependencies from scratch for every test), we are creating all the dependencies as properties of our test class. Because of that, we have created our own TestScope
and TestDispatcher
to pass into the ApplicationRepository
since we are not inside a runTest
block anymore. That is also a valid approach for providing scopes and dispatchers to components.
However, the most important thing to mention is that we have to pass our newly created testDispatcher
to the CoroutineRule
. Otherwise, we will have multiple schedulers, and the tests will fail:
@get:Rule
val coroutineRule = CoroutineRule(testDispatcher)
With all the dependencies set up, writing the test itself can't be any easier:
@Test
fun `calling loadNewActivity() triggers the api client`() = runTest {
// Arrange
val viewModel = NewActivityViewModel(
getRandomActivity,
saveActivity,
deleteActivity,
isActivitySaved
)
// Act
viewModel.loadNewActivity()
runCurrent()
// Assert
verify(mockApiClient, times(1)).getActivity()
}
Now that you know what integration tests are and how to write them, you can write as many as you want for your application. We will not write any more integration tests in this article since they will be repetitive. Still, you can check the finish
branch for more examples.
Writing Jetpack Compose UI tests
When I started writing this article, I had no idea how enormous it would become. I would have probably been too lazy to write it if I knew.
That said, we are on the final stretch, so buckle up.
In this section, we will look into how we can write UI tests for Jetpack Compose.
The approach to writing tests for composables is the same as for regular tests. We can write small unit tests, testing composables in isolation, or medium and large integration tests, testing whole screens of the application.
As before, we will start small and cover all the basics so you can comfortably write Compose UI tests on your own.
Before we start, we will need to set up the dependencies.
Android Studio automatically includes the androidx.compose.ui:ui-test-junit4
library in the Compose projects, so the only one we need to add to be able to run Compose tests is:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")
Now, let's open up NewActivityScreen.kt
and generate a test for it inside the androidTest
folder.
One thing we need to do before we can start writing tests is to add a ComposeTestRule
. It allows us to set composable content on the screen of our test device.
For testing composables in isolation, we can create ComposeTestRule
using createComposeRule()
function:
class NewActivityScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
}
Note: We needed the
ui-test-manifest
dependency for this rule to work properly.
We will look at the scenario of how to test a whole Activity
(again, the Android one) later on.
Now that we have our ComposeTestRule
let's create a simple test that displays a NewActivityCard
and asserts that the title is displayed correctly. Luckily, we already have some test Activity
models available in the androidTest
folder from testing the database.
To start displaying composables on the device we can use:
composeTestRule.setContent {
// Compose world
}
So let's compose the NewActivityCard
in our test:
@Test
fun activityNameDisplayedOnACard() {
composeTestRule.setContent {
NewActivityCard(
modifier = Modifier.fillMaxWidth(),
activity = androidActivity1,
isFavorite = false,
onFavoriteClick = { },
onLinkClick = { }
)
}
}
This test will now display a NewActivityCard
on the screen.
We can also use the ComposeTestRule
to make assertions about our composables. But before we do that, we need to understand the concept of semantics in Compose.
Semantics in Compose
In Compose, there is a Composition - a tree-like structure that consists of composables that describe the UI. Parallel to that, there is another tree, called the Semantics tree. It describes the UI in an alternative manner. It gives the UI components semantic meaning that makes UI understandable for accessibility features and testing framework.
Another way to put it is that the Composition contains the information on how to draw the UI components, while the semantics tree contains the information about what those components mean.
When testing Jetpack Compose UI, we will be using the Semantics tree to look up components on the screen and make assertions about them. Therefore, it can be beneficial to visualize how the semantic tree looks for the given UI.
We can do it in tests with the help of ComposeTestRule
:
composeTestRule.onRoot().printToLog(tag = "SEMANTICS TREE")
Note:
SEMANTICS TREE
is just a tag, not a requirement.
It will print all the information about semantic nodes to the debug level logs.
Here is an example of how the semantics tree looks for our NewActivityCard
:
Printing with useUnmergedTree = 'false'
Node #1 at (l=0.0, t=66.0, r=1080.0, b=878.0)px
|-Node #2 at (l=0.0, t=66.0, r=1080.0, b=878.0)px
|-Node #3 at (l=66.0, t=128.0, r=277.0, b=180.0)px
| Text = '[Recreational]'
| Actions = [GetTextLayoutResult]
|-Node #4 at (l=937.0, t=121.0, r=1003.0, b=187.0)px
| Role = 'Button'
| Focused = 'false'
| ContentDescription = '[Save Activity]'
| Actions = [OnClick]
| MergeDescendants = 'true'
|-Node #7 at (l=362.0, t=312.0, r=718.0, b=386.0)px
| Text = '[Learn to dance]'
| Actions = [GetTextLayoutResult]
...
As you can see, we can easily understand what individual UI components do.
For example:
|-Node #4 at (l=937.0, t=121.0, r=1003.0, b=187.0)px
| Role = 'Button'
| Focused = 'false'
| ContentDescription = '[Save Activity]'
| Actions = [OnClick]
| MergeDescendants = 'true'
We have a button here, which we can click on and save our activity.
Note: If we pass
isFavorite = true
to our card, the ContentDescription will be[Delete Activity]
.
To use the semantics tree in our tests, ComposeTestRule
has a series of finder onNode()
methods that return a SemanticsNodeInteraction
:
/**
* Represents a semantics node and the path to fetch it
* from the semantics tree. One can interact with this node
* by performing actions such as [performClick], assertions
* such as [assertHasClickAction], or navigate to other nodes
* such as [onChildren].
*/
class SemanticsNodeInteraction
Here are the examples of assertions we can make on a SemanticsNodeInteraction
:
And here are the actions we can perform:
To have this information readily available, here is an awesome Compose testing cheat sheet, prepared by Google.
Now that we understand how to use the semantics tree to write Compose UI tests let's return to our test.
Testing Composables in isolation
Testing nodes with text
With our first test, we want to assert that the activity name is displayed correctly on the card.
For finding a node with a static text we can use ComposeTestRule
's onNodeWithText()
method. After that we will call assertIsDisplayed()
on it.
Here is the final test:
@Test
fun activityNameDisplayedOnACard() {
composeTestRule.setContent {
NewActivityCard(
modifier = Modifier.fillMaxWidth(),
activity = androidActivity1,
isFavorite = false,
onFavoriteClick = { },
onLinkClick = { }
)
}
composeTestRule.onNodeWithText(androidActivity1.name)
.assertIsDisplayed()
}
Testing nodes with content description
However, not all UI components will have text on them, so we need other means to access their SemanticsNodeInteraction
.
One such example is the favorite button, which allows the user to save or delete an activity.
Let's test that this button triggers the onFavoriteClick
function.
As we have discussed previously, for interaction with components, especially lambdas, the easiest route is to use Mockito. So let's use it.
This button doesn't have any text, but it does have a content description. That is another way how we can access a node - with ComposeTestRule
's onNodeWithContentDescription()
method.
The content descriptions are stored in the strings.xml
file, so we will need a Context
to access them. However, we already know how to get a Context
in instrumented tests from the previous examples.
With that in mind, here is our final test:
@Test
fun onFavoriteClickCallbackIsTriggered() {
val onFavoriteClick: (isFavorite: Boolean) -> Unit = mock()
val isFavorite = false
composeTestRule.setContent {
NewActivityCard(
modifier = Modifier.fillMaxWidth(),
activity = androidActivity1,
isFavorite = isFavorite,
onFavoriteClick = onFavoriteClick,
onLinkClick = { }
)
}
val contentDescription = ApplicationProvider.getApplicationContext<Context>()
.getString(R.string.cd_save_activity)
composeTestRule.onNodeWithContentDescription(contentDescription)
.performClick()
verify(onFavoriteClick, times(1)).invoke(!isFavorite)
}
Testing nodes with tags
There is a third option if we can't find our UI components using text or content description.
We can manually add a testTag
to our component to find it later. Some people might have a knee-jerk reaction to the notion that we are changing production code to make our tests run, and I would agree with that sentiment.
However, in Jetpack Compose, we sometimes don't have other options, and the toolkit embraces this practice. That's why the ComposeTestRule
has a method onNodeWithTag(testTag: String)
.
In this test, we will test if clicking a link on the activity card triggers the onLinkClick
callback. We obviously can use both text and content description to find this node, but let's pretend we can't and use a testTag
to find this node in the semantics tree.
First, let's add a Tags
object file to our project (not the androidTest
module). And add a tag for our link button:
object Tags {
const val ActivityLink = "tag_activity_link"
}
Note: I am using the Pascal case naming convention for constants since it is used throughout the Compose framework.
Now let's open the NewActivityScreen.kt
file and find our TextButton
. It should be somewhere around line 138.
To add a test tag to a composable, we need to use Modifier
's testTag()
method:
TextButton(
modifier = Modifier
.align(alignment = Alignment.CenterHorizontally)
.testTag(Tags.ActivityLink),
onClick = {
onLinkClick(activity.link)
}
) {
...
}
And that is it. We can now find this composable as a node in the semantics tree using this tag and use it in our test. All the other concepts should already be familiar.
Here is the finished test:
@Test
fun onLinkClickCallbackIsTriggered() {
val onLinkClick: (link: String) -> Unit = mock()
composeTestRule.setContent {
NewActivityCard(
modifier = Modifier.fillMaxWidth(),
activity = androidActivity1,
isFavorite = false,
onFavoriteClick = { },
onLinkClick = onLinkClick
)
}
composeTestRule.onNodeWithTag(Tags.ActivityLink)
.performClick()
verify(onLinkClick, times(1)).invoke(androidActivity1.link)
}
Large UI Compose tests with Hilt
To finish this article with a bang, we will write a large UI test for the whole application. But there is one more crucial part of Android testing that we haven't yet discussed.
Testing dependency injection with Hilt.
Luckily, we can do that in this test and end on a double bang instead.
Here are the steps that we will replicate with this UI test:
- Open the app and wait for an activity to load;
- Click refresh and wait for a new activity to load;
- Click save to favorites;
- Go to the Favorites screen and ensure that the saved activity is in the list;
- Click delete and ensure that the activity is no longer in the list;
- Assert that the "no saved activities" info message is displayed.
Moreover, our test will use Hilt to inject dependencies. We will swap the ActivityApiClient
for a test ApiClient
using the MockWebServer, and the Room database for an in-memory instance.
Setting up Hilt testing
Let's start by adding Hilt testing dependencies to our project:
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
Now, let's create a new test for BoredomBusterApp.kt
Composable.
First, we have to annotate this test with @HiltAndroidTest
annotation, which will generate the Hilt components. Second, we need to add a HiltAndroidRule
and pass our test instance to it:
@HiltAndroidTest
class BoredomBusterAppTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
}
With that out of the way, to use Hilt in tests, we need an Application
class that supports Hilt. Since our app doesn't need anything from our own Application
class, we can use HiltTestApplication
provided by hilt-android-testing
library. However, if you need to use a custom application class, there is a solution in the official documentation.
The next step is to set up a custom runner, which will set up this application for tests. It sounds scary, but it really isn't.
Let's create a new di
package inside the androidTest
module and add a HiltTestRunner
class to it:
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
classLoader: ClassLoader?,
name: String?,
context: Context?
): Application {
return super.newApplication(
classLoader,
HiltTestApplication::class.java.name,
context
)
}
}
Now all we have to do is to open the module-level build.gradle
file and replace the existing testInstrumentationRunner
inside defaultConfig
with our new one:
testInstrumentationRunner "eu.maxkim.boredombuster.di.HiltTestRunner"
Replacing Hilt modules for tests
We have two options if we want to replace the production Hilt bindings with test ones. We can either replace the whole Hilt module containing the bindings or bind fake components for each test individually.
We will discuss the first scenario. However, if you only want to swap bindings for individual tests, it is quite simple to do. For that, refer to the Hilt testing documentation. But keep in mind that the second approach can noticeably slow down the execution of your tests.
We will start by replacing the DbModule
containing AppDatabase
and ActivityDao
bindings.
Let's create a new object TestDbModule
inside the di
package we have just created and annotate it with @Module
.
Next, we have to specify in which component to install this module, just like we do with @InstallIn
in production modules, and specify which module to replace. We can achieve both these goals with the @TestInstallIn
annotation, which we can use as follows:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DbModule::class]
)
object TestDbModule {
}
Apart from that, it is just a regular Hilt module, and we can just provide our dependencies. We have already looked at how to create an in-memory Room database, so here is the final code:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DbModule::class]
)
object TestDbModule {
@Provides
@Singleton
fun provideRoom(@ApplicationContext context: Context): AppDatabase {
return Room.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java
).build()
}
@Provides
@Singleton
fun provideActivityDao(appDatabase: AppDatabase): ActivityDao {
return appDatabase.activityDao()
}
}
Next, we will replace the NetworkModule
. While everything stays the same as when we used the MockWebServer
for testing ActivityRemoteDataSourceImpl
, we have to make some additional considerations since we will be running our UI test on a real Android system.
Before we start, we need to add the MockWebServer
library as a dependency to androidTest
module:
androidTestImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"
However, there is one problem. When running a MockWebServer
in the instrumented tests, this newer version will conflict with Retrofit's older okhttp3
version. I am a bit puzzled why this wasn't an issue when running local tests, but I will think about it some other day.
That said, the easiest way to resolve this problem is to force a newer version of the okhttp
library by adding a resolution strategy to the end of the module-level build.gradle
file:
dependencies {
...
}
configurations.all {
resolutionStrategy {
force "com.squareup.okhttp3:okhttp:$okhttp_version"
}
}
With that out of the way, the next issue is that the mockWebServer.url("/")
method that we used for passing a base URL to the Retrofit is considered a network operation. And as you most certainly are aware, Android restricts using network operations on the main thread.
Switching threads in a Hilt modules is not a trivial matter, so I used the solution provided in this medium article:
@Provides
@Singleton
@BaseUrl
fun provideBaseUrl(mockWebServer:MockWebServer): String {
var url = ""
val thread = Thread {
url = mockWebServer.url("/").toString()
}
thread.start()
thread.join()
return url
}
Now, let's finish our TestNetworkModule
, which replaces the NetworkModule
and provides an ActivityApiClient
that uses the MockWebServer
.
Here is the final code:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [NetworkModule::class]
)
object TestNetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder().build()
}
@Provides
@Singleton
fun provideMockServer(): MockWebServer {
return MockWebServer()
}
/**
* We need to jump through the hoops a bit
* to avoid NetworkOnMainThread exception
* in our UI tests.
*/
@Provides
@Singleton
@BaseUrl
fun provideBaseUrl(mockWebServer:MockWebServer): String {
var url = ""
val thread = Thread {
url = mockWebServer.url("/").toString()
}
thread.start()
thread.join()
return url
}
@Provides
@Singleton
fun provideMoshi(): Moshi {
return Moshi.Builder().add(ActivityTypeAdapter()).build()
}
@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient,
moshi: Moshi,
@BaseUrl baseUrl: String
): Retrofit {
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi).asLenient())
.build()
}
@Provides
@Singleton
fun provideApiClient(retrofit: Retrofit): ActivityApiClient {
return retrofit.create(ActivityApiClient::class.java)
}
}
However, we are not done with network issues in tests yet. But this will be the last one, I promise.
Now the problem is that we are not using any encryption for our MockWebServer
, and starting with Android 9, the cleartext communication is disabled by default for network security reasons.
Since we don't need this security level for debug builds that UI test use, the easiest fix is to enable cleartext traffic for debug builds while leaving it off for production builds.
To achieve that, we need to create a new /debug
folder inside the /src
and add a new debug AndroidManifest.xml
file, which will be merged with the real one for debug builds.
We only need to change one flag in the debug manifest:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="eu.maxkim.boredombuster">
<application android:usesCleartextTraffic="true"
tools:ignore="MissingApplicationIcon,UnusedAttribute" />
</manifest>
We are now ready to use our test Hilt bindings in UI tests. Hilt will automatically inject them as dependencies where needed.
And if we want to inject a component directly into the test, it works just like in Android components. We can use @Inject
annotation with a lateinit
property.
However, to populate those properties, we need to call hiltRule.inject()
.
Let's add mockWebServer
and database
instances to our test to see how it works, and clean them up after testing for good measure:
@HiltAndroidTest
class BoredomBusterAppTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var mockWebServer: MockWebServer
@Inject
lateinit var database: AppDatabase
@Before
fun init() {
hiltRule.inject()
}
@After
fun tearDown() {
mockWebServer.shutdown()
database.close()
}
}
Preparing test models
As we have mentioned before, there are ways to share files between test
and androidTest
if you want to, but for now, let's create a TestAndroidResponses.kt
file in the androidTest
module and put some test responses in it:
// TestAndroidResponses.kt
val successfulAndroidResponse1 = """
{
"activity": "Go to a music festival with some friends",
"type": "social",
"participants": 4,
"price": 0.4,
"link": "",
"key": "6482790",
"accessibility": 0.2
}
""".trimIndent()
val successfulAndroidResponse2 = """
{
"activity": "Learn how to use a french press",
"type": "recreational",
"participants": 1,
"price": 0.3,
"link": "https://en.wikipedia.org/wiki/French_press",
"key": "4522866",
"accessibility": 0.3
}
""".trimIndent()
Also, let's add the parsed versions to our TestAndroidModels.kt
file:
// TestAndroidModels.kt
val responseAndroidActivity1 = Activity(
name = "Go to a music festival with some friends",
type = Activity.Type.Social,
participantCount = 4,
price = 0.4f,
accessibility = 0.2f,
key = "6482790",
link = ""
)
val responseAndroidActivity2 = Activity(
name = "Learn how to use a french press",
type = Activity.Type.Recreational,
participantCount = 1,
price = 0.3f,
accessibility = 0.3f,
key = "4522866",
link = "https://en.wikipedia.org/wiki/French_press"
)
Testing Compose Activity
With all the preparations out of the way, we can now start testing our MainActivity
.
For testing composables in isolation we were creating a ComposeTestRule
with the createComposeRule()
function.
However, we need to create an AndroidComposeTestRule
for testing Android activities. We can do it with a handy function createAndroidComposeRule()
, which takes an Activity
as a type.
We will use that to create our compose rule, but we also want to ensure that the Hilt rule is always applied first. We can do it by adding an order
argument to our Rule
s.
Here is how our rules look after setting everything up:
@HiltAndroidTest
class BoredomBusterAppTest {
@get:Rule(order = 1)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<MainActivity>()
...
}
Now we can finally write the test itself.
When writing large end-to-end UI tests, we usually still test concrete scenarios. But just to illustrate the concept, we will write a test that clicks all over the app and call it a day (finally!).
When using createAndroidComposeRule()
we don't need to call setContent()
ourselves anymore, since it will create our MainActivity
for us.
Another important concept when UI testing the whole app is that we will probably need to wait for stuff to appear.
There is a convenient ComposeTestRule
method for that, which we can use to wait until the activity card appears on the screen after loading:
fun waitUntil(timeoutMillis: Long!, condition: (() -> Boolean)?): Unit
It will just wait around (not quite, but we will discuss it later) until the given condition is satisfied. We can also specify the timeout after which the test will fail. By default, the timeout is 1000ms.
In our case, after loading a new activity, we will wait around until the node with activity's name appears on the screen. And to make things a bit cleaner, we will extract this logic in a separate method:
private fun waitUntilVisibleWithText(text: String) {
composeTestRule.waitUntil {
composeTestRule.onAllNodesWithText(text)
.fetchSemanticsNodes().size == 1
}
}
This method will just wait around until there is at least one node with the given text.
To make our test more readable, let's extract all the interactions into separate private methods, which we will also be able to use in other tests if we wish.
For example, we can create a method that clicks on a node with a content description:
private fun clickOnNodeWithContentDescription(@StringRes cdRes: Int) {
val contentDescription = ApplicationProvider.getApplicationContext<Context>()
.getString(cdRes)
composeTestRule.onNodeWithContentDescription(contentDescription)
.performClick()
}
And use it in a bunch of action methods for our test:
private fun saveAsFavorite() {
clickOnNodeWithContentDescription(R.string.cd_save_activity)
}
private fun deleteFromFavorites() {
clickOnNodeWithContentDescription(R.string.cd_delete_activity)
}
private fun refreshActivity() {
clickOnNodeWithContentDescription(R.string.cd_refresh_activity)
}
To add methods for switching between bottom navigation tabs, let's add some new tags:
object Tags {
...
const val ActivityTab = "tag_activity_tab"
const val FavoritesTab = "tag_favorites_tab"
}
And add them to the BottomNavigationBar.kt
:
listOf(
Destination.Activity,
Destination.Favorites
).forEach { destination ->
val testTag = when (destination) {
Destination.Activity -> Tags.ActivityTab
Destination.Favorites -> Tags.FavoritesTab
else -> ""
}
BottomNavigationItem(
modifier = Modifier.testTag(testTag),
...
Now we can find these nodes and click on them for navigation:
private fun navigateToFavorites() {
composeTestRule.onNodeWithTag(Tags.FavoritesTab)
.performClick()
}
private fun navigateToActivity() {
composeTestRule.onNodeWithTag(Tags.ActivityTab)
.performClick()
}
Lastly, let's add a method to enqueue an activity json on our MockWebServer
:
private fun enqueueActivityResponse(activityJson: String) {
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.addHeader("Content-Type", "application/json; charset=utf-8")
.setBody(activityJson)
)
}
Now we have everything we need to test the scenario outlined at the beginning of this section.
Here is the final test:
@Test
fun refreshingSavingAndDeletingWorksCorrectly() {
enqueueActivityResponse(successfulAndroidResponse1)
waitUntilVisibleWithText(responseAndroidActivity1.name)
enqueueActivityResponse(successfulAndroidResponse2)
refreshActivity()
waitUntilVisibleWithText(responseAndroidActivity2.name)
saveAsFavorite()
navigateToFavorites()
composeTestRule.onNodeWithText(responseAndroidActivity2.name)
.assertIsDisplayed()
deleteFromFavorites()
composeTestRule.onNodeWithText(responseAndroidActivity2.name)
.assertDoesNotExist()
val noActivitiesMessage = ApplicationProvider.getApplicationContext<Context>()
.getString(R.string.message_empty_activity_list)
composeTestRule.onNodeWithText(noActivitiesMessage)
.assertIsDisplayed()
}
Other considerations when testing Compose
Jetpack Compose is a huge toolkit, and obviously, we didn't cover every aspect of it when it comes to testing.
However, before we part our ways, here are some pointers that might be useful for testing more complex scenarios with Compose.
Using unmerged tree
Sometimes nodes merge together the semantic information about their children, for example, if two Text
composables are composed side-by-side.
First of all, remember that you can always see how your semantics tree looks for debugging:
composeTestRule.onRoot().printToLog("TAG")
Secondly, all the finder methods have a useUnmergedTree
argument, which you can use to search the unmerged version of the semantics tree:
composeTestRule.onNodeWithText("My Text", useUnmergedTree = true)
.assertIsDisplayed()
Synchronization
Just like the test coroutine builder, the Compose doesn't run in real-time during the tests and has a virtual clock.
During tests, when the UI state of a composable changes it doesn't automatically trigger recomposition as it would when running the app normally.
The advancement of virtual clock and recomposition is usually triggered by the assertion methods on a SemanticsNodeInteraction
. Likewise, methods like waitUntil
will advance the clock frame by frame if the main clock is in the autoAdvance
mode.
Speaking of which, if you would like to take manual control of the main clock, you can set autoAdvance
to false
:
composeTestRule.mainClock.autoAdvance = false
This will allow you to control the main clock manually by using methods like:
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)
The are some other nuances with synchronization in Compose, so if you are interested in this topic, please refer to the official documentation.
Custom semantic properties
You can create your own semantic properties to expose for your tests if you want. You can read about that in the official documentation.
UI tests with Compose and Views
If you have a hybrid UI app that uses both Compose and the View system, you can write tests that combine Espresso and ComposeTestRule
. No additional setup is needed. You can just use them together in your tests.
Conclusion
When I first set out to write this article, I was sure that it would take no more than a couple of evenings. However, it turns out that testing Android applications is quite a big topic, and there is a lot of ground to cover.
Hopefully, I have discussed the most common scenarios, and this information is a good start for writing all kinds of tests for your Android apps.
Once again, you can find everything that we have talked about on GitHub in the finish
branch. I will throw some more tests in there as well for reference.
If you have any questions about testing or Android development in general, or if you think I missed some crucial aspects of testing, feel free to leave your comments. I would appreciate the feedback.
See you next time.
Your friend,
Max