The test tooling (build system, test runner, assertions, coroutine/Flow testing, mocking
policy, static analysis) is now decided — see adr/012-build-and-test-tooling.md
and §8 below. The Gradle module layout is also now decided — see
adr/010-module-layout.md: domain tests are plain JVM unit tests
under the :domain module, guaranteed Android-free by the build, not merely by convention. What is
still pending is the UI toolkit and the CI/reproducible-build pipeline — see
Pending tooling decisions. What was always fixed regardless of
tooling remains fixed: the TDD discipline, the shape of the pyramid, what a test must and must not
do, and the privacy/resource checks every relevant feature carries.
Related: ../CLAUDE.md §4 and §5 (binding rules this document operationalises),
privacy.md, architecture/, adr/012-build-and-test-tooling.md,
adr/proposals/.
CLAUDE.md §4: TDD is the default, not a preference. Concretely, for every behaviour change:
- Write the failing test first. It names the behaviour, not the implementation
(
returns coarsened coordinates when precision is reduced, nottest1orcalls roundTo3Decimals). - Observe it fail, and fail for the expected reason (missing class, wrong assertion) — not a compile error unrelated to the behaviour, not a fail from a leftover bug in the test itself. A test nobody has watched fail is not trusted to catch a regression.
- Write the minimal production code to pass it. No extra behaviour, no speculative generalisation ahead of the next test.
- Refactor with the test green as a safety net: remove duplication, clarify names, extract collaborators. Tests are refactored too, to the same standard as production code.
- Repeat for the next test in the recon's TDD test list (
docs/specs/<feature>.md).
A PR whose tests were written after the implementation, or added only to satisfy a coverage
number, is a review finding, not a formality — see CLAUDE.md §10.9.
The architecture (domain pure Kotlin / data implements domain interfaces / presentation
holds no business logic — CLAUDE.md §4) is what makes this pyramid enforceable: the layering
itself decides where a behaviour can be tested without an Android runtime.
▲ few
instrumented / UI — critical paths only, real or emulated device
│
data-layer tests — fake providers, recorded/fixture responses
│
domain tests — pure Kotlin, no Android imports, no runtime
▼ many
- Pure Kotlin, run on the JVM, zero Android framework imports, zero instrumentation.
- Cover routing decisions, coordinate coarsening, relay selection logic, incident/traffic merging, geocoding query shaping, error-type mapping — anything expressible as a function of inputs to outputs without a device.
- Fast enough to run on every commit locally; this is the layer TDD red-green-refactor happens in almost all of the time.
- Exercise
dataimplementations ofdomaininterfaces (repositories, HTTP clients, local storage) against fakes and recorded/fixture responses, never a live endpoint. - Verify: request shaping (what is actually sent), response parsing, error mapping, retry/cache behaviour, and — for every outbound integration — that the relay path is actually exercised (§5).
- A recorded response is a static fixture checked into the test sources, built from synthetic
data (see
CLAUDE.md§5, "in the repository" rules): no real coordinates, no real address, no real trace.
- Reserved for what cannot be verified any other way: does the map actually render a route, does the permission-denied path actually show the fallback UI, does the foreground-service notification actually appear (from v0.2).
- Kept deliberately small in number — slow, flaky-prone, and expensive to maintain. Each one earns its place by covering a path where a domain or data test cannot reach the real failure mode (rendering, system permission dialogs, lifecycle transitions driven by the OS).
A test that is accepted:
- Behavioural — asserts an observable outcome (returned value, emitted state, request sent), not an internal call sequence or private field.
- Independent — does not depend on execution order, shared mutable state, or another test's side effect. Can run alone and get the same result.
- Not a tautology — does not assert something the production code trivially guarantees by construction (e.g. asserting a constant equals itself, or mirroring the implementation's arithmetic instead of an independently-derived expected value).
- Deterministic — same result every run: no wall-clock reads, no unseeded randomness, no reliance on real-world timing.
- No network access — every external source is reached through a
domaininterface with a fake or fixture implementation in tests. See §4. - No real coordinates — fixtures use obviously synthetic values (e.g.
0.0, 0.0or clearly fake test coordinates), never a real address or a real GPS trace, perCLAUDE.md§5.
A test that is rejected in review:
- Assertion-free — runs code and asserts nothing, or only asserts "no exception was thrown" where an exception was never the risk being tested.
- Implementation-coupled — breaks when a private method is renamed or refactored without any change in observable behaviour (mock-heavy tests that assert call order/count on internals with no behavioural claim).
- Ignored or disabled —
@Ignore/@Disabled/commented-out to reach a green build. Forbidden outright byCLAUDE.md§10.8; a failing test is fixed or the change is not merged. - Sleep-based —
Thread.sleep/fixed delays to "wait" for async work instead of an explicit synchronisation point (test dispatcher, fake clock, deterministic scheduler, awaiting a well-defined signal). Sleep-based tests are slow and still flaky.
Every external source the app touches — the Waze-backed traffic/congestion endpoint (decision D10),
the OSM tile provider, geocoding/search, device GPS, and the Waze-backed routing engine (decision
D11) — is reached exclusively through a domain-owned interface. data supplies the real
implementation; tests supply a fake or a fixture-backed stub. This is what makes the rule
enforceable rather than aspirational. Tile source and geocoding provider remain open ADRs; traffic
and routing are decided and are named here rather than left as a placeholder for an eventual
choice.
No test may perform a real network call. Not to the traffic endpoint, not to the tile
provider, not to a geocoding provider, not to any relay/proxy, not to a routing service, not to
localhost standing in for one of these. A test that needs "real" data uses a recorded fixture
checked into version control, built from synthetic input.
These are not optional extras; they are the tests that make the privacy guarantees in
CLAUDE.md §5 and §5.1 and in docs/privacy.md checkable rather than asserted in prose. Every
feature that touches an outbound flow (traffic, tiles, geocoding, routing — traffic and routing
decided as Waze, decisions D10/D11; tiles and geocoding remain open ADRs) must have tests
demonstrating:
- Every outbound request goes through the relay path. A request built for one of these flows
is routed through the user-selected relay/proxy abstraction — never a code path that can reach
the network directly, bypassing the relay selection. This is the single highest-value test in
the suite per
CLAUDE.md§5.1. - Fail-closed relay behaviour is a regression-guarded property, not an assumption (decision
D1). A dedicated test asserts that no outbound call proceeds while
RelayConfigurationisNotChosen(surfacingRelayNotChosen), and that a configured-but-unreachable relay fails the call rather than silently falling back to a direct connection (surfacingRelayUnreachable, decision D6) — a test that must fail if fail-open behaviour were ever shipped by accident.docs/privacy.mdnames this a required part of the networking test suite, not an optional nicety, and it is distinct from the relay-path test above: that one checks relay is used, this one checks the app never proceeds without one being resolved one way or the other. - Coordinates are coarsened for the flows where that is possible — the traffic and tile flows
— to the precision the feature actually needs before they leave the
domain/databoundary outbound, verified by asserting the precision of what a fake transport actually receives, not by inspecting an intermediate value that is later discarded. Geocoding and routing are explicitly exempt from this test, mirroringdocs/specs/001-navigation-mvp.mdFR-26: a geocoding query must be sent at full text precision to work at all, and routing (Waze-backed, decision D11) needs precise origin/destination coordinates to route correctly, since it is a remote call by construction, not a hypothetical one. A test asserting coarsening on either of those two flows would be asserting behaviour the product does not, and must not, have. - No identifier is attached to an outbound request — no session token, no install UUID, no device fingerprint, no persistent correlation value across requests. Verified by asserting the fake transport receives no such field/header, across repeated calls.
- Nothing sensitive is logged. Log calls in code paths touching location, destination text, or request contents are exercised in tests with a fake logger, and the test asserts the logged content contains no coordinate, address, or search string — not merely that logging "was called".
- The app functions with location denied. Domain/data logic that depends on device position has an explicit denied/unavailable path tested to a defined fallback behaviour, not a crash or a silent no-op that leaves the user without feedback.
- On-disk response caches are bounded, evictable, clearable, and backup-excluded (decisions D7,
D9).
docs/privacy.md's doctrine that the privacy bar governs what leaves the device does not mean local caches are untested — the opposite: because they persist on disk, they carry their own testable claims. For each of the app's response caches (tile, geocoding, traffic), a test suite demonstrates:- A cache hit for a non-expired entry is served without a network call to the corresponding provider — including while the fake connectivity check reports offline, since a hit must not depend on connectivity.
- An entry whose TTL has elapsed is treated as a miss and re-fetched, not served stale.
- Writes that would exceed the cache's configured maximum size evict least-recently-used entries first, verified against the configured bound itself, not a hardcoded figure.
- An explicit clear-cache action removes all of a cache's entries; a subsequent request for a previously-cached item is a miss afterward.
- The cache's configured base directory resolves to Android's no-backup files directory
(
Context.getNoBackupFilesDir()), verified against a real or Robolectric-providedContext— not merely that the adapter writes to whatever directory it was constructed with, which would be tautological and would not verify the backup-exclusion claim at all. The geocoding cache is held to this same standard, not a looser one, because it is the most sensitive local artefact in the app (decision D9) — seedocs/specs/001-navigation-mvp.mdtests 49–53 (tile), 76–80 (geocoding), and 81–85 (traffic).
Per CLAUDE.md §4's resource discipline:
- No main-thread work for anything that touches I/O, parsing, or computation beyond trivial —
verified by structuring the code so blocking work is expressed in a
domain/datafunction callable from a test on a background dispatcher, and by review of the dispatcher used inpresentation. Where the chosen concurrency primitive supports it (pending the relevant ADR), a test asserts the call does not run on the main dispatcher. - Cancellation on lifecycle end. Work started for a screen or a location subscription is provably cancelled when its owning scope ends — tested by starting the work against a fake scope/dispatcher, ending the scope, and asserting no further side effect (no further emission, no further network call recorded against the fake transport) occurs afterward.
Some things do not reduce to a deterministic function call:
| Cannot be unit-tested | Verified instead by |
|---|---|
| Real map rendering, tile compositing on screen | Instrumented/UI test on the critical path only (§2.3), manual verification during review |
| Actual OS permission dialogs and their exact wording | Instrumented test driving the real permission flow, or manual verification |
| Real foreground-service notification behaviour under Doze (v0.2+) | Manual verification on a device/emulator; documented in the PR's test evidence |
| Actual battery/memory cost in the field | Manual profiling (Android Studio profiler or equivalent) attached as PR evidence when a change plausibly affects it, per CLAUDE.md §4 |
| Reproducible-build/F-Droid pipeline correctness | CI job once the ADR in adr/proposals/011-ci-reproducible-build-fdroid.md is decided and implemented — not a unit test |
None of these substitute for the tests in §1–§6 where those apply; they cover only what is structurally outside a deterministic test's reach.
The Gradle project is scaffolded (docs/adr/012-build-and-test-tooling.md,
docs/adr/010-module-layout.md): Gradle with the Kotlin DSL and a version catalog
(gradle/libs.versions.toml); JUnit4 as the test runner; kotlin.test for assertions;
kotlinx-coroutines-test plus Turbine for coroutine/Flow tests (added to a module once a test
actually needs them); Robolectric only where a test genuinely cannot avoid the Android framework;
androidx.test for instrumented critical paths; no mocking library (hand-written fakes of the domain
provider interfaces instead); ktlint and detekt, both blocking, run via ./gradlew ktlintCheck detekt.
Real, runnable commands today:
./gradlew :domain:test # domain unit tests — plain JVM, no Android SDK needed
./gradlew :domain:build # domain compile + test + ktlint + detekt
./gradlew ktlintCheck detekt # static analysis, both modules
:domain builds and tests with no Android SDK installed at all — that is the point of
docs/adr/010-module-layout.md. :app (the Android application module) additionally needs an
Android SDK on the machine (ANDROID_HOME/local.properties) to configure and build; it has no
Kotlin source yet (Stage G presentation tests are gated on adr/proposals/001-ui-toolkit.md).
CI's own execution of "all tests" is pending adr/proposals/011-ci-reproducible-build-fdroid.md —
the commands above are what a human or an agent runs locally today; nothing yet runs them
automatically on every push.
JDK requirement — read this before running anything. detekt fails on whatever JVM Gradle
itself runs on — the daemon's own JVM, not a compile/test toolchain — if that JVM is newer than
detekt 1.23.8's bundled compiler frontend can parse (verified: it fails on JDK 25 with Invalid value (25) passed to --jvm-target, and separately with an unparseable-version-string error deeper
in its embedded compiler). This is not something org.gradle.java.installations.paths or any
other toolchain property can fix: toolchain properties tell Gradle which JDK to use for a
compile or test task's toolchain; they say nothing about which JDK launches the Gradle daemon
process that runs detekt's analysis in-process. If your machine's default java resolves to
something newer than JDK 21, detekt — and therefore ./gradlew build, check, and any command
that includes it — will fail even with a JDK 21 also installed, unless you point the Gradle
launcher itself at that JDK 21.
The actual remedy: set JAVA_HOME to a JDK 21 installation before invoking ./gradlew, e.g.:
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew :domain:build ktlintCheck detekt
(adjust the path to wherever a JDK 21 is installed on your machine — update-java-alternatives -l
or your OS's JDK manager will show it). org.gradle.java.installations.auto-download=false in
gradle.properties means Gradle never silently fetches a JDK to work around this on your behalf —
it detects a locally installed one for toolchain resolution (:domain and :app both declare 21
as their required JVM target for that purpose) but will not launch its own daemon on a different
JDK than whatever JAVA_HOME/PATH resolves to. Every verified command in the build-skeleton PR
body sets JAVA_HOME explicitly for exactly this reason — do the same locally.
The build system, test-runner/assertion/mocking tooling, and module layout are decided
(docs/adr/012-build-and-test-tooling.md, docs/adr/010-module-layout.md, see §8). What remains
open, tracked as ADR proposals, not decided here:
docs/adr/proposals/001-ui-toolkit.md— affects what an instrumented/UI test targets.docs/adr/proposals/011-ci-reproducible-build-fdroid.md— affects how "all tests" are run in CI and what reproducibility verification looks like.
An agent or contributor needing one of these before its ADR is approved stops and asks, per
CLAUDE.md §0.2 — it does not pick silently.