DEV Community

Sridhar Subramani
Sridhar Subramani

Posted on

Getting Started with Android Testing: Building Reliable Apps with Confidence (Part 3/3)

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.

  1. Initial State Validation: The test confirms that the login button is initially disabled when no inputs are provided.
  2. 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.
  3. 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)
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 and password input fields
    • A submit button
  • Functionality:
    • Pressing the submit button navigates the user to the ProfileScreen.
    • The ProfileScreen greets the user with a message displaying their email ID.

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Dependencies

// Used to create AndroidHiltTestRunner from AndroidJUnitRunner
androidTestImplementation("androidx.test:runner:1.6.2")
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
class AndroidHiltTestRunner : AndroidJUnitRunner() {
  override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
    return super.newApplication(cl, HiltTestApplication::class.java.name, context)
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

Dependencies

// To perform UI automation test.
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
Enter fullscreen mode Exit fullscreen mode

Command

./gradlew connectedAndroidTest --continue
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Command

./gradlew testDeviceDebugAndroidTest
Enter fullscreen mode Exit fullscreen mode

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)