Max Kim
Art and science of writing good code

Art and science of writing good code

Full Guide to Testing Android Applications in 2022

Full Guide to Testing Android Applications in 2022

Max Kim's photo
Max Kim
·May 13, 2022·

66 min read

Subscribe to my newsletter and never miss my upcoming articles

Listen to this article

Table of contents

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. NewActivityViewModel_structure.png

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 this ViewModel, but for the sake of covering more testing examples, the FavoritesViewModel exposes its UI state via a LiveData.

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:

  1. When creating a NewActivityViewModel, the initial UI state is Loading;
  2. Creating a NewActivityViewModel triggers the emission of a new Success UI state;
  3. Creating a NewActivityViewModel triggers the emission of a new Error UI state in case of error;
  4. If Activity is already saved, the UI state's isFavorite is set to true;
  5. Calling loadNewActivity() successfully reloads the existing UI state with a new activity;
  6. Calling setIsFavorite(activity, true) calls SaveActivity use case;
  7. Calling setIsFavorite(activity, false) calls DeleteActivity 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.

Generate_test.png

This will open the Create Test popup:

Generate_test_popup.png

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:

choose_destination_directory.png

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 inside kotlinx-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 from JUnit.

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 the invoke() 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 Flows 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 Flows 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 to Loading and finish with another Success 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 Flows. 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:

  1. The view model exposes the list of activities from LiveData as a List UI state;
  2. The view model exposes an empty list from LiveData as an Empty UI state;
  3. 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 of mockito-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 because MockitoJUnit.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 our LiveData 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's MediatorLiveData that does the mapping won't have any sources and will crash with a NullPointerException.

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 a LifecycleOwner.

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 a runTest block to get the same result. But its name is not as explicit as runCurrent() or advanceUntilIdle() 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:

  1. We can save an activity to the database;
  2. We can delete an activity from the database;
  3. 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 and androidTest modules do not share dependencies, but somehow androidTest knows about org.jetbrains.kotlinx:kotlinx-coroutines-test. It comes from androidx.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 of Arrange 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:

  1. Successful response returns Result.Success with an activity;
  2. 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.

CoroutineScopes 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 CoroutineScopes and CoroutineDispatchers. 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 the TestScope, which is a receiver for runTest.

Test scenarios

Now that we know how to resolve the dependencies, let's write some tests for the following scenarios:

  1. Calling getNewActivity() returns a result after switching the context;
  2. 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:

semantics_assert.png

And here are the actions we can perform:

semantics_actions.png

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 Rules.

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

Did you find this article valuable?

Support Max Kim by becoming a sponsor. Any amount is appreciated!

Learn more about Hashnode Sponsors
 
Share this