Espresso Robot Pattern

Write UI tests that read like what the user does. One robot per screen, one chain per test.

IDLE
$ ./gradlew :app:connectedUatDebugAndroidTest
ordersList()
.openFirstOrder()
.assertDisplayed()
.assertAmountFormatted()
BUILD SUCCESSFUL
Illustrative animation, not real device output.

Every Espresso suite in this app uses the Robot pattern: test methods are a chain of robot calls, and all widget-level Espresso code lives in robot classes. Purchase History is the reference implementation; a new module copies its shape and never touches purchasehistory/.

How a robot chain reads

A @Test method contains only robot calls, with no onView or Espresso calls inline. Each call is one user-visible action or assertion and returns either this (same screen) or the next screen's robot (navigated forward).

@Test
fun t05_transactionsFilter_juneRange_showsChipsAndRows() {
    openTransactions()
        .applyDateRange(JUNE_YEAR, Calendar.JUNE, 1, 30)
        .assertDateRange("06/01/$JUNE_YEAR", "06/30/$JUNE_YEAR")
        .waitForRows()
}

The waiting, polling and calendar paging behind applyDateRange live once, in MaterialDatePickerRobot and BaseRobot. Rule of thumb: push "how do I interact with this widget" down into a robot class, and keep the test reading as "what is the user doing."

Is this a standard pattern?

Yes, with one caveat: Google ships no Robot base class, so it is a community convention rather than part of the Espresso API. It is widely used across production Android codebases (Espresso, Compose UI testing, UI Automator). What is specific to this repo is the two-package split (testsupport/robot vs <module>/robot) and the naming and return-type conventions below.

BaseRobot and MaterialDatePickerRobot know nothing about Purchase History, so any module can depend on them with zero coupling. A new module's dev:

  1. Writes robot classes under their module's robot package, extending BaseRobot.
  2. Gets every BaseRobot method and all of MaterialDatePickerRobot for free.
  3. Never edits anything under purchasehistory/.

Package layout

Shared code lives in testsupport/robot; each module owns its own robot package.

presentation/ui/testsupport/robot/        <- module-agnostic, shared by every suite
    BaseRobot.kt                              generic wait/poll/RecyclerView helpers
    MaterialDatePickerRobot.kt                drives any MaterialDatePicker range picker

presentation/ui/purchasehistory/robot/    <- module-specific (yours: ui/<module>/robot/)
    CustomerListRobot.kt
    TransactionsRobot.kt
    TransactionDetailsRobot.kt                also holds SoDetailsSheetRobot,
                                              OrderSummarySheetRobot, DrDetailsSheetRobot

presentation/ui/purchasehistory/
    PurchaseHistoryFlowTest.kt                the @Test methods
    PurchaseHistoryTestUtils.kt               non-Espresso fixtures only
    purchase_history_test.md                  test-case table + exclusions
    espresso_readme.md                        the source of this guide
FileOwnsDoes NOT own
testsupport/robot/BaseRobot.ktGeneric polling, wait and RecyclerView helpers, usable on any screenAny specific view id or module domain type
testsupport/robot/MaterialDatePickerRobot.ktDriving a MaterialDatePicker range picker (paging months, tapping days, confirming)The screen that opened the picker; control is handed back to that screen's robot
<module>/robot/*.ktScreen and sheet ids, domain types, business-rule assertions, navigation between the module's screensGeneric Espresso helpers (reuse BaseRobot)
<Module>FlowTest.kt@Test methods (one robot chain each), @Before and @BeforeClass setupDirect onView/Espresso calls; those belong in a robot
<Module>TestUtils.ktNon-Espresso fixtures the robots assume exist (seeded prefs, DB rows, auth state)Anything that drives the UI
<module>_test.mdTest-case table mapped to requirements, plus an explicit "not included in automation" sectionn/a

API reference

BaseRobot

All methods are protected: call them from inside a robot subclass, never from a test class. File: testsupport/robot/BaseRobot.kt.

MethodSignatureWhat it doesUse it when
waitForViewwaitForView(matcher, timeoutMs = 60_000)Polls until the matcher resolves to a shown view; throws the last error on timeoutWaiting for the next screen's anchor view after an async navigation
appearsappears(matcher, timeoutMs): BooleanNon-throwing waitForViewBranching on an optional view
isDisplayedNowisDisplayedNow(matcher): BooleanOne-shot check, true only if shown right now"Am I on screen X" state checks
itemCountitemCount(matcher): IntReads a displayed RecyclerView's adapter item countComputing an expected count, e.g. "Total Orders (N)"
waitForItemswaitForItems(matcher, minCount = 1, timeoutMs = 60_000)Polls until item count >= minCountWaiting for a list to load from the network
waitForListToSettlewaitForListToSettle(matcher, timeoutMs = 5_000)Polls isAnimating until idle for 2 consecutive readsAfter a search or filter reorders a list, before tapping by position
textOftextOf(matcher): StringReads a TextView's current textParsing a label to make a decision
assertFormattedAmountassertFormattedAmount(matcher)Asserts the text matches ₱#,###.## (app-wide formatAmount())Any amount field in any module

MaterialDatePickerRobot

File: testsupport/robot/MaterialDatePickerRobot.kt. Return MaterialDatePickerRobot() from the method that opens the picker (reference: TransactionsRobot.openDateRangePicker()), then chain off it.

MethodSignatureWhat it does
pickRangepickRange(year, month, fromDay, toDay)Pages to the month and year, taps fromDay then toDay (same month), confirms
pickRangeAcrossMonthspickRangeAcrossMonths(fromYear, fromMonth, fromDay, toYear, toMonth, toDay)Same, but re-pages to the to month before tapping the to day

Both return MaterialDatePickerRobot; the caller keeps using the screen robot it already holds once the picker closes.

Conventions

Method naming and return types

A reader should know a method's shape from its name alone.

PrefixMeaningReturnsExample
assert...Checks something is true now; fails the test if notthisassertEmptyStateShown(): CustomerListRobot
tap... / click...Performs a clickthis if staying on screen, else the next screen's robottapSoNumber(): SoDetailsSheetRobot
open...Navigates forward (click plus wait for the destination's anchor view)Next screen's robotopenCustomer(...): TransactionsRobot
apply...Multi-step action that changes visible state, e.g. filtersthisapplyDateRange(...): TransactionsRobot
wait...Blocks until a condition holds, no assertionthiswaitForRows(): TransactionsRobot
is... / ...count() / value gettersReads a value for the test's own assertionPlain value (Boolean, Int, String); breaks the chain on purposerowCount(): Int
press... / ...ToXDismisses the current screen or sheetThe previous robot, after re-asserting its anchor viewpressBackToDetails(): TransactionDetailsRobot

Constructor rule: if a robot needs something from outside Espresso (ViewModel state via ActivityScenario.onActivity, a repository), pass it into the constructor, as in CustomerListRobot(private val scenario: ActivityScenario<PurchaseHistoryActivity>). Do not use a singleton or service locator.

BaseRobot vs your module's robot

BaseRobot only grows a method when the capability is generic to Espresso and Android widgets: true for any screen, in any module, with no knowledge of your ids or domain types. Everything else stays in your module's robot.

Add to BaseRobot (generic)Keep in your module's robot (specific)
Wait for a Snackbar showing this textWait for the tvDrDetailsHeader details header
Read a Spinner's currently selected textAssert the FSP Code filter defaults to ALL_OPTION
Scroll a NestedScrollView until a child is visibleClick the first row where PurchaseHistorySO.drCount >= 1
Poll until a RecyclerView stops animatingAssert an amount in your screen's specific field
Drive any MaterialDatePicker (as its own testsupport/robot class)Assert this screen's date-range chips show a given value

Two guardrails:

  • BaseRobot helpers stay protected. If a test needs one to be public, that behavior belongs in a module robot method.
  • Do not promote speculatively. Write it in your own robot first; move it to testsupport only when a second module needs the identical generic behavior.

Tie-breaker question: would this method make sense in a suite for a screen that has never heard of Purchase History? If yes, it is BaseRobot material.

How the test class is wired

The activity launches once per class, robots are created fresh per call, and @Before resets to one known screen.

@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)   // t01, t02, ... run in that order
class SPurchaseHistoryFlowTest {

    companion object {
        private var scenario: ActivityScenario<PurchaseHistoryActivity>? = null

        @BeforeClass @JvmStatic
        fun launchActivity() { /* seed fixtures, launch once for the whole class */ }

        @AfterClass @JvmStatic
        fun closeActivity() { scenario?.close() }
    }

    private fun customerList() = CustomerListRobot(scenario!!)
    private fun transactions() = TransactionsRobot()

    @Before
    fun returnToCustomerList() {
        // back out of whatever the previous test left open
    }

    @Test
    fun t05_transactionsFilter_juneRange_showsChipsAndRows() {
        openTransactions()
            .applyDateRange(JUNE_YEAR, Calendar.JUNE, 1, 30)
            .assertDateRange("06/01/$JUNE_YEAR", "06/30/$JUNE_YEAR")
            .waitForRows()
    }
}
PieceWhy it is shaped this way
@BeforeClass/@AfterClass launch and close the Activity onceLaunching is slow and the suite hits a real API; per-test launches would multiply runtime
private fun customerList() = CustomerListRobot(scenario!!) creates a fresh robot per callRobots hold no state; a stored field could go stale across a screen transition
@Before resets to one known starting screenTest independence (below)
@FixMethodOrder(NAME_ASCENDING) with t01, t02, ...JUnit 4's default order is an arbitrary name hash; these tests follow a documented flow, so the run order should be readable
Each @Test is a single robot chainA needed onView call belongs in a robot method, not inline

Test independence

@Before always returns to the same starting screen (the customer list) by pressing back until it shows, polling around the loading dialog. Every @Test re-drives its own path in via robot chains, so nothing relies on state a previous test left behind. This costs speed (deep-screen tests re-walk the path) but one failing test cannot cascade into unrelated failures.

Template: adding a new module's suite

Worked example for a fictional Orders module with two screens: an order list, and a details screen reached by tapping a row. Copy the files, rename Orders, OrdersActivity, rvOrders and friends, and delete what you do not need.

presentation/ui/orders/robot/OrdersListRobot.kt
presentation/ui/orders/robot/OrderDetailsRobot.kt
presentation/ui/orders/OrdersFlowTest.kt
presentation/ui/orders/OrdersTestUtils.kt      (only if you need non-Espresso fixtures)
presentation/ui/orders/orders_test.md

OrdersListRobot.kt (entry screen)

package com.felco.sfaapp.v2.presentation.ui.orders.robot

import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import com.felco.sfaapp.R
import com.felco.sfaapp.v2.presentation.ui.testsupport.robot.BaseRobot
import org.hamcrest.Matchers.allOf

/** Screen 1: order list, the entry point of the Orders module. */
class OrdersListRobot : BaseRobot() {

    private val visibleList = allOf(withId(R.id.rvOrders), isDisplayed())

    fun waitForOrders(): OrdersListRobot = apply { waitForItems(visibleList) }

    fun assertListDisplayed(): OrdersListRobot = apply {
        onView(visibleList).check(matches(isDisplayed()))
    }

    /** Taps the first row; swap for a tapRowWhenAvailable(predicate) if rows need filtering. */
    fun openFirstOrder(): OrderDetailsRobot {
        waitForOrders()
        onView(visibleList).perform(
            RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click())
        )
        return OrderDetailsRobot()
    }
}

OrderDetailsRobot.kt (second screen)

package com.felco.sfaapp.v2.presentation.ui.orders.robot

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import com.felco.sfaapp.R
import com.felco.sfaapp.v2.presentation.ui.testsupport.robot.BaseRobot

/** Screen 2: a single order's details. */
class OrderDetailsRobot : BaseRobot() {

    fun assertDisplayed(): OrderDetailsRobot = apply {
        waitForView(withId(R.id.tvOrderNumber))
        onView(withId(R.id.tvOrderNumber)).check(matches(isDisplayed()))
    }

    fun assertAmountFormatted(): OrderDetailsRobot = apply {
        assertFormattedAmount(withId(R.id.tvOrderAmount))
    }
}

OrdersFlowTest.kt (test class)

package com.felco.sfaapp.v2.presentation.ui.orders

import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.felco.sfaapp.v2.presentation.ui.orders.robot.OrdersListRobot
import org.junit.AfterClass
import org.junit.Before
import org.junit.BeforeClass
import org.junit.FixMethodOrder
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters

@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class OrdersFlowTest {

    companion object {
        private var scenario: ActivityScenario<OrdersActivity>? = null

        @BeforeClass @JvmStatic
        fun launchActivity() {
            // seed any fixtures OrdersTestUtils exposes, then:
            scenario = ActivityScenario.launch(OrdersActivity::class.java)
        }

        @AfterClass @JvmStatic
        fun closeActivity() {
            scenario?.close()
            scenario = null
        }
    }

    private fun ordersList() = OrdersListRobot()

    @Before
    fun returnToOrdersList() {
        if (scenario?.state == Lifecycle.State.DESTROYED) {
            scenario = ActivityScenario.launch(OrdersActivity::class.java)
        }
        // press back / reset to the list screen, as returnToCustomerList() does
    }

    @Test
    fun t01_ordersList_loadsRows() {
        ordersList().waitForOrders().assertListDisplayed()
    }

    @Test
    fun t02_tapFirstOrder_opensDetails() {
        ordersList().openFirstOrder().assertDisplayed().assertAmountFormatted()
    }
}

orders_test.md

Copy purchase_history_test.md's two sections (## Test cases table and ## Not included in automation) and fill them in against your own requirements doc.

Checklist for a new module

  • Create presentation/ui/<module>/robot/ with one <Screen>Robot per screen or sheet, in navigation order (OrdersListRobot, not OrdersListPage).
  • Extend BaseRobot and check the API tables before writing any wait or poll logic.
  • For a Material date-range picker, return MaterialDatePickerRobot() from the opening method and chain .pickRange(...).
  • Wire navigation as return types per the naming table: forward navigation returns the next robot, staying returns this.
  • Keep domain knowledge (ids, entities, business rules) in your module's robots, not in testsupport.
  • Write <Module>FlowTest.kt from the template: launch once, @Before reset, t01, t02 names, no inline onView.
  • Put non-Espresso fixtures (prefs, DB rows, auth state) in <Module>TestUtils.kt.
  • Write <module>_test.md with the test-case table and what is excluded, and why.
  • Compile before opening a PR: ./gradlew :app:compile<Flavor><BuildType>AndroidTestKotlin (for example compileUatDebugAndroidTestKotlin).
  • If you added anything to testsupport/robot, update the package layout section of this guide.

Requirements and running the suite

The device must be online (the modules' connectivity gate evicts otherwise), unlocked, and left with animations on; waitForListToSettle and waitForCalendarToSettle exist to wait animations out. Purchase History also needs a UAT account passed as testEmail; see purchase_history_test.md for the fixture data it needs. Each module's <module>_test.md states its own equivalent.

The app has dev, uat and prod flavors, each with a debug build type, so task names look like connectedUatDebugAndroidTest. Purchase History runs against the real UAT API, so it uses uat.

# Whole class, default test account (DEFAULT_TEST_EMAIL in the companion object)
.\gradlew.bat :app:connectedUatDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.felco.sfaapp.v2.presentation.ui.purchasehistory.SPurchaseHistoryFlowTest

# Same, with an explicit UAT test account
.\gradlew.bat :app:connectedUatDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.felco.sfaapp.v2.presentation.ui.purchasehistory.SPurchaseHistoryFlowTest -Pandroid.testInstrumentationRunnerArguments.testEmail=your.uat.account@fireflyelectric.com

# One test method
.\gradlew.bat :app:connectedUatDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.felco.sfaapp.v2.presentation.ui.purchasehistory.SPurchaseHistoryFlowTest#t05_transactionsFilter_juneRange_showsChipsAndRows

# Compile only, no run (do this before every PR)
.\gradlew.bat :app:compileUatDebugAndroidTestKotlin

Swap uat for dev or prod and the class name for your own module's test class to run a different suite.

Android Studio: right-click the test class or a @Test gutter icon and choose Run '...'. To pass testEmail, edit the run configuration, open Instrumentation Arguments, and add testEmail=your.uat.account@fireflyelectric.com.

Troubleshooting

SymptomLikely causeFix
NoMatchingViewException on a view you can seeA dialog (loading spinner, date picker, Snackbar) still holds the windowWait on the dialog's own view with waitForView/appears; do not add a Thread.sleep
AmbiguousViewMatcherExceptionTwo fragments share an id (e.g. rvPurchaseHistory in the activity's hidden list and the fragment's)Scope it with allOf(withId(...), isDisplayed()) or isDescendantOfA(withId(R.id.fragment_container)), as TransactionsRobot.waitForRows() does
A row tap hits the wrong item or nothingTapped by position while the list was still reordering after search or filterCall waitForListToSettle(matcher) first; see CustomerListRobot.openCustomer
Found more than one sub-view with text, from actionOnItemMatching rows by visible content when several rows qualifyMatch by adapter data: read the adapter's currentList and click by position; see TransactionsRobot.firstRowPosition and tapRowWhenAvailable
Test hangs until the full timeout, then failsThe awaited condition never becomes true: a real regression, or fixture data changed (e.g. the UAT account's June data)Check the failure screenshot and hierarchy dump, then the fixture data in Requirements; a stale test account is the most common cause
Passes alone, fails with the rest of the classA previous test left a screen or dialog open that @Before did not back out ofExtend the @Before reset's deeper-screen check (isOnDeeperScreenOrSheet()) to include the new screen or sheet
IllegalStateException from ActivityScenario after a crashThe scenario reached DESTROYED and nothing relaunched itAdd the same relaunchIfDestroyed() check PurchaseHistoryFlowTest uses
Flaky in CI, fine locallyCI runners are slowerDo not shorten timeouts; a missing poll is the real problem

FAQ

QuestionAnswer
Do I need a *SheetRobot if my screen has no bottom sheets?No. Create only the robots your screens need; Purchase History has three sheet robots because it has three sheets.
I need to tap a row matching a predicate, like TransactionsRobot.tapRowWhenAvailable. Where does it go?Your own module's robot, since it needs your domain type and business rule. Copy the shape, not the code.
Can I add to BaseRobot if I am pretty sure another module will need it?No. Write it in your own robot first; promote only when a second module needs the identical generic behavior.
Where does a helper that reads ViewModel state directly go?Pass it as a constructor parameter on the robot, like CustomerListRobot(scenario) does for firstCustomerKunnr(). No singletons.
Must tests be named t01_...?Yes if tests build on each other in a documented flow, because NAME_ASCENDING needs a sortable prefix. Fully independent tests can use plain names; never rely on JUnit's hash-based default order if you need a sequence.
Can I mock instead of hitting a real API?That is a suite-level decision separate from the Robot pattern; record it in your module's own test plan.
Another module's robot already has the method I need. Can I import it?No. Module robots are not imported cross-module. If it is generic, promote it to testsupport; if specific, write your own.
My fix from the troubleshooting table did not workCheck whether the fixture data in your module's <module>_test.md still matches the test account; most reports of a broken pattern are stale test data.

AI-assisted review with CLAUDE.md

This section is a proposed team setup, not existing repo config.

A CLAUDE.md file at the repo root is read by Claude Code at the start of every session, so it is the place to put this guide's rules in a form an agent can follow. With it, Claude Code can read robots and tests, compile the androidTest source set, run the suite on a connected device or emulator, and read the failure report.

What goes where

FilePurpose
CLAUDE.md (repo root)Short, always-loaded rules: architecture, Robot conventions, the exact compile and run commands
presentation/ui/CLAUDE.md (optional, nested)Rules that only matter when working in UI test code; loaded when Claude works in that folder
.claude/commands/review-espresso.mdA reusable slash command: /review-espresso <module> runs the review loop below
.claude/settings.jsonPermission allowlist so routine commands (gradlew, adb) run without prompting each time

Keep CLAUDE.md short and link to this guide (or espresso_readme.md, download it here) instead of pasting it, since the whole file is loaded into every session.

Starter CLAUDE.md

# Espresso testing rules

Full guide: presentation/ui/purchasehistory/espresso_readme.md

## Architecture
- Tests use the Robot pattern. @Test methods are robot chains only: no onView/Espresso calls inline.
- Shared helpers live in testsupport/robot (BaseRobot, MaterialDatePickerRobot). Module robots live in ui/<module>/robot/.
- BaseRobot helpers stay protected. Only promote a helper to testsupport when a SECOND module needs it.
- Never import one module's robots into another module's suite.

## Naming
- assert... / apply... / wait... return this; tap... / open... return the next screen's robot;
  value getters return plain values.
- Test names: t01_..., t02_... with @FixMethodOrder(NAME_ASCENDING).

## Do not
- Do not add Thread.sleep; wait with waitForView / appears / waitForItems / waitForListToSettle.
- Do not shorten timeouts to hide flakiness.
- Do not change business logic in app code to make a test pass; report it instead.
- Do not commit UAT credentials; pass testEmail via -P arguments.

## Commands (Windows PowerShell)
- Compile only: .\gradlew.bat :app:compileUatDebugAndroidTestKotlin
- Run a class: .\gradlew.bat :app:connectedUatDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=<fully.qualified.TestClass>
- Run one test: append #<methodName> to the class argument.
- Failure reports: app/build/reports/androidTests/connected/

The read, run, test loop

A /review-espresso command (or a plain prompt) can drive these steps:

  1. Read. Review the module's robots, test class and <module>_test.md against the checklist and conventions: inline onView, wrong return types, module logic leaking into BaseRobot, missing @Before reset for a new screen, Thread.sleep.
  2. Compile. Run the compile-only task and fix errors before anything touches a device.
  3. Run. Run the suite (or the one changed test) with connected...AndroidTest. This needs a device or emulator already attached and online; check with adb devices first.
  4. Diagnose. Read the report under app/build/reports/androidTests/connected/ and map the failure to the troubleshooting table. Rule out stale fixture data before blaming code.
  5. Fix and re-run. Apply the smallest fix, re-run only the affected test, then the full class to catch cross-test leakage.
  6. Report. Summarize what changed, what passed, and anything that looks like an app bug rather than a test bug.

Practical limits

  • The suite hits the real UAT API and needs a network-connected device, so a cloud sandbox cannot run it. Run Claude Code locally on a machine with the emulator or device attached, or on a CI runner that has one.
  • Claude can read a failure and propose a fix, but a passing run is the evidence, not the model's reading of the code. Require a green re-run before accepting a fix.
  • Put test account credentials in local Gradle properties or environment variables, and keep them out of CLAUDE.md.