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

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    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

    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)
    )
    }
    }
    }





    Test




    class LoginKtTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun shouldEnableButtonOnlyWhenInputsAreValid() {

    val loginUseCase = mockk(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("1 2")
    onNodeWithTag("loginButton").assertIsNotEnabled()

    // Valid Input Validation
    onNodeWithTag("passwordInput").performTextInput("1 2345")
    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:mock ito-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():
    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.HiltTestAp plication
    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 screens: LoginScreen and ProfileScreen.
    • 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()

    @Test
    fun shouldSuccessfullyLaunchProfileScreenWithEmailPost Login() {

    with(androidComposeRule) {
    setContent { MainScreen() }

    onNodeWithTag("emailInput").performTextInput("abc@ gmail.com")
    onNodeWithTag("passwordInput").performTextInput("1 2345")
    onNodeWithTag("loginButton").performClick()

    waitUntil(2500L) {
    onAllNodesWithTag("welcomeMessageText").fetchSeman ticsNodes().isNotEmpty()
    }

    onNodeWithTag("welcomeMessageText").assertTextEqua ls("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.AndroidHiltTestRun ner"
    }
    }











    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.getIn strumentation())

    // 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.getApplicationContextContext>( )
    val intent = context.packageManager.getLaunchIntentForPackage(B ASIC_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_PA CKAGE).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("welcomeMess ageText"))
    .isEqualTo("Email as explicit argument hello@gmail.com")
    assertThat(device.textFromFieldWithId("welcomeMess ageText2"))
    .isEqualTo("Email from saved state handle hello@gmail.com")

    device.waitForIdle()
    }
    }

    Dependencies





    // To perform UI automation test.
    androidTestImplementation("androidx.test.uiautomat or: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 {
    createManagedVirtualDevice>("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.




    More...
Working...