Kotlin UI Testing
UI testing is a crucial part of ensuring your Kotlin applications work correctly from the user's perspective. While unit tests verify individual components function correctly in isolation, UI tests validate that all parts of your application work together as expected when a user interacts with them.
What is UI Testing?
User Interface testing verifies that the graphical interface of your application behaves as expected when a user interacts with it. This includes testing:
- Visual elements appear correctly
- Navigation flows work as intended
- User inputs are handled properly
- The app responds appropriately to user interactions
Let's explore how to implement UI tests in Kotlin Android applications using popular testing frameworks.
Getting Started with UI Testing in Kotlin
Setting Up Dependencies
Before writing UI tests, you need to add the necessary dependencies to your project's build.gradle
file:
dependencies {
// Espresso for Android UI testing
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
// UI Automator for system UI testing
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
// Robolectric for unit testing UI components
testImplementation 'org.robolectric:robolectric:4.9'
}
Espresso Testing Framework
Espresso is a powerful testing framework provided by Google for Android UI testing. It's designed to make writing UI tests simple and reliable.
Basic Espresso Test Structure
An Espresso test typically follows this pattern:
- Find a view
- Perform an action on the view
- Check if the expected result appears
Let's write a simple test for a login screen:
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun loginWithValidCredentials_navigatesToMainActivity() {
// 1. Find the username and password fields and enter text
Espresso.onView(ViewMatchers.withId(R.id.username))
.perform(ViewActions.typeText("[email protected]"), ViewActions.closeSoftKeyboard())
Espresso.onView(ViewMatchers.withId(R.id.password))
.perform(ViewActions.typeText("password123"), ViewActions.closeSoftKeyboard())
// 2. Find and click the login button
Espresso.onView(ViewMatchers.withId(R.id.login_button))
.perform(ViewActions.click())
// 3. Check that the MainActivity is launched
Espresso.onView(ViewMatchers.withId(R.id.welcome_text))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
Espresso.onView(ViewMatchers.withId(R.id.welcome_text))
.check(ViewAssertions.matches(ViewMatchers.withText("Welcome, User!")))
}
}
Using Espresso Matchers
Espresso provides various matchers to find views and verify their state:
// Find a view by ID
Espresso.onView(ViewMatchers.withId(R.id.username))
// Find a view by text
Espresso.onView(ViewMatchers.withText("Login"))
// Find a view by content description
Espresso.onView(ViewMatchers.withContentDescription("Submit button"))
// Check if a view is displayed
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
// Check if a view contains specific text
.check(ViewAssertions.matches(ViewMatchers.withText("Welcome")))
// Check if a view is enabled
.check(ViewAssertions.matches(ViewMatchers.isEnabled()))
Testing RecyclerView
RecyclerView is a common component in Android apps. Here's how to test it with Espresso:
@Test
fun clickOnRecyclerViewItem_opensDetailScreen() {
// Scroll to a specific position
Espresso.onView(ViewMatchers.withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(3))
// Click on an item at position
Espresso.onView(ViewMatchers.withId(R.id.recycler_view))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(3, ViewActions.click()))
// Verify the detail screen is shown
Espresso.onView(ViewMatchers.withId(R.id.detail_title))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
}
UI Automator
UI Automator is another testing framework that allows you to test interactions between your app and the system UI. It's particularly useful when your tests need to interact with system apps or navigate outside your application.
@RunWith(AndroidJUnit4::class)
class SystemInteractionTest {
@Test
fun testShareFunction() {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
// Launch the app
val context = ApplicationProvider.getApplicationContext<Context>()
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
// Wait for the app to start
device.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 5000)
// Click share button in your app
Espresso.onView(ViewMatchers.withId(R.id.share_button))
.perform(ViewActions.click())
// Interact with system share dialog
val shareSheetTitle = device.findObject(UiSelector().text("Share via"))
assertTrue("Share sheet not displayed", shareSheetTitle.exists())
// Select an app from the share sheet (e.g., Gmail)
val gmailOption = device.findObject(UiSelector().textContains("Gmail"))
if (gmailOption.exists()) {
gmailOption.click()
// Now we're in Gmail, we can check that the share content was passed correctly
val subjectField = device.findObject(UiSelector().resourceId("com.google.android.gm:id/subject"))
assertTrue("Subject field not found in Gmail", subjectField.exists())
assertEquals("Check shared subject text", "Check out this awesome app!", subjectField.text)
}
}
}
Robolectric for UI Testing
Robolectric allows you to run UI tests on your local JVM without needing an emulator or physical device, making tests run faster.
@RunWith(RobolectricTestRunner::class)
class MainActivityRobolectricTest {
@Test
fun clickingButton_shouldChangeText() {
val activity = Robolectric.buildActivity(MainActivity::class.java).create().resume().get()
// Find views
val button = activity.findViewById<Button>(R.id.change_text_button)
val textView = activity.findViewById<TextView>(R.id.text_view)
// Verify initial state
assertEquals("Hello World!", textView.text.toString())
// Perform click
button.performClick()
// Verify text changed
assertEquals("Button clicked!", textView.text.toString())
}
}
Testing Jetpack Compose UI
For modern Kotlin applications using Jetpack Compose, we use the Compose UI testing framework:
First, add the dependency:
androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.4.3"
Then write your test:
@RunWith(AndroidJUnit4::class)
class ComposeUITest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testSimpleButtonClick() {
// Set up your composable
composeTestRule.setContent {
MyAppTheme {
MyLoginScreen(
onLoginClick = { username, password ->
// Test will check if this is called with right parameters
}
)
}
}
// Find elements and interact
composeTestRule.onNodeWithText("Username")
.performTextInput("testuser")
composeTestRule.onNodeWithText("Password")
.performTextInput("password123")
composeTestRule.onNodeWithText("Login")
.performClick()
// Verify results
composeTestRule.onNodeWithText("Welcome, testuser!")
.assertIsDisplayed()
}
}
Best Practices for UI Testing
-
Make tests independent: Each test should be able to run on its own.
-
Avoid test flakiness: UI tests can be flaky (intermittently failing). Use proper synchronization mechanisms like
IdlingResource
in Espresso. -
Use test fixtures: Prepare your app state before testing instead of navigating through the UI to reach the test point.
-
Test one thing per test: Keep tests focused on a single feature or behavior.
-
Use screen robots: Implement the Robot Pattern to create more maintainable tests:
class LoginRobot {
fun inputUsername(username: String): LoginRobot {
Espresso.onView(ViewMatchers.withId(R.id.username))
.perform(ViewActions.typeText(username), ViewActions.closeSoftKeyboard())
return this
}
fun inputPassword(password: String): LoginRobot {
Espresso.onView(ViewMatchers.withId(R.id.password))
.perform(ViewActions.typeText(password), ViewActions.closeSoftKeyboard())
return this
}
fun clickLoginButton(): MainScreenRobot {
Espresso.onView(ViewMatchers.withId(R.id.login_button))
.perform(ViewActions.click())
return MainScreenRobot()
}
infix fun verify(func: LoginVerification.() -> Unit): LoginRobot {
LoginVerification().apply(func)
return this
}
}
class LoginVerification {
fun errorMessageDisplayed(message: String) {
Espresso.onView(ViewMatchers.withId(R.id.error_message))
.check(ViewAssertions.matches(ViewMatchers.withText(message)))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
}
}
// Usage in test
@Test
fun invalidLogin_showsErrorMessage() {
LoginRobot()
.inputUsername("[email protected]")
.inputPassword("wrongpassword")
.clickLoginButton()
.verify {
errorMessageDisplayed("Invalid credentials")
}
}
Handling Asynchronous Operations
When testing UI that involves asynchronous operations, use IdlingResource to make Espresso wait:
// Create an IdlingResource implementation
class DataLoadingIdlingResource(private val activity: MainActivity) : IdlingResource {
private var callback: IdlingResource.ResourceCallback? = null
override fun getName(): String = "DataLoadingResource"
override fun isIdleNow(): Boolean {
val idle = !activity.isLoading
if (idle && callback != null) {
callback?.onTransitionToIdle()
}
return idle
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
this.callback = callback
}
}
// In your test
@Test
fun loadData_displaysCorrectly() {
val activity = activityRule.scenario.onActivity { it }
val idlingResource = DataLoadingIdlingResource(activity)
try {
IdlingRegistry.getInstance().register(idlingResource)
// Trigger loading data
Espresso.onView(ViewMatchers.withId(R.id.load_button))
.perform(ViewActions.click())
// Espresso will wait until the IdlingResource is idle
// Verify data loaded correctly
Espresso.onView(ViewMatchers.withId(R.id.data_text))
.check(ViewAssertions.matches(ViewMatchers.withText("Loaded data")))
} finally {
IdlingRegistry.getInstance().unregister(idlingResource)
}
}
Summary
UI testing is essential for ensuring your Kotlin applications provide a seamless user experience. In this guide, we've explored:
- Setting up UI testing dependencies
- Using Espresso for basic UI testing
- Testing more complex UI components like RecyclerView
- Using UI Automator for system interaction testing
- Implementing Robolectric tests for faster UI validation
- Testing Jetpack Compose UIs
- Best practices for writing maintainable UI tests
- Handling asynchronous operations in tests
By implementing comprehensive UI tests alongside unit tests and integration tests, you can build more reliable Kotlin applications that deliver a consistent user experience.
Additional Resources
- Official Espresso Documentation
- UI Automator Documentation
- Robolectric Documentation
- Jetpack Compose Testing Documentation
Exercises
-
Create a simple login screen and write Espresso tests to validate both successful and failed login attempts.
-
Implement a RecyclerView with items that navigate to a detail screen when clicked. Write tests to verify this navigation flow works correctly.
-
Create a test using UI Automator that verifies your app can share content with other applications.
-
Implement the Robot Pattern for one of your existing UI tests to make it more maintainable.
-
Create a simple Jetpack Compose UI with a counter button and write tests to verify the counter increments correctly when the button is clicked.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)