Android Testing

Sharing code between local and instrumentation tests

Alex Zhukovich 5 min read
Sharing code between local and instrumentation tests
Table of Contents

Today, I would like to talk about sharing code between local and instrumentation test cases. We often use almost similar test data for them. It can be a factory which produces test data for us or predefined set of data. We usually split local and instrumentation test cases into different folders; it means that we should duplicate test data or factories for generating test data.

Alternatively, we can create a common test folder where we can store files, classes, functions which will be available for local and instrumentation tests.

Recently was announced that we can run Espresso tests with a Robolectric framework. However, we would like to avoid code duplication if we want to have a possibility of running the same tests cases as local and instrumentation tests. We have different folders for local and instrumentation tests, and we can create a shared folder for test cases which can be run as local or instrumentation tests.

Sharing code allows us to reduce code duplication for test data and test cases.

Sharing test data between local and instrumentation test cases

Let's take a look at different test cases which use the same data. I want to show you test cases from MapNotes app. The source code of the app you can find on GitHub.

MapNotes app

We can start with local unit test cases, which verify different that SignInPresenter works correctly.

@Test
fun `signIn with correct login and password, non-null view attached`() {
    coEvery { userRepository.signIn(any(), any()) } returns  
            Result.Success(AUTH_USER)

    presenter.onAttach(view)
    presenter.signIn(CORRECT_EMAIL, PASSWORD)

    verify { view.navigateToMapScreen() }
}

As you can see, we use prepared earlier data for verification sign in process. In this case, we use CORRECT_EMAIL, PASSWORD and AUTH_USER constants. The same set of test data we can use for verification SignIn screen with UI instrumentation test case.

@Test
fun shouldNavigationToHomeScreenAfterSuccessfulSignIn() {
    coEvery { userRepository.getCurrentUser() } returns Result.Success(AUTH_USER)
    coEvery { userRepository.signIn(CORRECT_EMAIL, PASSWORD) } returns
            Result.Success(AUTH_USER)

    onView(withId(R.id.email))
            .perform(replaceText(TestData.CORRECT_EMAIL))

    onView(withId(R.id.password))
            .perform(replaceText(PASSWORD))

    onView(withId(R.id.signIn))
            .perform(click())

    val mapVisibilityIdlingResource =
            ViewVisibilityIdlingResource(R.id.mapContainer, View.VISIBLE)
    IdlingRegistry.getInstance().register(mapVisibilityIdlingResource)

    onView(withId(R.id.mapContainer))
            .check(ViewAssertions.matches(isDisplayed()))

    IdlingRegistry.getInstance().unregister(mapVisibilityIdlingResource)
}

The main problem here is that we store the same data twice for local and instrumentation test cases.

Code duplication

As you can see, we have two almost similar files because they can have an additional set of data which is used only for some category of tests (local or instrumentation).

We can avoid file duplication with creating a shared folder for local and instrumentation test cases and store test data in this directory because after it we will have access to this data from both categories of tests.

First of all, we should go to the Project Structure view and change it to Project.

Project Structure view

After it, we can have the same structure, as we have on a disk, which helps us to configure a shared folder and see it at the beginning in Android Studio.

Afterwards, we should create a new folder; I prefer to call it "sharedTest". However, everything depends on the purposes of the folder.I'll create a "sharedTest" folder where I want to store test data and test cases for similar scenarios for local and instrumentation tests. The next step is to create a "java" folder if you use the default structure.

sharedTest folder

When it's done, we can create a package where we want to store our test data, in my case, it's a com.alexzh.mapnotes package.

sharedTest folder with package

Finally, we can move to the build.gradle. We should open this file and add a sourceSets section to android one.

android {
    ...  
    sourceSets {
        androidTest {
            java.srcDirs += "src/sharedTest/java"
        }
        test {
            java.srcDirs += "src/sharedTest/java"
        }
    }
}

After moving files to the shared folder and removing them from test and androidTest folders, we avoid duplication and all test cases work correctly.

Another approach is using factories which will generate test data for us, like TestDataFactory.

object TestDataFactory {

    fun randomString(): String {
        return UUID.randomUUID().toString()
    }

    fun randomDouble(): Double {
        return Math.random()
    }

    fun generateLat(): Double {
        val min = -85.05
        val max = 85.05
        return min + (max - min) * randomDouble()
    }

    fun generateLong(): Double {
        val min = -180.0
        val max = 180.0
        return min + (max - min) * randomDouble()
    }

    fun generateEmail(): String {
        return "${randomString()}@mail.com"
    }

    fun generateAuthUser(): AuthUser {
        return AuthUser(randomString())
    }

    fun generateNote(): Note {
        return Note(
                generateLat(),
                generateLong(),
                randomString(),
                randomString())
    }
}

Sharing test code and running the same scenarios with local and instrumentation tests

Let's start with checking dependencies which project should have for Espresso, Robolectric, Runners and extensions. I grouped all dependencies for better reading, and everything works fine with these versions of dependencies.

dependencies {
    ...
    // Robolectric
    testImplementation "org.robolectric:robolectric:$robolectric_version" // 4.1

    // Android test runner and rules
    androidTestImplementation "androidx.test:runner:$test_runner_version" // 1.1.0
    androidTestImplementation "androidx.test:core:$test_core_version" // 1.1.0
    androidTestImplementation "androidx.test.ext:junit:$test_junit_version" // 1.1.0
    androidTestImplementation "androidx.test:rules:$test_rules_version" // 1.1.0

    testImplementation "androidx.test:runner:$test_runner_version" // 1.1.0
    testImplementation "androidx.test:core:$test_core_version" // 1.1.0
    testImplementation "androidx.test.ext:junit:$test_junit_version" // 1.1.0
    testImplementation "androidx.test:rules:$test_rules_version" // 1.1.0

    // Espresso
    androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_core_version" //3.1.0
    androidTestImplementation "androidx.test.espresso:espresso-intents:$espresso_intents_version" //3.1.0

    testImplementation "androidx.test.espresso:espresso-core:$espresso_core_version" //3.1.0
    testImplementation "androidx.test.espresso:espresso-intents:$espresso_intents_version" //3.1.0
}

Afterwards, we should move the LoginActivityTest test class to sharedTest folder. After it, we will have a problem with dependencies because we are using the few modules for dependency injection. It means that we should move TestAppModule file with all dependencies and FakeMapFragment as one of the dependencies which should be available for TestAppModule. Finally, we can run our tests without any modification. Let's check these tests with local and instrumentation tests and see the time difference between them.

Finally, we can create configurations for running LoginActivityTest as local and as instrumentation tests or use gradle tasks.

Running a specific test class as instrumentation tests use gradle:

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

Example:

./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.alex.mapnotes.login.LoginActivityTest

Running a specific test class as local tests use gradle:

./gradlew :MODULE:testDebugUnitTest --tests "PACKAGE.CLASS"

Example:

./gradlew :app:testDebugUnitTest --tests "com.alex.mapnotes.login.LoginActivityTest"

I had the next results:

  • Instrumentation tests: 10s 792ms
  • Local test: 4s 669ms

To sum up, sharing code between different categories of tests helps you to reduce code duplication for test data and test in general when we use the same test cases for local and instrumentation when we want to run tests with Robolectric and Espresso.


Mobile development with Alex

A blog about Android development & testing, Best Practices, Tips and Tricks

Share
More from Mobile development with Alex
Not all UI tests are the same
Android Testing

Not all UI tests are the same

The article explores different types of UI testing, such as end-to-end testing, UI testing with fake data, pixel perfection testing, and accessibility testing. The article also includes information on how to get started with UI testing in any project.
Alex Zhukovich 8 min read
How to test Android App Shortcuts
Android Testing

How to test Android App Shortcuts

The "App Shortcuts" feature allows users to access a specific part of the application from the device's home screen. Users can see all available shortcuts by long pressing on the icon of the applications. In this article, we will learn how to test app shortcuts in Android apps.
Alex Zhukovich 5 min read
How to group Android tests
Android Testing

How to group Android tests

Nowadays, mobile apps have many screens so that the projects can have many UI tests. When a project is large, we can have hundreds or thousands of UI tests, and running them locally can be time-consuming. In this article, we will explore different ways of grouping test cases.
Alex Zhukovich 17 min read

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Mobile development with Alex.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.