Espresso Robot Pattern
Write UI tests that read like what the user does. One robot per screen, one chain per test.
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:
- Writes robot classes under their module's
robotpackage, extendingBaseRobot. - Gets every
BaseRobotmethod and all ofMaterialDatePickerRobotfor free. - 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
| File | Owns | Does NOT own |
|---|---|---|
testsupport/robot/BaseRobot.kt | Generic polling, wait and RecyclerView helpers, usable on any screen | Any specific view id or module domain type |
testsupport/robot/MaterialDatePickerRobot.kt | Driving 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/*.kt | Screen and sheet ids, domain types, business-rule assertions, navigation between the module's screens | Generic Espresso helpers (reuse BaseRobot) |
<Module>FlowTest.kt | @Test methods (one robot chain each), @Before and @BeforeClass setup | Direct onView/Espresso calls; those belong in a robot |
<Module>TestUtils.kt | Non-Espresso fixtures the robots assume exist (seeded prefs, DB rows, auth state) | Anything that drives the UI |
<module>_test.md | Test-case table mapped to requirements, plus an explicit "not included in automation" section | n/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.
| Method | Signature | What it does | Use it when |
|---|---|---|---|
waitForView | waitForView(matcher, timeoutMs = 60_000) | Polls until the matcher resolves to a shown view; throws the last error on timeout | Waiting for the next screen's anchor view after an async navigation |
appears | appears(matcher, timeoutMs): Boolean | Non-throwing waitForView | Branching on an optional view |
isDisplayedNow | isDisplayedNow(matcher): Boolean | One-shot check, true only if shown right now | "Am I on screen X" state checks |
itemCount | itemCount(matcher): Int | Reads a displayed RecyclerView's adapter item count | Computing an expected count, e.g. "Total Orders (N)" |
waitForItems | waitForItems(matcher, minCount = 1, timeoutMs = 60_000) | Polls until item count >= minCount | Waiting for a list to load from the network |
waitForListToSettle | waitForListToSettle(matcher, timeoutMs = 5_000) | Polls isAnimating until idle for 2 consecutive reads | After a search or filter reorders a list, before tapping by position |
textOf | textOf(matcher): String | Reads a TextView's current text | Parsing a label to make a decision |
assertFormattedAmount | assertFormattedAmount(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.
| Method | Signature | What it does |
|---|---|---|
pickRange | pickRange(year, month, fromDay, toDay) | Pages to the month and year, taps fromDay then toDay (same month), confirms |
pickRangeAcrossMonths | pickRangeAcrossMonths(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.
| Prefix | Meaning | Returns | Example |
|---|---|---|---|
assert... | Checks something is true now; fails the test if not | this | assertEmptyStateShown(): CustomerListRobot |
tap... / click... | Performs a click | this if staying on screen, else the next screen's robot | tapSoNumber(): SoDetailsSheetRobot |
open... | Navigates forward (click plus wait for the destination's anchor view) | Next screen's robot | openCustomer(...): TransactionsRobot |
apply... | Multi-step action that changes visible state, e.g. filters | this | applyDateRange(...): TransactionsRobot |
wait... | Blocks until a condition holds, no assertion | this | waitForRows(): TransactionsRobot |
is... / ...count() / value getters | Reads a value for the test's own assertion | Plain value (Boolean, Int, String); breaks the chain on purpose | rowCount(): Int |
press... / ...ToX | Dismisses the current screen or sheet | The previous robot, after re-asserting its anchor view | pressBackToDetails(): 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 text | Wait for the tvDrDetailsHeader details header |
| Read a Spinner's currently selected text | Assert the FSP Code filter defaults to ALL_OPTION |
| Scroll a NestedScrollView until a child is visible | Click the first row where PurchaseHistorySO.drCount >= 1 |
| Poll until a RecyclerView stops animating | Assert 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:
BaseRobothelpers stayprotected. 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
testsupportonly 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()
}
}
| Piece | Why it is shaped this way |
|---|---|
@BeforeClass/@AfterClass launch and close the Activity once | Launching 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 call | Robots hold no state; a stored field could go stale across a screen transition |
@Before resets to one known starting screen | Test 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 chain | A 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>Robotper screen or sheet, in navigation order (OrdersListRobot, notOrdersListPage). - Extend
BaseRobotand 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.ktfrom the template: launch once,@Beforereset,t01,t02names, no inlineonView. - Put non-Espresso fixtures (prefs, DB rows, auth state) in
<Module>TestUtils.kt. - Write
<module>_test.mdwith the test-case table and what is excluded, and why. - Compile before opening a PR:
./gradlew :app:compile<Flavor><BuildType>AndroidTestKotlin(for examplecompileUatDebugAndroidTestKotlin). - 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
| Symptom | Likely cause | Fix |
|---|---|---|
NoMatchingViewException on a view you can see | A dialog (loading spinner, date picker, Snackbar) still holds the window | Wait on the dialog's own view with waitForView/appears; do not add a Thread.sleep |
AmbiguousViewMatcherException | Two 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 nothing | Tapped by position while the list was still reordering after search or filter | Call waitForListToSettle(matcher) first; see CustomerListRobot.openCustomer |
Found more than one sub-view with text, from actionOnItem | Matching rows by visible content when several rows qualify | Match by adapter data: read the adapter's currentList and click by position; see TransactionsRobot.firstRowPosition and tapRowWhenAvailable |
| Test hangs until the full timeout, then fails | The 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 class | A previous test left a screen or dialog open that @Before did not back out of | Extend the @Before reset's deeper-screen check (isOnDeeperScreenOrSheet()) to include the new screen or sheet |
IllegalStateException from ActivityScenario after a crash | The scenario reached DESTROYED and nothing relaunched it | Add the same relaunchIfDestroyed() check PurchaseHistoryFlowTest uses |
| Flaky in CI, fine locally | CI runners are slower | Do not shorten timeouts; a missing poll is the real problem |
FAQ
| Question | Answer |
|---|---|
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 work | Check 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
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
| File | Purpose |
|---|---|
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.md | A reusable slash command: /review-espresso <module> runs the review loop below |
.claude/settings.json | Permission 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:
- Read. Review the module's robots, test class and
<module>_test.mdagainst the checklist and conventions: inlineonView, wrong return types, module logic leaking intoBaseRobot, missing@Beforereset for a new screen,Thread.sleep. - Compile. Run the compile-only task and fix errors before anything touches a device.
- Run. Run the suite (or the one changed test) with
connected...AndroidTest. This needs a device or emulator already attached and online; check withadb devicesfirst. - 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. - Fix and re-run. Apply the smallest fix, re-run only the affected test, then the full class to catch cross-test leakage.
- 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.