DEV Community

Jones Mbindyo
Jones Mbindyo

Posted on

Supercharge Your Android Apps with Effective Testing Strategies

meme break it

Welcome to the world of Android testing! In our previous journey, we built a fully functional Reminder App using Jetpack Compose. However, to ensure the completeness and quality of our application, we need to delve into the realm of Android Tests. Here, we explore the compelling benefits of incorporating tests into your app development process. Let's discover how testing can enhance the reliability, stability, and overall user experience of your Android applications.

What we will learn.

  • Enhanced Reliability: We can easily validate the functionalities of our app's UI components, ensuring that they work as intended across various scenarios and user interactions. This will help us catch potential bugs, edge cases, and regressions early in the development cycle, resulting in a more reliable and robust application.
  • Improved Stability: Android tests will give us an edge during code changes and updates. They will help us identify any unintended side effects or compatibility issues that may arise when modifying existing code or introducing new features. This will help us maintain the stability of our app and mitigate the risk of introducing regressions.
  • Enhanced User Experience: Robust Android UI testing helps ensure that our app delivers a seamless and intuitive user experience. By simulating user interactions and validating UI behavior, we can identify and fix usability issues, layout problems, and inconsistencies in the user interface. This ultimately leads to a polished and user-friendly app, enhancing overall satisfaction and engagement.

Nice to have.

  • Completion of the previous Reminder App article is recommended as we will build upon the same app in this article.
  • Proficiency in building Android apps using Kotlin.
  • Familiarity with Jetpack Compose.
  • Basic understanding of Android tests is a plus.
  • Familiarity with Git.

Let's delve into the juicy part.

In an Android project tree, the (androidTest) and (test) directories serve different purposes:
project tree

  • (androidTest): This directory is specifically designated for instrumented tests, also known as Android tests or UI tests. It contains test code that interacts with the Android framework and runs on an Android device or emulator. Instrumented tests typically simulate user interactions with the app's UI, validate UI behavior, and test functionalities that require a running Android environment. These tests can access and manipulate the app's resources, activities, and components, providing a more comprehensive evaluation of the app's behavior on a device.

  • (test): The (test) directory is used for unit tests. These tests are written in isolation and do not require an Android device or emulator to execute. They focus on testing individual units of code, such as classes, functions, or methods, in isolation from the Android framework.

    Unit tests primarily target business logic, algorithms, and non-UI components of the app. They are typically faster to execute compared to instrumented tests and provide a more targeted and efficient means of verifying the correctness of individual code units.

meme intro

Unit Tests.

  • We will start by testing our DataSource object class which holds our list of plants. Below is our test class setup:
class DataSourceModelTest : TestCase() {

    @Before
    override fun setUp() {

    }
}
Enter fullscreen mode Exit fullscreen mode
  • In the context of unit testing using a testing framework like JUnit, the @Before annotation is used to mark a method that should be executed before each individual test method in the test class.
  • To finalize our DataSourceModelTest test case, we will define our expected results and verify them using Assert.assertEquals(), which is a method used to compare two values and assert their equality.
import com.rocqjones.reminderapp.data.DataSource
import com.rocqjones.reminderapp.models.ComposeRandomItem
import junit.framework.TestCase
import org.junit.Assert
import org.junit.Before
import org.junit.Test

class DataSourceModelTest : TestCase() {

    private val actualPlants = DataSource.plants
    private lateinit var expectedPlant1 : ComposeRandomItem
    private lateinit var expectedPlant2 : ComposeRandomItem

    @Before
    override fun setUp() {
        expectedPlant1 = ComposeRandomItem(
            name = "Aloe Vera",
            schedule = "Monthly",
            type = "Succulent",
            description = "Aloe vera is a succulent plant species of the genus Aloe. It is cultivated for agricultural and medicinal uses."
        )
        expectedPlant2 = ComposeRandomItem(
            name = "Bamboo Palm",
            schedule = "Weekly",
            type = "Palm",
            description = "The Bamboo Palm, also known as the Reed Palm, is a medium-sized palm native to South America. It is a popular houseplant and can grow up to 12 feet tall."
        )
    }

    @Test
    fun testExpectedPlant1() {
        Assert.assertEquals(expectedPlant1, actualPlants[0])
    }

    @Test
    fun testExpectedPlant2() {
        Assert.assertEquals(expectedPlant2, actualPlants[1])
    }
}
Enter fullscreen mode Exit fullscreen mode
  • After running the code locally here are the expected results. test res1
  • Another way to verify the test cases of our class is through CI/CD. actions case1

To create a new branch for testing Android tests, use the command git checkout -b "testing/android-tests". Remember to commit and push your changes. As we have set branch rules, the Android CI will automatically run checks on the branch. If any issues arise, the branch won't be mergeable until we fix our errors. From our GitHub Actions you will find all failed and successful check.

Android Tests.

Tests for this section should go in the androidTest folder since they will be interacting with the Android framework.

We will start by writing Android tests for our MainActivity. To get started we will make several changes to our existing code.

  • For fun ListItems(//...) add testTag("list_items") to the modifier and also update your ListItems composable functions to assign unique test tags to each ComposeCard. Updated code should now look like.
@Composable
fun ListItems(
    modifier: Modifier = Modifier,
    data: List<ComposeRandomItem> = DataSource.plants.map { it }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp).testTag("list_items")) {
        itemsIndexed(items = data.toMutableList()) { index, n ->
            ComposeCard(
                name = n.name,
                type = n.type,
                description = n.description,
                modifier = Modifier.testTag("composeCard_$index")
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Next we modify our ComposeCard and remove the redundant opt-in annotation @OptIn(ExperimentalMaterial3Api::class).
@Composable
fun ComposeCard(name: String, type: String, description: String, modifier: Modifier) {
    val dialogState = remember { mutableStateOf(false) }

    Card(
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.primary
        ),
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp).clickable { 
            dialogState.value = true
        }
    ) {
        CardContent(name, type, description)
    }

    if (dialogState.value) {
        ReminderDialog(name = name, onDismiss = { dialogState.value = false })
    }
}
Enter fullscreen mode Exit fullscreen mode

We are now set to write our Android UI tests, we first set up our MainActivityTest class for UI testing using Jetpack Compose. It initializes the test rule, creates a setup method to prepare the test environment, and specifies the content of the ComposeTestRule to the MainActivity composable function.

@RunWith(AndroidJUnit4::class)
class MainActivityTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Before
    fun setUp() {
        // Set up your Compose UI here
        composeTestRule.setContent {
            ReminderAppTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    ListItems()
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Add the following dependencies.
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"

debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
implementation 'androidx.test.uiautomator:uiautomator:2.2.0'
Enter fullscreen mode Exit fullscreen mode
  • In this MainActivityTest class, we use the @RunWith annotation to indicate that we are using the AndroidJUnit4 test runner. The composeTestRule rule is created using createComposeRule() and allows us to write UI tests for Compose.
  • The setUp method is annotated with @Before and is run before each test case.
import android.app.NotificationManager
import android.content.Context
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import com.rocqjones.reminderapp.ui.theme.ReminderAppTheme
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.TimeUnit


@RunWith(AndroidJUnit4::class)
class MainActivityTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Before
    fun setUp() {
        // Set up your Compose UI here
        composeTestRule.setContent {
            ReminderAppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    ListItems()
                }
            }
        }
    }

    @Test
    fun testComposableList() {
        composeTestRule.onNodeWithTag("list_items")
    }

    @Test
    fun testComposableCard() {
        composeTestRule.onNodeWithTag("composeCard_0")
    }

    @Test
    fun testOpenReminderDialog() {
        composeTestRule.onNodeWithTag(
            "list_items"
        ).assertExists().onChildren()[0].assertExists().performClick()

        // Verify that the ReminderDialog is displayed
        composeTestRule.onNodeWithTag("reminderDialog").assertExists()

        val schedules = listOf(
            "5 seconds" to 5000L,
            "8 minutes" to 8 * 60 * 1000L,
            "1 day" to 24 * 60 * 60 * 1000L,
            "1 week" to 7 * 24 * 60 * 60 * 1000L
        )

        composeTestRule.onNodeWithTag("reminderDialog")
            .assertExists()

        for ((scheduleText, _) in schedules) {
            composeTestRule.onNodeWithText(scheduleText)
                .assertExists()
        }

        composeTestRule.onNodeWithText("5 seconds").assertExists().performClick()

        // Wait for 5 seconds
        Thread.sleep(TimeUnit.SECONDS.toMillis(5))

        // Check if the notification appears
        val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        val notificationExists = device.wait(
            Until.hasObject(By.pkg("com.rocqjones.reminderapp").depth(0)),
            TimeUnit.SECONDS.toMillis(10)
        )
        Assert.assertTrue("Notification should appear", notificationExists != null)

        // Click the notification
        val notification = device.findObject(By.text("Reminder App."))
        notification?.click()

        // Verify that the MainActivity is launched
        val instrumentation = InstrumentationRegistry.getInstrumentation()
        instrumentation.waitForIdleSync()
        val currentActivity = instrumentation.targetContext.packageManager.getLaunchIntentForPackage(
            instrumentation.targetContext.packageName
        )?.component?.className
        Assert.assertEquals(
            "MainActivity should be launched",
            MainActivity::class.java.name,
            currentActivity
        )

        // Clear the notification
        val notificationManager = instrumentation.targetContext.getSystemService(
            Context.NOTIFICATION_SERVICE
        ) as NotificationManager
        notificationManager.cancel(17)
    }
}
Enter fullscreen mode Exit fullscreen mode
  • The testComposableList and testComposableCard test cases serve as basic checks to ensure that the UI elements are present.
  • The testOpenReminderDialog test case is the main test case that we simulates opening the ReminderDialog and performing various assertions. It clicks on a ComposeCard to open the dialog, verifies the presence of the dialog, interacts with its options, waits for the notification to appear, checks if the notification is displayed, clicks the notification to launch the MainActivity, verifies that the MainActivity is launched, and finally clears the notification from the status bar.
  • UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) is used to obtain an instance of the UiDevice class. It allows us to simulate touch inputs and in this case accessing device information. Once we have the UiDevice instance, we can use its methods to interact with the scheduled notification.
  • Below is a screenshot of our Android UI test cases. Android UI test

Workflows References.

Conclusion.

By leveraging tests in your Android app development, you can achieve a higher level of reliability, stability, and user experience, ensuring that your app meets the expectations of your users and stands out in the competitive landscape. Feel free to customize and expand upon this code as needed.

Resources.

For more alluring content about Android Jetpack Compose follow me on Twitter and LinkedIn so that you do not miss any updates about new articles.

Top comments (0)