This article is a part of the "Efficient Android testing" series:

Today I want to talk about different types of Android test cases, examples, and how to run them from Android Studio, Gradle, and ADB commands.

Overview

In the Android world, we have different types of test cases, which allow us to test both Android-specific code and Android independent code.

Types of Android tests

There are Local and Instrumentation test cases. The main difference between these tests is that the "local" test can be executed on JVM (Java Virtual Machine) and a specific version of JVM with Android dependencies, delivered as part of the Robolectric library, which allows us to test Android-specific code. Instrumentation test cases require an emulator or device, and we can test Android-specific code with the instrumentation test cases.

The instrumentation tests are much slower than local test cases because we spend additional time installing two applications to the device and collecting data from the device to understand each test case's state. Still, we can run test cases on many devices simultaneously. The local tests are usually executed on one version of JVM with Android dependencies; this means that we cannot check how our code works on a different Android OS version.

Local tests in details

Let's talk a bit more about local tests. We can split all local test cases into UI and non-UI tests. So, we can test the UI of the application with the Robolectric framework. Still, the current version of the Robolectric framework has many limitations, and we cannot verify all screens of the application (Robolectric has issues with testing the NavigationDrawer component).

The non-UI local testing can be done with Robolectric when we have Android dependencies in our code, like Context, Activity, etc. When our code has no Android dependencies, we can test it without the Robolectric framework, and in this case, it will be much faster.

Storing local tests in the project

Every Android project already has a predefined place for local tests in the /module/src/test/ folder.

Storing local tests in the project

Running local tests

We have multiple ways of running local tests in the Andoid project. We can use any of the following options:

  • Running test cases from the "Project Tree"
  • Running test cases from the "File"
  • Running test cases from the "Edit Configuration" window
  • Running test cases using the "Gradle command"

If you are familiar with different ways of running local tests, I recommend moving to the "Local tests: Use Cases" section.

Running test cases from the "Project Tree"

We need to select a folder with the test cases which we want to execute, open the context menu, and select the “Run 'Tests in 'FOLDER''” menu item.

Running test cases from the "Project Tree"

Running test cases from the "File"

The alternative option is to run a specific test case or all tests from the opened class.

Running test cases from the "File"

Running test cases from the "Edit Configuration" window

The "Edit Configuration" window is one of the most flexible UI options for configuration test cases to be executed because we can customize modules, packages, and specific test cases. We can open this window y selecting the "Edit Configuration" submenu in the "Run" menu, or we can show this window by an opening menu of active configuration (near the "Run" icon).

Running test cases from the "Edit Configuration" window

Running test cases using the "Gradle command"

We can run all local tests from any module use the following command:

./gradlew :MODULE:test

If a module supports multiple build flavors, we can use the testVariantNameUnitTest. Imagine that we have "Dev" and "Prod" flavors and run local tests for them.

// Dev
./gradlew :MODULE:testDevDebugUnitTest

// Prod
./gradlew :MODULE:testProdDebugUnitTest

We can also execute test cases from a specific file:

./gradlew :MODULE:testDebugUnitTest --tests TestClass

In addition to it, we can run a specific test case:

./gradlew :MODULE:testDebugUnitTest --tests TestClass.specificTestCase

Use Cases

Local tests are often used for verification of the business logic of the application. I want to look at different functions that can be tested with the local tests approach without the Robolectric framework.

  • Prepare data to display in the UI layer (non-UI local tests)
  • Show error on login screen when password is empty (UI local tests)

Use Case: Prepare data to display in the UI layer

So, let's imagine that we have a function that gets data from the repository and returns an already formatted String. The prepared template for the String value is stored in the strings.xml file. Let's assume that the ViewModel has such a function.

The content of the strings.xml file:

<resources>
    <string name="total_price">Total price: € %1$.1f</string>
</resources>

The source code of ViewModel:

class BasketViewModel(
    application: Application,
    private val basketRepository: BasketRepository
) : AndroidViewModel(application) {
    private val context = getApplication<Application>().applicationContext

    private val _totalPrice = MutableLiveData<String>()
    val totalPrice: LiveData<String>
        get() = _totalPrice

    fun calculateTotalPrice() {
        val totalPrice = basketRepository.calculateTotalPrice()
        _totalPrice.value = context.getString(
            R.string.total_price, 
            totalPrice.toDouble()
        )
    }
}

As you can see, the calculateTotalPrice() function requires a Context for getting string from the strings.xml file. This means that we need a context object to test this function if we want to get a real value from the strings.xml file. However, if we want to use fake data, we can avoid the Robolectric framework in our tests.

We will use the getStatesAfter() function for getting value from the LiveData<T> object. This function uses the MockK framework.

inline fun <reified T : Any> LiveData<T>.getStatesAfter(
    func: () -> Unit
): List<T> {
    val states = mutableListOf<T>()
    val observer = mockk<Observer<T>>()
    val slot = slot<T>()

    this.observeForever(observer)

    every { observer.onChanged(capture(slot)) } answers {
        states.add(slot.captured)
    }

    func.invoke()
    return states
}

Let's start with a test case with the Robolectric framework:

@RunWith(RobolectricTestRunner::class)
class BasketViewModelTest {
    @get:Rule
    val instantExecutorRule = InstantTaskExecutorRule()

    private val basketRepository = mockk<BasketRepository>()
    private val viewModel = BasketViewModel(
        ApplicationProvider.getApplicationContext(),
        basketRepository
    )

    @Test
    fun should_postFormattedTotalPrice_when_repositoryProvidesData() {
        val totalPrice = BigDecimal(10.0)
        every { basketRepository.calculateTotalPrice() } returns totalPrice

        val state = viewModel.totalPrice.getStatesAfter {
            viewModel.calculateTotalPrice()
        }
        assertEquals(
            "Total price: € ${totalPrice.toDouble()}", 
            state.first()
        )
    }
}

If we want to avoid using context inside the ViewModel class, we can move the string formatting to the View layer. In this case, our function will post a similar value as the repository. So, let's rewrite it:

class BasketViewModel(
    private val basketRepository: BasketRepository
) : ViewModel() {
    private val _totalPrice = MutableLiveData<BigDecimal>()
    val totalPrice: LiveData<BigDecimal>
        get() = _totalPrice

    fun calculateTotalPrice() {
        _totalPrice.value = basketRepository.calculateTotalPrice()
    }
}

So, let's replace test with Robolectric framework to test without Robolectric framework

class BasketViewModelTest {
    @get:Rule
    val instantExecutorRule = InstantTaskExecutorRule()

    private val basketRepository = mockk<BasketRepository>()
    private val viewModel = BasketViewModel(basketRepository)

    @Test
    fun should_postFormattedTotalPrice_when_repositoryProvidesData() {
        val totalPrice = BigDecimal(10.0)
        every { basketRepository.calculateTotalPrice() } returns totalPrice

        val state = viewModel2.totalPrice.getStatesAfter {
            viewModel2.calculateTotalPrice()
        }
        assertEquals(totalPrice, state.first())
    }
}

In this case, we can replace the AndroidViewModel within ViewModel and move the Context from ViewModel to Activity. The refactored test case is much faster because we don't need to initialize the Robolectric framework.I executed test cases 5 times, and below, you see the average value.

  • Test case with Robolectric framework 1s 76ms
  • Test case without Robolectric framework 5s 250ms

Note: Only the first test case with the Robolectric framework is very slow because Robolectric should be initialized in the first test case.

Use Case: Show error on login screen when password is empty

Let's take a look at the local UI test case. This test's main idea is to verify that the application shows the "Password is blank" when the password filed is active and empty.

@RunWith(RobolectricTestRunner::class)
class LoginActivityTest {
    @Test
    fun should_displayEmptyPasswordError_when_passwordIsEmpty() {
        ActivityScenario.launch(LoginActivity::class.java)

        onView(withId(R.id.emailEditText))
            .perform(replaceText("test-account@alexzh.com"))

        onView(withId(R.id.passwordEditText))
            .perform(replaceText(""))

        onView(withId(R.id.passwordInputLayout))
            .check(matches(hasDescendant(withText(R.string.error_password_is_blank))))
    }
    
    ...
}

As you can see, we can use the Espresso framework and run it locally without a device and emulator. However, here we have limitations, and we cannot interact with all components and do any actions, like swipe with Espresso and Robolectric.

Instrumentation tests in details

Instrumentation tests require a device or emulator for UI and non-UI tests. They are slower than local tests, but on the other hand, we can execute them on multiple devices simultaneously. Such tests can be helpful when we have device-specific or Android OS version-specific issues in the application. In this case, we can run test cases on different devices without any changes.

The non-UI instrumentation test cases are usually used for verification of Android-specific components, like a database.

Storing instrumentation tests in the project

Every Android project already has a predefined place for instrumentation tests in the /module/src/androidTest/ folder.

Storing instrumentation tests in the project

Running instrumentation tests

I want to start with an explanation about running instrumentation tests under the hood.

Running instrumentation tests under the hood

First of all, we need to generate two applications, which will be installed on a device or emulator later on. This is the application we are developing and a test application that includes all test cases that should be executed on the device or emulator. When both apps are installed on the device, the test application will interact with the dev application via Instrumentation API, which is a part of Android ROM.

We have multiple ways of running instrumentation tests in the Android project. We can use any of the following options:

  • Running test cases from the "Project Tree"
  • Running test cases from the "File"
  • Running test cases from the "Edit Configuration" window
  • Running test cases using the "Gradle command"
  • Running test cases using the "ADB command"

If you are familiar with different ways of running local tests, I recommend moving to the "Instrumentation tests: Use Cases" section.

Running test cases from the "Project Tree"

We need to select a folder with the test cases which we want to execute, open the context menu, and select the “Run 'Tests in 'PACKAGE''” menu item.

Running test cases from the "Project Tree"

Running test cases from the "File"

The alternative option is to run a specific test case or all tests from the opened class.

Running test cases from the "File"

Running test cases from the "Edit Configuration" window

The "Edit Configuration" window is one of the most flexible UI options for configuration test cases to be executed because we can customize modules, packages, and specific test cases. We can open this window y selecting the "Edit Configuration" submenu in the "Run" menu, or we can show this window by an opening menu of active configuration (near the "Run" icon).

Running test cases from the "Edit Configuration" window

Running test cases using the "Gradle command"

We can run all Instrumentation tests from any module use the following command:

// full command
./gradlew :MODULE:connectedAndroidTest

// abbreviation
./gradlew :MODULE:cAT

If a module supports multiple build flavors, we can use the connectedVariantNameAndroidTest. Imagine that we have "Dev" and "Prod" flavors and run local tests for them.

// Dev
./gradlew :MODULE:connectedDevDebugAndroidTest

// Prod
./gradlew :MODULE:connectedProdDebugAndroidTest

We can also execute test cases from a specific file:

./gradlew :MODULE:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=PACKAGE.CLASS

In addition to it, we can run a specific test case:

./gradlew :MODULE:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=PACKAGE.CLASS.TEST

Running test cases using the "ADB command"

We can interact with an Android device or emulator using the ADB (Android Debug Bridge). As we discussed before, we need to have a "dev app" and "test app" to test them on the device.

Generate dev application:

// assembleVariantName
./gradlew :MODULE:assembleDebug

Generate test application:

// assembleVariantNameAndroidTest
./gradlew :mobile-ui:assembleDebugAndroidTest

Install dev and test applications:

// install dev app
adb install MODULE/build/outputs/apk/debug/mobile-ui-debug.apk

// install test app
adb install MODULE/build/outputs/apk/androidTest/debug/mobile-ui-debug-androidTest.apk

Run all test cases using ADB:

//  adb shell am instrument -w <test_package_name>/<runner_class>
adb shell am instrument -w PACKAGE.test/androidx.test.runner.AndroidJUnitRunner
  • PACKAGE is similar to package in AndroidManifest.xml file

Run all test cases from a specific class using ADB:

adb shell am instrument -w -e class CLASS_PATH \ PACKAGE.test/androidx.test.runner.AndroidJUnitRunner
  • CLASS_PATH is a path of the class to test
  • PACKAGE is similar to package in AndroidManifest.xml file

Use Cases

Instrumentation test cases are often used to verify interaction with Android-specific components, like databases, User Interfaces (UI), etc. Let's take a look at different use cases, which we can verify with instrumentation test cases.

  • Adding data to databases (non-UI instrumentation test)
  • Show error on login screen when password is empty (UI instrumentation test)

Note: I will use the Espresso framework for UI verification.

Use Case: Adding data to databases

I want to start with a specific SQLite database in Android. A version of the SQLite database depends on the ROM installed on the device/emulation. This means that you can have a situation in which everything is working properly on one device, but on the other one, you have a problem because of the database version. Fortunately, this happens rarely, and all essential SQL commands work well on all devices.

So, let's imagine a situation where we want to store a User infomation in the database. In this example, I will use the Room library as a layer on top of the SQLite database.

Note: In this example, we will store User information in one table for simplicity.

@RunWith(AndroidJUnit4::class)
class UserDaoTest {

    private val database: ContactsDatabase by lazy {
        val context: Context = ApplicationProvider.getApplicationContext()
        Room.databaseBuilder(context, ContactsDatabase::class.java, "TEST_DATABASE")
            .allowMainThreadQueries()
            .build()
    }

    @Test
    fun should_addUserToDatabase() {
        val user = User(
            id = 1234L,
            fullName = "Test User",
            city = "FooBar",
            postcode = "1234",
            street = "FooBarBaz",
            houseNumber = "42"
        )

        database.userDao().insert(user)

        val dbUser = database.userDao().getUser(user.id)
        assertEquals(user, dbUser)
    }

    ...
}

Note: Remember to clean up the database if you use the real database in tests.

Use Case: Show error on login screen when password is empty

Let's take a look at the instrumentation UI test case. I propose to create a similar UI test case as we created with the Robolectric framework.

This test's main idea is to verify that the application shows the "Password is blank" when the password filed is active and empty.

@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
    @Test
    fun should_displayEmptyPasswordError_when_passwordIsEmpty() {
        ActivityScenario.launch(LoginActivity::class.java)

        onView(withId(R.id.emailEditText))
            .perform(replaceText("test-account@alexzh.com"))

        onView(withId(R.id.passwordEditText))
            .perform(replaceText(""))

        onView(withId(R.id.passwordInputLayout))
            .check(matches(hasDescendant(withText(R.string.error_password_is_blank))))
    }
    
    ...
}

Note: As you can see this test case is fully similar to Robolectric test case. We can store such test case in a shared folder and have the possibility to execute them as local or instrumentation tests without code changes. Read more about this here.

Summary

So, let's summarize information about local and instrumentation test cases.

The local tests are executed on local JVM (Java Virtual Machine); this means that we don't need a specific device or emulation, but we can only verify our application on one ROM (like a particular device). These test cases are much faster than instrumentation test cases.

The instrumentation tests require a specific device or emulator, and we can execute such test cases on different devices simultaneously. They are slower than local tests because we need to install two applications on the device or emulator (the application and test application, including all test cases). In the end, we need to download test data from the device to our workstation.

I also want to mention some limitations of UI tests with the Robolectric framework. We can use basic verification, but Robolectric has a problem with gestures, navigation drawable, etc.

Resources