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