Learn the Fundamentals of Android Testing, One Step at a Time (Part 3/3)
Previous article
Target Audience for This Blog
This blog covers the basics of testing in Android, providing insights into setup, dependencies, and an introduction to different types of tests. It is designed to help beginners understand the fundamentals of Android testing and how various tests are implemented.
Integration testing
Integration testing typically involves testing the interactions between different components or modules of an application.
During these tests, we can visually observe the app launching, with all the interactions specified in the code happening in real time.
However, there’s an alternative approach that leverages GradleManagedDevices
to run integration tests. This method skips the UI preview and executes the tests on a configured virtual or physical device. More details on this approach are provided in the next section.
Integration Testing Frameworks
Framework | Description |
---|---|
Robolectric | To perform android UI/functional testing on JVM without the need for android device. * Test files are located inside the test folder |
AndroidX test runner | Provides AndroidJUnitRunner which is a JUnit test runner that allows to run instrumented JUnit 4 tests on Android devices, including those using the Espresso, UI Automator, and Compose testing frameworks. * Test files are located inside the androidTest folder. |
UI Automator | A UI testing framework designed for cross-app functional testing, enabling interactions with both system apps and installed apps. * Test files are located inside the androidTest folder. |
The following test cases, written for RobolectricTestRunner
and AndroidJUnitRunner
, appear similar to the Compose UI Unit Test code snippet. This is because the androidx.compose.ui.test.junit4
library provides test implementations for both JVM and Android. Using the same interfaces, tests can run on either runtime. The appropriate implementation is selected at runtime based on the configured test runner.
The androidx.compose.ui.test.junit4
module provides the ComposeTestRule
and its Android-specific implementation, AndroidComposeTestRule
. These rules allow you to set Compose content or access the activity. You can construct these rules using factory functions: createComposeRule
for general use or createAndroidComposeRule
if activity access is required.
Robolectric
In this test, we are verifying the behavior of the Login composable screen by ensuring that the login button is
enabled only when the inputs provided by the user are valid.
- Initial State Validation: The test confirms that the login button is initially disabled when no inputs are provided.
- Partial Input Validation: The test simulates entering invalid email and password combinations step-by-step to ensure that the button remains disabled until all conditions for validity are met.
- Valid Input Validation: Finally, the test validates that the login button becomes enabled only when both the email and password meet the required validation criteria (a valid email format and a password of sufficient length).
This test ensures that the Login composable correctly enforces input validation and enables the login button only under valid conditions.
System Under Test
@Composable
fun Login(onSuccess: (email: Email) -> Unit, viewModel: LoginViewModel = hiltViewModel()) {
LaunchedEffect(key1 = viewModel.loginState, block = {
if (viewModel.loginState == LoginState.LoginSuccess) onSuccess(viewModel.email)
})
Column {
Text(text = stringResource(id = R.string.login))
EmailInput(modifier = Modifier
.semantics { testTagsAsResourceId = true;testTag = "emailInput" }
.testTag("emailInput")
.fillMaxWidth(),
value = viewModel.email.value ?: "",
isEnabled = viewModel.loginState !== LoginState.InProgress,
onValueChange = viewModel::updateEmail)
PasswordInput(modifier = Modifier
.semantics { testTagsAsResourceId = true;testTag = "passwordInput" }
.fillMaxWidth(),
value = viewModel.password.value ?: "",
isEnabled = viewModel.loginState !== LoginState.InProgress,
onValueChange = viewModel::updatePassword)
if (viewModel.loginState === LoginState.LoginPending) {
PrimaryButton(modifier = Modifier
.semantics { testTagsAsResourceId = true;testTag = "loginButton" }
.fillMaxWidth(),
text = stringResource(id = R.string.login),
enabled = viewModel.isLoginButtonEnabled,
onClick = viewModel::login)
}
if (viewModel.loginState === LoginState.InProgress) {
CircularProgressIndicator(
modifier = Modifier
.semantics { testTagsAsResourceId = true;testTag = "progressLoader" }
.align(Alignment.CenterHorizontally)
)
}
}
}
Test
class LoginKtTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun shouldEnableButtonOnlyWhenInputsAreValid() {
val loginUseCase = mockk<LoginUseCaseImpl>(relaxed = true)
val loginViewModel = LoginViewModel(loginUseCase)
coEvery { loginUseCase.login(any(), any()) } returns Unit
with(composeRule) {
setContent { Login(onSuccess = {}, viewModel = loginViewModel) }
// Initial State Validation
onNodeWithTag("loginButton").assertIsNotEnabled()
// Partial Input Validation
onNodeWithTag("emailInput").performTextInput("abcd")
onNodeWithTag("loginButton").assertIsNotEnabled()
// Partial Input Validation
onNodeWithTag("emailInput").performTextInput("abcd@gmail.com")
onNodeWithTag("loginButton").assertIsNotEnabled()
// Partial Input Validation
onNodeWithTag("passwordInput").performTextInput("12")
onNodeWithTag("loginButton").assertIsNotEnabled()
// Valid Input Validation
onNodeWithTag("passwordInput").performTextInput("12345")
onNodeWithTag("loginButton").assertIsEnabled()
}
}
}
Dependencies
// Allows us to create and configure mock objects, stub methods, verify method invocations, and more
androidTestImplementation("io.mockk:mockk-agent:1.13.5")
androidTestImplementation("io.mockk:mockk-android:1.13.5")
androidTestImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
// Assertion library
androidTestImplementation("com.google.truth:truth:1.1.4")
// Needed for createComposeRule , createAndroidComposeRule and other rules used to perform UI test
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version") // used with AndroidTestRunner to run ui test on virtual/physical device.
// Required to add androidx.activity.ComponentActivity to test manifest.
// Needed for createComposeRule(), but not for createAndroidComposeRule<YourActivity>():
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")
Setup
Create a app/src/test/resources/robolectric.properties
file and define the robolectric properties.
instrumentedPackages=androidx.loader.content
application=dagger.hilt.android.testing.HiltTestApplication
sdk=29
Android JUnit test
AndroidJUnitRunner
is a test runner which lets us run the test on android virtual/physical/GradleManaged devices, including those using the Espresso
, UI Automator
, and Compose
testing frameworks.
System Under Test
We have two composable screens: LoginScreen
and ProfileScreen
, both inside MainScreen
.
- The
LoginScreen
contains:-
email
andpassword
input fields - A
submit
button
-
- Functionality:
- Pressing the
submit
button navigates the user to theProfileScreen
. - The
ProfileScreen
greets the user with a message displaying theiremail
ID.
- Pressing the
Test
@HiltAndroidTest
class MainScreenTest {
@get:Rule(order = 0)
val hiltAndroidRule = HiltAndroidRule(this)
/**
* Need a activity that annotated with @AndroidEntryPoint. and it has to be registered in manifest.
* Add comment why we used createAndroidComposeRule instead of composeTestRule
*/
@get:Rule(order = 1)
val androidComposeRule = createAndroidComposeRule<DummyTestActivity>()
@Test
fun shouldSuccessfullyLaunchProfileScreenWithEmailPostLogin() {
with(androidComposeRule) {
setContent { MainScreen() }
onNodeWithTag("emailInput").performTextInput("abc@gmail.com")
onNodeWithTag("passwordInput").performTextInput("12345")
onNodeWithTag("loginButton").performClick()
waitUntil(2500L) {
onAllNodesWithTag("welcomeMessageText").fetchSemanticsNodes().isNotEmpty()
}
onNodeWithTag("welcomeMessageText").assertTextEquals("Email as explicit argument abc@gmail.com")
onNodeWithTag("welcomeMessageText2")
.assertTextEquals("Email from saved state handle abc@gmail.com")
waitForIdle()
}
}
}
Dependencies
// Used to create AndroidHiltTestRunner from AndroidJUnitRunner
androidTestImplementation("androidx.test:runner:1.6.2")
Setup
android {
defaultConfig {
// testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// If we are using Hilt we can extend the AndroidJUnitRunner and pass the HiltTestApplication as application component.
testInstrumentationRunner = "com.gandiva.android.sample.AndroidHiltTestRunner"
}
}
class AndroidHiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
UI Automator
UI Automator is a UI testing framework designed for cross-app functional UI testing, allowing interaction with both system and installed apps. Unlike frameworks that are limited to the app under test, UI Automator provides a wide range of APIs to interact with the entire device.
This enables true cross-app functional testing, such as opening the device settings, disabling the network, and then launching your app to verify how it handles a no-network condition.
With UI Automator, you can easily locate UI components using convenient descriptors like the text displayed on the component or its content description, making test scripts more intuitive and readable.
System Under Test
We use an Android device as the system under test. The process begins by launching the home intent, bringing the device to the home screen. Once the home app is launched, we proceed to open our app for testing.
The test scenario remains the same: we navigate to the LoginScreen
, enter the email
and password
, and press the
submit button. Upon successful submission, the app navigates to the ProfileScreen
, where the user is greeted with their email
ID.
Test
private const val BASIC_SAMPLE_PACKAGE = "com.gandiva.android.sample"
private const val LAUNCH_TIMEOUT = 5000L
@HiltAndroidTest
class LoginJourneyTest {
private lateinit var device: UiDevice
@get:Rule
val hiltAndroidRule = HiltAndroidRule(this)
@Before
fun startMainActivityFromHomeScreen() {
// Initialize UiDevice instance
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
// Start from the home screen
device.pressHome()
// Wait for launcher
val launcherPackage: String = device.launcherPackageName
MatcherAssert.assertThat(launcherPackage, CoreMatchers.notNullValue())
device.wait(Until.hasObject(By.pkg(launcherPackage).depth(0)), LAUNCH_TIMEOUT)
// Launch the app
val context = ApplicationProvider.getApplicationContext<Context>()
val intent = context.packageManager.getLaunchIntentForPackage(BASIC_SAMPLE_PACKAGE)
?.apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) }
context.startActivity(intent)
// Wait for the app to appear
device.wait(Until.hasObject(By.pkg(BASIC_SAMPLE_PACKAGE).depth(0)), LAUNCH_TIMEOUT)
}
@Test
fun shouldLaunchProfileScreenWhenLoginIsSuccess() {
device.enterTextOnFieldWithId("emailInput", "hello@gmail.com")
device.enterTextOnFieldWithId("passwordInput", "123456")
device.wait(Until.hasObject(By.res("loginButton").enabled(true)), 1500L)
assertThat(device.findObject(By.res("loginButton")).isEnabled).isEqualTo(true)
device.clickFieldWithId("loginButton")
device.wait(Until.hasObject(By.res("hasObject")), 3000L)
assertThat(device.textFromFieldWithId("welcomeMessageText"))
.isEqualTo("Email as explicit argument hello@gmail.com")
assertThat(device.textFromFieldWithId("welcomeMessageText2"))
.isEqualTo("Email from saved state handle hello@gmail.com")
device.waitForIdle()
}
}
Dependencies
// To perform UI automation test.
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
Command
./gradlew connectedAndroidTest --continue
Gradle Managed Devices
Gradle Managed Devices provide a way to configure virtual or physical devices directly in Gradle for running integration tests. Since the configuration is managed within Gradle, it gains full control over the device lifecycle, allowing it to start or shut down devices as needed.
Unlike standard Android Virtual Devices (AVDs) or physical devices, there won’t be any visual preview during the test run. Once the test completes, you can review the results in the reports generated in the build folder.
Gradle Managed Devices are primarily used for running automated tests at scale on various virtual devices, so the focus is on configuration details rather than a visual representation.
Setup
testOptions {
managedDevices {
devices {
create<ManagedVirtualDevice>("testDevice") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp"
}
}
}
}
Command
./gradlew testDeviceDebugAndroidTest
Source Code
Test Your Code, Rest Your Worries
With a sturdy suite of tests as steadfast as a fortress, developers can confidently push code even on a Friday evening and log off without a trace of worry.
Top comments (0)