Testing¶
textwire is tested in layers. Each layer proves one kind of thing, runs in one place, and
has one way to add to it. The rule behind all of them: make check passes with no network,
no Twilio account and no phone, and anything that needs those is marked and run by hand.
| Layer | Where | Runs in | Proves |
|---|---|---|---|
| Unit | server/tests/**, android/*/src/test/** |
make check, CI |
Each module does what its docstring says, at 100% line and branch coverage on the server, the pure-Kotlin modules and the app's logic (ADR-0009). |
| Property | the hypothesis tests among the unit tests |
make check, CI |
Round trips hold for every input: frames, alphabets, envelopes, requests, pagination, plain splitting, GSM-7 sanitising. |
| Golden vectors | protocol/vectors/, server/tests/vectors/, android/protocol/src/test/ |
make check, CI |
Python and Kotlin agree on every byte of the wire format, and the committed files match the reference (make vectors-check). |
| End to end, in process | server/tests/simulate/, server/tests/service/test_dispatcher.py |
make check, CI |
The real server, the real pipeline and the virtual phone agree over the fake transport: searches, pages, links, plain mode, dropped frames and resends, both alphabets. |
| Contract | server/tests/transport/test_twilio.py |
make check, CI |
The Twilio client sends the right requests and reads the fields the server depends on, against recorded response shapes. |
| Repository rules | server/tests/test_repo_rules.py |
make check, CI |
File sizes, no coverage pragmas, versions agree, every ADR is indexed, every internal link in the docs resolves. |
| Screens | android/app/src/test/**/ui/UiTest.kt |
make check, CI |
Every screen of the app does what a person expects, at the Galaxy S21 Ultra's screen size and again on a small phone, where every control must still be reachable. |
| Browser scripts | server/tests/js/, run by server/tests/test_javascript.py |
make check, CI |
The dashboard's script and the site's app.js, run in Node against a stand-in page: what a phone texted is escaped, the budget bar, the probe form, the menu, the Copy buttons. |
| The site | server/tests/test_check_site.py, server/tests/test_site.py |
make check, CI |
The landing page has no broken anchor, link, asset or Copy button and no draft text; the handbook builds strictly; every link from one into the other resolves. |
| Live | server/tests/live/ (marker live) |
by hand, make -C server test-live |
One real SMS reaches a real phone through a real account. Costs money; never in CI. |
| Device acceptance | ACCEPTANCE.md | by hand, on the phone | What only a real route can show: latency, delivery, the alphabet probe, background receipt, recovery after a kill. |
Running the server suite¶
make -C server check # everything, exactly as CI runs it
cd server
uv run python -P -m pytest tests/protocol -q # one package
uv run python -P -m pytest tests/service/test_handler.py -q # one file
uv run python -P -m pytest -k "resend" -q # by name
uv run python -P -m pytest --cov --cov-report=html # then open htmlcov/index.html
-P keeps sys.path the same as CI's. On the reference Windows machine, run the full suite
in the background and keep working: it takes about two minutes, and a foreground run blocks
the session. Never edit a source file while a coverage run is in progress: the report then
lines up old line numbers with the new file and a complete module reads as 72%.
Running the Android suite¶
cd android
./gradlew check # ktlint, unit tests, coverage floors, for every module
./gradlew :protocol:test # one module
./gradlew ktlintFormat # fix layout before ktlintCheck complains
:protocol and :core are held at 100% line and branch by JaCoCo. In :app, everything
outside the ui package is held at 100% line and branch too, and the Compose screens at 99%
of lines (ADR-0009 says which four lines no test can reach, and why screens carry no branch
floor). The report is app/build/reports/jacoco/jacocoAppReport/html/index.html.
The app's tests run under Robolectric at the Galaxy S21 Ultra's screen size
(app/src/test/resources/robolectric.properties). One test repeats the important controls
on a 320 by 480 phone, where they must still be reachable by scrolling. MainActivityTest
drives the real activity over the real application, including the permission dialog's
answer; SmsTest feeds the receiver real SMS-DELIVER PDUs.
How the fakes work¶
Every seam to the outside world is a Protocol with a hand-written fake in the same package
or next to it, and the real thing is only ever reached through that seam:
| Seam | Interface | Fake | Real |
|---|---|---|---|
| The web | content.fetch.Fetcher |
content.fakes.FakeFetcher (a table of URLs; unknown is 404) |
HttpxFetcher |
| Name resolution | content.fetch.Resolver |
a table in the fetch tests | SystemResolver |
| Search | content.search.SearchProvider |
content.fakes.FakeSearchProvider |
DdgsSearchProvider |
| SMS | transport.base.Transport |
transport.fake.FakeTransport (an inbox and an outbox) |
TwilioTransport |
| Time | clock.Clock |
clock.FakeClock (moves only when told; sleep returns at once) |
SystemClock |
| The phone | none; it is the other end | simulate.phone.VirtualPhone |
the Android app |
content.fakes.load_fixtures builds a fetcher and a search provider from
protocol/fixtures/index.json, so tests and the offline simulator read the same recorded
pages. There are no mocking libraries on the server; respx is used only to stand in for
Twilio's HTTP endpoints in the contract tests.
The golden vectors¶
protocol/vectors/ is generated from the Python reference by make vectors and committed.
make vectors-check (part of make check) fails when the generator's output and the files
differ in either direction. Every case is one JSON file; manifest.json carries a hash of
each. The Kotlin build translates the frame, envelope and request cases and the constants in
ids.json into tab-separated and properties resources at test time (GoldenVectorsTask and
IdsPropertiesTask in android/build-logic), so the app module needs no JSON library. The
Kotlin suite asserts frames both ways, envelopes decode-only (with the packaged dictionary,
whose hash it checks against ids.json), and requests format-only, since the phone never
parses one.
To change the wire format:
- Change the Python implementation and its tests.
- Edit
protocol/PROTOCOL.mdto say the new rule. make vectors, then read the diff: it is the review surface. A vector that changed for a reason you did not intend is a bug.- Change the Kotlin implementation until its vector test passes.
- Commit all of it in one pull request.
Compressed vectors are asserted both ways in Python (the pinned zstandard reproduces the
committed bytes) and decode-only in Kotlin, so a library upgrade shows up as a vector diff
to regenerate deliberately.
Adding a test¶
- A bug fix starts with a failing test whose name says what the defect was:
test_a_reused_tag_forgets_what_it_meant_before. The test is the commit's first hunk. - Name the behaviour, not the method.
test_strangers_get_nothing_and_cost_nothing, nottest_handle_unknown_sender. - Phone numbers come from
tests/conftest.py: Ofcom's drama range, so no test can text a real person. - Time comes from the
clockfixture, aFakeClockat 2026-09-30 12:00 UTC. - Handler tests use
tests/service/conftest.py'sbuild_rig: recorded fixtures, an in-memory store, a budget, andrig.ask("a7 g https://...")to handle one SMS. - Coverage is 100% or the build fails. A branch that cannot be reached is a branch to delete, not to exclude.
Live tests¶
server/tests/live/ sends real SMS through the account in server/.env. They are marked
live, deselected by default (-m 'not live' in pyproject.toml), and run only by hand:
TEXTWIRE_LIVE_TARGET=+447700900123 make -C server test-live
Record the result, the date and the cost in ACCEPTANCE.md.
The browser scripts¶
Two pieces of JavaScript ship: the script inside the dashboard page
(server/src/textwire/api/dashboard.py) and the landing page's site/app.js. Neither has a
build step, so their tests have none either: server/tests/js/dom.mjs is a small stand-in
for the parts of a page they touch, and the tests use only node:test.
cd server
uv run python -P -m pytest tests/test_javascript.py -q # as the suite runs them
test_javascript.py cuts the script out of the page the server really serves, so the test
cannot drift from what ships. Without Node the two tests skip locally and say so; in CI a
missing Node is a failure.
The performance baseline¶
make bench measures what every recorded page costs in SMS and how fast the server makes
it, and compares both with the committed baseline. A page that
now takes more SMS fails it. make bench-record writes a new baseline; commit it with the
change, and its diff is the review. The suite's test_bench.py keeps the baseline's cost
half equal to what the code sends.
The site¶
make site builds the whole GitHub Pages site into build/site: MkDocs writes the handbook
into build/site/handbook, the files in site/ are copied in beside it, and
server/scripts/check_site.py checks the result. The same checker runs on site/ alone in
the test suite, where links into the handbook are taken on trust because the handbook is not
built yet. To look at the result, python -m http.server -d build/site and open the address
it prints; make site-serve serves the handbook alone with live reload.