Skip to content
My Guidelines
Guides
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 Clock and IdGenerator, never call Date.now() or Math.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

LayerWhat it verifiesToolMocksSpeedTarget coverage
domain/Business rules, invariants, calculationsJest / VitestNone< 1 ms~100%
application/Orchestration, error pathsJest / VitestOutbound ports only — prefer fakes< 10 ms~100%
infrastructure/Adapters honour their port contractJest + MSW / in-memory MMKVNetwork only< 100 msMeaningful paths
ui/User-visible behaviourReact Native Testing LibraryThe container< 500 msCritical screens
E2EReal flows on a real buildMaestroNothingMinutesHappy 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:

domain/expense/Money.test.ts
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.

test/fake/InMemoryExpenseRepository.ts
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()],
}
}
application/expense/createExpense.test.ts
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() or new Date() in code under test — inject Clock.
  • Never Math.random() or crypto.randomUUID() — inject IdGenerator.
  • 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.

test/builder/expense.ts
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.

ui/feature/expense/ExpenseListScreen.test.tsx
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:

  1. getByRole with an accessible name
  2. getByLabelText
  3. getByText
  4. getByTestId — 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.
  • ❌ waitFor with 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.

.maestro/create-expense.yaml
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:

  1. 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.
  2. GREEN — the minimum code that passes.
  3. 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 moduleCall it — it is pure
Mocking the class under testNever
Mock asserting save was calledFake asserting the state that resulted
Date.now() in code under testInjected Clock
Snapshot as the only assertionExplicit assertion on visible behaviour
getByTestId firstgetByRole with an accessible name
Only the happy path testedLoading, empty, error, content
Everything pushed to E2EDomain tests are 1000× faster
Tests sharing module-level statebeforeEach
waitFor with a long timeoutFix the race
Coverage chased with component testsCoverage 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
  • Clock and IdGenerator injected — 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