Lesson 31 of 34 intermediate 6 min read

Before this:Integration testsGolden files & fixtures

Replay: testing a radio without a radio

Key takeaways Live radio is the worst possible test input: unrepeatable, uncontrollable, and different at every desk. GopherTrunk’s answer is replay: record the raw IQ samples an SDR produces into a capture file, then feed that file through the same decode pipeline as live radio — same signal, bit for bit, every run, on any machine. It’s the fixture idea at system scale, and it makes whole-decoder behavior deterministic: assertable in CI, bisectable, and shareable — a user’s capture file turns “it garbles sometimes” into a bug that fails identically on a developer’s desk.

Everything Unit 3 said about integration testing meets its hardest customer here: how do you write a repeatable test for a system whose input is the radio spectrum? The answer is one of GopherTrunk’s central engineering moves, and the pattern transfers to any system that eats an unrepeatable stream.

The problem: the air never says the same thing twice

Test a decoder against live radio and every principle from this module breaks at once. Unrepeatable: the transmission that triggered the bug is gone forever; no failing run can be re-run. Uncontrollable: you can’t ask the local network to transmit a truncated burst so you can test that branch. Unportable: a test needing an antenna and a nearby TETRA network runs on approximately one desk on Earth — and never in CI. Nondeterministic: signal strength, fading, and interference differ minute to minute — the definition of a flaky test, imposed by physics.

The move: record the samples, replay the file

An SDR hands software a stream of raw IQ samples — numbers describing the received signal itself, before any decoding (the RF & SDR module explains the format). Write that stream to a capture file and you’ve frozen the radio moment: not a description of what happened, but the actual input, replayable forever.

gophertrunk replay -in tetra_cc_2s_144k.cs16 -format cs16

The decode pipeline neither knows nor cares that its samples come from a file instead of an antenna — same filters, same demodulator, same protocol decoders. That “same pipeline” property is load-bearing: a replay test exercises the real code path, so what it proves is what live operation gets. Determinism follows: the file contains the same bits every time, so decoding is repeatable down to the frame count — a signal that faded at 3.2 seconds fades at 3.2 seconds on every run, on every machine, forever.

Captures as fixtures: the suite this enables

With determinism secured, the whole testing toolkit applies to an entire radio system at once. A replay integration test is arrange-act-assert where “arrange” is a file in testdata/:

func TestReplayDecodesControlChannel(t *testing.T) {
    stats := replayCapture(t, "testdata/tetra_cc_sync_loss_2s_144k.cs16")

    // Deterministic input ⇒ exact expectations, like any fixture test:
    if stats.BSCHValid < 140 {
        t.Errorf("decoded %d valid BSCH bursts, want >= 140", stats.BSCHValid)
    }
}

Three properties make capture fixtures more than big test files:

  • They preserve hard-won conditions. A capture of a weak, distorted, barely-decodable signal is irreplaceable — you can’t order those conditions up again. GopherTrunk’s suite keeps exactly such captures: the marginal signal that once broke the decoder, now pinned as the input a fix must handle, forever.
  • They’re the bug-report gold standard. A user whose reception garbles “sometimes” sends the capture; the developer replays it and sees the same garble on the same frame. Reproduction — Unit 5’s hardest step — arrives as an email attachment.
  • They feed every tool downstream. A capture-driven check is a command that exits pass/fail — exactly what git bisect run wants, and exactly what regression testing wants: fix a decode bug, and the triggering capture joins the suite as the failing-first test.

Because whole-capture decoding takes seconds to minutes per file, replay suites live on the integration tier — GopherTrunk’s make integration, not the per-commit make vet test gate — with the heaviest capture harnesses skip-guarded behind environment variables naming a capture file, so the suite passes cleanly on machines without the (large) files and digs deep on machines with them.

Rule of thumb: any system fed by an unrepeatable stream — radio, market data, sensors, user traffic — becomes testable the same way: record the stream at the boundary, replay the recording as a fixture.

One caveat keeps this honest, and it sets up the next lesson. Replay proves the decoder handles that captured signal deterministically. It says nothing about signals you never captured — and if your captures are all clean, strong, and well-formed, the suite is green over a decoder that fails on the real world’s margins. Worse still is testing against signals you synthesized yourself…

Quick check: what makes a replay test deterministic when live radio testing never is?

Recap

  • Live radio input is unrepeatable, uncontrollable, unportable, nondeterministic — untestable by every standard in this module.
  • Replay freezes the boundary: record raw IQ samples to a capture file, feed it through the same decode pipeline as live radio.
  • Captures are fixtures at system scale: exact expectations, preserved hard-won signal conditions, and bug reports that reproduce on any desk.
  • Replay checks power the whole toolkit — CI, bisect run, failing-first regression tests — from the make integration tier.
  • Limit: replay proves behavior on captured signals only — which is why the next lesson’s trap matters.

Next up: The self-consistent synthetic trap