In this guide (15 items)
Mobile Testing
Mobile Testing Strategy
MANDATORY FOR ALL AI AGENTS: Every feature includes tests. No exceptions. Domain tests use NO mocks. Use-case tests mock ONLY outbound ports. Component tests assert behaviour, never implementation. Tests MUST be deterministic — inject
ClockandIdGenerator, never callDate.now()orMath.random()in code under test. Follow TDD: RED → GREEN → REFACTOR, and never skip the RED step.
Companion commands: /mobile-init-project (Jest, RNTL, Maestro, fakes, coverage gates), /mobile-add-test (tests per layer).
This is the mobile counterpart of testing.md. The pyramid and the mocking rules are identical; only the tools change. Architecture context: mobile-architecture.md. Conventions: typescript-code-style.md.
1. The pyramid, mapped to layers
| Layer | What it verifies | Tool | Mocks | Speed | Target coverage |
|---|---|---|---|---|---|
domain/ | Business rules, invariants, calculations | Jest / Vitest | None | < 1 ms | ~100% |
application/ | Orchestration, error paths | Jest / Vitest | Outbound ports only — prefer fakes | < 10 ms | ~100% |
infrastructure/ | Adapters honour their port contract | Jest + MSW / in-memory MMKV | Network only | < 100 ms | Meaningful paths |
ui/ | User-visible behaviour | React Native Testing Library | The container | < 500 ms | Critical screens |
| E2E | Real flows on a real build | Maestro | Nothing | Minutes | Happy paths + money paths |
Minimum overall line coverage: 80%. Domain and use cases must be near 100%. Excluded from coverage: config files, generated code, design tokens, type-only modules.
The shape matters more than the number. A codebase at 85% that is all component tests is worse than one at 80% concentrated in the domain.
2. Why hexagonal pays off here
This is the concrete return on the architecture. In domain/ there is nothing to mock, because there is nothing injected:
describe('Money', () => { it('should_reject_non_integer_minor_units', () => { const result = money(10.5, 'COP') expect(result.ok).toBe(false) })
it('should_add_amounts_without_floating_point_drift', () => { const a = unwrap(money(10, 'COP')) // 0.10 const b = unwrap(money(20, 'COP')) // 0.20 expect(unwrap(add(a, b)).amountMinor).toBe(30) })
it('should_reject_addition_across_currencies', () => { const cop = unwrap(money(100, 'COP')) const usd = unwrap(money(100, 'USD')) expect(add(cop, usd)).toEqual(err({ kind: 'CurrencyMismatch', a: 'COP', b: 'USD' })) })})No renderer, no jest.mock, no setup file. Hundreds of these run in under a second. If a domain test needs a mock, the code under test is in the wrong layer.
3. Use-case tests: fakes over mocks
Mock only outbound ports, and prefer a fake when behaviour matters. A mock asserts that a call happened; a fake lets you assert the outcome — which is what you actually care about.
export function inMemoryExpenseRepository(seed: Expense[] = []): ExpenseRepository & { all(): Expense[] } { const store = new Map(seed.map((e) => [e.id, e])) return { async save(expense) { store.set(expense.id, expense); return ok(expense) }, async findById(id) { return store.get(id) ?? null }, async findByPeriod(period) { return [...store.values()].filter((e) => within(e, period)) }, all: () => [...store.values()], }}describe('createExpense', () => { const FIXED_NOW = instant('2026-03-01T10:00:00Z')
function subject(repository = inMemoryExpenseRepository()) { return { run: createExpense({ repository, clock: fixedClock(FIXED_NOW), ids: sequentialIds() }), repository, } }
it('should_persist_expense_when_command_is_valid', async () => { const { run, repository } = subject()
const result = await run(aCreateExpenseCommand())
expect(result.ok).toBe(true) expect(repository.all()).toHaveLength(1) })
it('should_reject_when_amount_exceeds_limit', async () => { const { run, repository } = subject()
const result = await run(aCreateExpenseCommand({ amountMinor: 999_999_999 }))
expect(result).toEqual(err({ kind: 'AmountTooLarge', max: MAX_AMOUNT_MINOR })) expect(repository.all()).toHaveLength(0) // nothing was written })})Note the second assertion. A mock-based test would confirm save was not called; the fake confirms nothing was persisted — the property that actually matters.
Never
- ❌ Mock the use case under test.
- ❌ Mock
domain/— it is pure, call it. - ❌
jest.mock('react-native')in a use-case test — if RN appears there, the layering is broken.
4. Determinism
Flaky tests get deleted, and then bugs ship. Non-determinism has exactly three sources; all three are ports.
export const fixedClock = (now: Instant): Clock => ({ now: () => now })
export const sequentialIds = (): IdGenerator => { let n = 0 return { next: () => `id-${++n}` as ExpenseId }}- Never
Date.now()ornew Date()in code under test — injectClock. - Never
Math.random()orcrypto.randomUUID()— injectIdGenerator. - Random test data only with a fixed seed.
- No shared mutable state between tests. Setup in
beforeEach, never module-level. - Each test runs independently and in any order.
Test the time zone explicitly. A date computed in UTC and rendered in the device’s local zone is a classic off-by-one-day bug that only appears for users east or west of the CI runner.
5. Test data builders
Repeated fixtures become builders with sensible defaults. The test states only what it cares about; everything else is noise hidden in the default.
export function anExpense(overrides: Partial<Expense> = {}): Expense { return { id: 'expense-1' as ExpenseId, amount: unwrap(money(1_000, 'COP')), category: 'food' as Category, occurredAt: instant('2026-03-01T10:00:00Z'), ...overrides, }}const large = anExpense({ amount: unwrap(money(999_999_999, 'COP')) })Named constants over magic values. MAX_AMOUNT_MINOR, not 999999999 repeated in five files.
6. Component tests
React Native Testing Library. Assert what the user perceives, never how the component is built.
describe('ExpenseListScreen', () => { it('should_show_empty_state_when_there_are_no_expenses', async () => { renderWithContainer(<ExpenseListScreen />, { container: testContainer({ expenses: [] }), })
expect(await screen.findByText('No expenses yet')).toBeVisible() })
it('should_show_error_state_with_retry_when_loading_fails', async () => { renderWithContainer(<ExpenseListScreen />, { container: testContainer({ failWith: { kind: 'Network' } }), })
expect(await screen.findByRole('button', { name: 'Try again' })).toBeVisible() })})Query priority — top to bottom, always prefer the highest that works:
getByRolewith an accessible namegetByLabelTextgetByTextgetByTestId— last resort only
A test that passes with getByRole proves the element is reachable by a screen reader. A testID-based test proves nothing about accessibility. Preferring roles makes the accessibility of mobile-ui.md self-enforcing.
Every screen tests four states
Loading, empty, error, content. A screen tested only on the happy path is untested where it matters — users meet the error path on the subway.
⚠️ A native-module styling engine needs its mock in setupFiles, not setupFilesAfterEnv. setupFilesAfterEnv runs after the module registry is populated, so the real native module is already resolved and every suite fails at import. See mobile-ui.md §1.
Never
- ❌ Assert on internal state or a hook’s return value
- ❌ Snapshot tests as the primary assertion. They pass on every change and get regenerated without reading. Reserve them for genuinely stable, complex output.
- ❌
waitForwith an arbitrary timeout to paper over a race
7. Adapter tests
An adapter test answers one question: does this implementation honour the port contract?
- HTTP adapters: MSW intercepts at the network layer, so the real client code runs — including timeout, header and error mapping. Test malformed and unexpected responses, not only the happy shape, since mobile-architecture.md §7 requires parsing at the boundary.
- Storage adapters: run against a real in-memory MMKV or a temp SQLite file. Explicitly test reading a row written by an older schema version — silent corrupt-data crash loops are only fixable by reinstalling, and users uninstall instead.
- Secure storage: test the port contract; do not attempt to test the OS keychain.
A contract test suite that every adapter of a port must pass is worth writing once a port has two implementations.
8. E2E
Maestro is the default: declarative YAML, no build step, tolerant of minor UI change.
appId: dev.codehunters.expense---- launchApp: { clearState: true }- tapOn: "Add expense"- inputText: "12000"- tapOn: "Food"- tapOn: "Save"- assertVisible: "12.000"Rules:
- E2E covers happy paths and money paths only. Everything else belongs lower in the pyramid — E2E is slow, and slow suites get skipped.
- Always
clearState: true. A test depending on a previous test’s leftovers is not a test. - Run against a real build, on CI, on both platforms.
- Detox is the alternative when you need fine-grained synchronisation control. Choosing it is an ADR.
9. TDD workflow
Non-negotiable, same as testing.md:
- RED — write a failing test describing the behaviour. Run it. See it fail. A test never observed failing has not been shown to test anything.
- GREEN — the minimum code that passes.
- REFACTOR — clean up while green.
Structure: Given–When–Then, visually separated by blank lines. One behaviour per test. No logic in tests — no if, no loops, no try. A test with a branch is two tests.
Naming: should_{expected}_when_{condition}.
10. CI gates
All must fail the build, not warn:
-
tsc --noEmit - ESLint including the boundary rules of mobile-architecture.md §9
- Unit + component tests
- Coverage ≥80% overall, domain and use cases ≥95%
- Maestro E2E on iOS and Android against a real build
- Formatter check
11. Anti-patterns
| ❌ | ✅ |
|---|---|
jest.mock of a domain module | Call it — it is pure |
| Mocking the class under test | Never |
Mock asserting save was called | Fake asserting the state that resulted |
Date.now() in code under test | Injected Clock |
| Snapshot as the only assertion | Explicit assertion on visible behaviour |
getByTestId first | getByRole with an accessible name |
| Only the happy path tested | Loading, empty, error, content |
| Everything pushed to E2E | Domain tests are 1000× faster |
| Tests sharing module-level state | beforeEach |
waitFor with a long timeout | Fix the race |
| Coverage chased with component tests | Coverage concentrated in the domain |
12. Pre-merge checklist
- New behaviour arrived test-first, and the RED step was observed
- Domain tests contain zero mocks
- Use-case tests use fakes for outbound ports
-
ClockandIdGeneratorinjected — no wall clock, no randomness - Each new screen covers all four states
- Component queries prefer
getByRole - Adapter tests cover malformed and legacy-shaped input
- Test names follow
should_{expected}_when_{condition} - No logic inside any test
- Coverage gates pass