This guide covers UI testing practices for iOS and Android in your project. UI tests verify the application's user interface behaves correctly from the user's perspective.
UI tests (also called instrumentation tests or UI automation tests) simulate real user interactions with your app. They:
- Test complete user flows
- Verify UI elements appear and function correctly
- Ensure proper navigation between screens
- Validate integration between components
Android UI tests use Jetpack Compose Testing APIs and are located in androidApp/src/androidTest/.
// androidApp/build.gradle.kts
dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}// androidApp/src/androidTest/.../utils/ComposeTestBase.kt
abstract class ComposeTestBase {
@get:Rule
val composeTestRule = createComposeRule()
fun assertElementExists(tag: String) {
composeTestRule.onNodeWithTag(tag).assertExists()
}
fun assertElementIsDisplayed(tag: String) {
composeTestRule.onNodeWithTag(tag).assertIsDisplayed()
}
fun clickElement(tag: String) {
composeTestRule.onNodeWithTag(tag).performClick()
}
fun waitUntilExists(tag: String, timeoutMillis: Long = 5000) {
composeTestRule.waitUntil(timeoutMillis) {
composeTestRule.onAllNodesWithTag(tag)
.fetchSemanticsNodes().isNotEmpty()
}
}
}@RunWith(AndroidJUnit4::class)
class CameraScreenTest : ComposeTestBase() {
@Test
fun cameraScreen_displaysAllRequiredElements() {
// Given: Camera screen is displayed
composeTestRule.setContent {
CameraScreen(
onNavigateBack = {},
onImageCaptured = {}
)
}
// Then: Verify UI elements
assertElementExists("camera_preview")
assertElementExists("capture_button")
assertElementExists("back_button")
// Verify text content
composeTestRule.onNodeWithText("Take Photo")
.assertExists()
.assertIsDisplayed()
}
@Test
fun captureButton_triggersImageCapture() {
var imageCaptured = false
composeTestRule.setContent {
CameraScreen(
onNavigateBack = {},
onImageCaptured = { imageCaptured = true }
)
}
// When: User clicks capture button
clickElement("capture_button")
// Then: Image capture callback is triggered
composeTestRule.waitUntil {
imageCaptured
}
assert(imageCaptured)
}
}iOS UI tests use XCUITest framework and are located in iosApp/iosAppUITests/.
// iosApp/iosAppUITests/Helpers/XCUITestBase.swift
class XCUITestBase: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
override func tearDownWithError() throws {
app = nil
}
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
element.waitForExistence(timeout: timeout)
}
func takeScreenshot(name: String) {
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}final class CameraScreenTests: XCUITestBase {
func testCameraScreen_DisplaysAllRequiredElements() {
// Given: Navigate to camera screen
let cameraCard = app.otherElements["menuSourceCard_camera"]
XCTAssertTrue(waitForElement(cameraCard))
cameraCard.tap()
// Then: Verify camera screen elements
XCTAssertTrue(app.otherElements["camera_preview"].exists)
XCTAssertTrue(app.buttons["capture_button"].exists)
XCTAssertTrue(app.buttons["back_button"].exists)
// Take screenshot for visual verification
takeScreenshot(name: "camera_screen_loaded")
}
func testCaptureButton_TriggersImageCapture() {
// Given: Navigate to camera screen
let cameraCard = app.otherElements["menuSourceCard_camera"]
cameraCard.tap()
// When: Tap capture button
let captureButton = app.buttons["capture_button"]
XCTAssertTrue(waitForElement(captureButton))
captureButton.tap()
// Then: Verify capture process
let processingIndicator = app.activityIndicators["processing_indicator"]
XCTAssertTrue(waitForElement(processingIndicator))
}
}@Composable
fun MenuCard(
title: String,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.testTag("menuCard_$title") // Unique test tag
.clickable { onClick() }
) {
Text(
text = title,
modifier = Modifier.testTag("menuCard_title_$title")
)
}
}struct MenuCard: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
VStack {
Text(title)
.accessibilityIdentifier("menuCard_title_\(title)")
}
}
.accessibilityIdentifier("menuCard_\(title)")
}
}Create page objects to encapsulate UI interactions:
class CameraScreenRobot(private val composeRule: ComposeTestRule) {
fun verifyCameraScreenDisplayed() = apply {
composeRule.onNodeWithTag("camera_screen").assertIsDisplayed()
}
fun clickCaptureButton() = apply {
composeRule.onNodeWithTag("capture_button").performClick()
}
fun verifyProcessingIndicatorShown() = apply {
composeRule.onNodeWithTag("processing_indicator").assertIsDisplayed()
}
fun waitForProcessingComplete() = apply {
composeRule.waitUntil(10000) {
composeRule.onAllNodesWithTag("processing_indicator")
.fetchSemanticsNodes().isEmpty()
}
}
}
// Usage in test
@Test
fun testCompletePhotoCapture() {
val robot = CameraScreenRobot(composeTestRule)
robot
.verifyCameraScreenDisplayed()
.clickCaptureButton()
.verifyProcessingIndicatorShown()
.waitForProcessingComplete()
}class CameraScreenPage {
let app: XCUIApplication
init(app: XCUIApplication) {
self.app = app
}
var cameraPreview: XCUIElement {
app.otherElements["camera_preview"]
}
var captureButton: XCUIElement {
app.buttons["capture_button"]
}
var processingIndicator: XCUIElement {
app.activityIndicators["processing_indicator"]
}
func verifyCameraScreenDisplayed() {
XCTAssertTrue(cameraPreview.exists)
XCTAssertTrue(captureButton.exists)
}
func capturePhoto() {
captureButton.tap()
}
func waitForProcessingComplete() {
let notExists = NSPredicate(format: "exists == false")
expectation(for: notExists, evaluatedWith: processingIndicator)
waitForExpectations(timeout: 10)
}
}
// Usage in test
func testCompletePhotoCapture() {
let cameraPage = CameraScreenPage(app: app)
cameraPage.verifyCameraScreenDisplayed()
cameraPage.capturePhoto()
cameraPage.waitForProcessingComplete()
}@Test
fun testAsyncDataLoading() {
composeTestRule.setContent {
FoodMenuScreen()
}
// Wait for loading to complete
composeTestRule.waitUntil(timeoutMillis = 10000) {
composeTestRule.onAllNodesWithTag("loading_indicator")
.fetchSemanticsNodes().isEmpty()
}
// Verify data is displayed
composeTestRule.onNodeWithText("Burger").assertIsDisplayed()
composeTestRule.onNodeWithText("$10.99").assertIsDisplayed()
}func testAsyncDataLoading() {
// Wait for loading indicator to disappear
let loadingIndicator = app.activityIndicators["loading_indicator"]
let notExists = NSPredicate(format: "exists == false")
expectation(for: notExists, evaluatedWith: loadingIndicator)
waitForExpectations(timeout: 10)
// Verify data is displayed
XCTAssertTrue(app.staticTexts["Burger"].exists)
XCTAssertTrue(app.staticTexts["$10.99"].exists)
}@Test
fun testCompleteMenuSelectionFlow() {
// Start at menu selection
composeTestRule.setContent {
YourApp()
}
// Select camera option
clickElement("menuSourceCard_camera")
waitUntilExists("camera_screen")
// Capture photo
clickElement("capture_button")
waitUntilExists("processing_screen")
// Wait for menu to load
waitUntilExists("food_menu_screen", timeoutMillis = 15000)
// Verify we reached the menu
assertElementExists("food_item_0")
composeTestRule.onNodeWithText("Swipe to rate").assertIsDisplayed()
}func testCompleteMenuSelectionFlow() {
// Select camera option
let cameraCard = app.otherElements["menuSourceCard_camera"]
XCTAssertTrue(waitForElement(cameraCard))
cameraCard.tap()
// Capture photo
let captureButton = app.buttons["capture_button"]
XCTAssertTrue(waitForElement(captureButton))
captureButton.tap()
// Wait for processing
let processingScreen = app.otherElements["processing_screen"]
XCTAssertTrue(waitForElement(processingScreen))
// Wait for menu to load
let foodMenuItem = app.otherElements["food_item_0"]
XCTAssertTrue(waitForElement(foodMenuItem, timeout: 15))
// Verify we reached the menu
XCTAssertTrue(app.staticTexts["Swipe to rate"].exists)
}# Run all UI tests
./androidApp/run_android_tests.sh
# Run specific test class
./gradlew :androidApp:connectedDebugAndroidTest --tests="*.CameraScreenTest"
# Run on specific device
./gradlew :androidApp:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.<project-name>.YourScreenTest# Run all UI tests
xcodebuild test -workspace iosApp.xcworkspace -scheme iosApp -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:iosAppUITests
# Run specific test file
xcodebuild test -workspace iosApp.xcworkspace -scheme iosApp -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:iosAppUITests/CameraScreenTests
# Run specific test method
xcodebuild test -workspace iosApp.xcworkspace -scheme iosApp -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:iosAppUITests/CameraScreenTests/testCameraScreen_DisplaysAllRequiredElementsandroidApp/src/androidTest/
├── java/com/example/<organization>/<project-name>/android/
│ ├── ui/ # UI test files
│ │ ├── CameraScreenTest.kt
│ │ ├── FoodMenuScreenTest.kt
│ │ └── NavigationTest.kt
│ ├── utils/ # Test utilities
│ │ └── ComposeTestBase.kt
│ └── robots/ # Page objects
│ ├── CameraRobot.kt
│ └── MenuRobot.kt
iosApp/iosAppUITests/
├── Screens/ # Screen-specific tests
│ ├── CameraScreenTests.swift
│ ├── FoodMenuScreenTests.swift
│ └── NavigationTests.swift
├── Helpers/ # Test utilities
│ └── XCUITestBase.swift
└── Pages/ # Page objects
├── CameraPage.swift
└── MenuPage.swift
@Test
fun testSwipeGesture() {
composeTestRule.onNodeWithTag("swipeable_card")
.performTouchInput {
swipeLeft()
}
// Verify swipe result
composeTestRule.onNodeWithText("Next Item").assertIsDisplayed()
}func testSwipeGesture() {
let swipeableCard = app.otherElements["swipeable_card"]
swipeableCard.swipeLeft()
// Verify swipe result
XCTAssertTrue(app.staticTexts["Next Item"].exists)
}@Test
fun testTextInput() {
composeTestRule.onNodeWithTag("search_field")
.performTextInput("Burger")
// Verify filtered results
composeTestRule.onNodeWithText("Burger").assertIsDisplayed()
composeTestRule.onNodeWithText("Pizza").assertDoesNotExist()
}func testTextInput() {
let searchField = app.textFields["search_field"]
searchField.tap()
searchField.typeText("Burger")
// Verify filtered results
XCTAssertTrue(app.staticTexts["Burger"].exists)
XCTAssertFalse(app.staticTexts["Pizza"].exists)
}@Test
fun testScreenshotComparison() {
composeTestRule.setContent {
FoodItemCard(
foodItem = FoodItem("Burger", 10.99, "Delicious beef burger"),
onSwipe = {}
)
}
// Capture screenshot for manual verification
composeTestRule.onRoot().captureToImage()
}func testScreenshotComparison() {
// Navigate to screen
navigateToFoodMenu()
// Capture screenshots at different states
takeScreenshot(name: "food_menu_initial")
// Interact with UI
app.buttons["filter_button"].tap()
takeScreenshot(name: "food_menu_filtered")
}// Android
composeTestRule.mainClock.autoAdvance = false
composeTestRule.mainClock.advanceTimeBy(1000) // Advance 1 second
// iOS
sleep(2) // Pause for 2 seconds to observe UI statecomposeTestRule.onRoot().printToLog("UI_TREE")print(app.debugDescription)- Set breakpoints in test code
- Use conditional breakpoints for specific scenarios
- Inspect element properties during execution
- Always wait for UI elements before interacting
- Use explicit waits instead of sleep
- Handle animations and transitions properly
- Run tests in parallel when possible
- Use test sharding for large test suites
- Mock network calls to reduce execution time
- Reset app state between tests
- Use test-specific data that won't conflict
- Clear preferences and databases in tearDown
name: UI Tests
on: [push, pull_request]
jobs:
android-ui-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Run Android UI Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
script: ./gradlew connectedCheck
ios-ui-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Run iOS UI Tests
run: |
xcodebuild test \
-workspace iosApp.xcworkspace \
-scheme iosApp \
-destination 'platform=iOS Simulator,name=iPhone 14'- Review Integration Testing for testing external services
- Check Test Coverage for UI test coverage requirements
- See Troubleshooting for common UI test issues