Files
firmware/test/README.md
Tom bca7c0b480 Tom fiddles with the test suite - again (#11517)
* test: make every suite run its own binary, and fail the run when it does not

PlatformIO links every native test program to the one $BUILD_DIR/$PROGNAME path and
attributes Unity output by text alone, never checking that the source file a case came
from belongs to the suite it thinks it ran. Both harnesses had been split into a build
pass (--without-testing) and a run pass (--without-building), and for a non-embedded
platform the run pass never relinks - so all 57 suites executed whichever suite was
linked last, each reporting PASSED under its own name. Introduced for CI in 4906f8a6
and for bin/run-tests.sh in de6b2319; both ran fused, and correctly, before that.

Drop --without-building from both run passes. The --without-testing pass stays as a
warm-up so no single suite absorbs the whole src compile in its reported duration; with
the objects already cached the per-suite step is one test_main.cpp plus a link.

Add bin/check-test-attribution.py, which grades the JUnit reports both harnesses already
produce. It fails on a test case whose source file lies outside the suite that reported
it, and on a suite that was asked to run and produced no cases at all. Wired in three
places: bin/run-tests.sh as a RED verdict ahead of the softer ones, per area in CI so a
mismatch names its area, and once over the merged report so an area that never executed
cannot hide. Suite ownership is matched on whole path segments, so test_mesh does not
claim test_mesh_module, and the -f pattern is resolved against the canonical set rather
than taken as a literal suite name.

* fix(test): pin simradio off for the packet-signing PKI cases

[env:coverage] passes -s to the test binary (74e6723ad, #8251), which sets
portduino_config.force_simradio. wouldEncryptWithPKC() lists !force_simradio among its
preconditions, so perhapsEncode() takes the channel-crypto branch, returns NONE and leaves
pki_encrypted false - failing test_B11_normal_unicast_still_uses_pki and
test_B12_licensed_receiver_does_not_decrypt_pki, both of which assert the production PKI
path. [env:native] passes no such flag, which is the whole of the long-standing
"passes under native, fails under coverage" split; it was never gcov, ASan or a host.

Save and clear the flag in setUp, restore it in tearDown, so the suite asserts the encode
path it is named for under either env's invocation. Same binary, pristine $HOME: 77 tests
0 failures with -s and without, where before -s gave 2 failures.

Whether the unit-test binary should run with -s at all is a separate question - it means CI
exercises the simradio configuration for every suite - and is left alone here.

* fix(router): drive the admin-key fallback budget from the injectable clock

The budget is 8 tokens refilling one per 250ms of wall clock, and
test_admin_key_fallback_is_rate_limited drains it with eight PKI decodes before asserting the
ninth is refused. That gives the drain loop 31ms per iteration, each of which generates a
keypair and does three X25519 operations under gcov and ASan. This box runs them in ~4ms;
a GitHub runner takes ~38ms, so a token refills mid-drain and the packet the test expects to
be blocked decodes. Measured from both runs' own log timestamps, 9.5x apart.

Read the bucket through Time::getMillis() instead of millis(), and have the test set and
advance the virtual clock rather than sleeping. The subtraction was already wrap-correct, so
the deadline guard is unaffected. Restores the clock in tearDown so the rest of the suite is
untouched, and drops ~3s of real sleeping from the run.

* test: declare the event-channel suites' shared state

Both construct a NodeDB, whose constructor persists a default set into an empty prefs
directory, so each writes the five prefs protos. Neither was declared, because until suites
started running their own binaries nothing had ever observed them writing anything.

* test: add a repeat runner for order-independent flakes

A single green run says nothing about a real-time race or a slow-host margin: the rate-limit
budget above passes here with 7x headroom and still fails on a CI runner. Run one suite N
times against a fresh scratch $HOME each time, optionally against CPU contention, and print a
flake rate. Failing runs keep their log and their sandbox; passing runs leave nothing.

Simradio is taken from the env's own test_testing_command, so a stress run reproduces the
real invocation rather than inventing a third one.

* fix(test): keep a native test run off the host's radio

bin/pio-test-isolate.sh sandboxes $HOME, but portduinoSetup() looks for config in
./config.yaml and /etc/meshtasticd/config.yaml - the second absolute, so no $HOME sandbox
can hide it. On a machine running meshtasticd that config selects the real LoRa module and
the run continues into GPIO and SPI setup, so ./bin/run-tests.sh -e native would drive the
developer's own radio without saying so. -e native is also the faster of the two, and the
one reached for when iterating.

[env:coverage] already passes -s, which short-circuits ahead of the config search and returns
before hardware init. Pass it for [env:native] too. That closes the hazard and, incidentally,
makes the two envs invoke the binary identically - they did not, which is the whole of the
long-standing "green locally, red in CI" split.

* test: run every suite with PKC on, and assert it stays that way

force_simradio does two unrelated jobs. It keeps portduinoSetup() off the host's hardware,
which every test run wants, and it makes wouldEncryptWithPKC() return false, which no test
run wants: the encode path under test then falls back to channel crypto and any case
asserting PKI fails, or worse, passes while asserting the wrong thing.

Three suites had each worked this out separately and cleared the flag themselves -
test_admin_session_repro's comment describes the mechanism exactly. Clear it once in
initializeTestEnvironment() instead. By then portduinoSetup() has already skipped the config
search and chosen the simulated radio, and it never reconsults the flag, so clearing it
cannot bring hardware back; the only remaining readers are the PKC gate and an
exit_simulator intercept no test can reach. The per-suite copy added to test_packet_signing
for B11/B12 goes away with it.

Two asserts, because both invariants were true only by inspection:

- No listening sockets. main.cpp's setup()/loop() are compiled out under PIO_UNIT_TESTING, so
  the phone API, MQTT and the web server never start - but nothing checked. A suite that
  pulled in a service binding a port would open one on the developer's machine for the length
  of the run.
- force_simradio still clear, before every test rather than once per suite, since a case that
  restores a struct it snapshotted earlier puts it back and silently disables PKC for
  everything after it. Named per test, so the report points at the case after the culprit.

Both exit rather than TEST_FAIL: they run outside a Unity test frame, and silently repairing
either one would leave the suite that broke it passing. Verified by disabling the clear and
watching the guard fire on the first case instead of reporting two quiet failures.

* test: let the repeat runner vary suite order too

Repeating one binary finds races and slow-host margins; it cannot find state that leaks from
one suite into the next, because only one suite runs. --shuffle drives run-tests.sh --seed
with a fresh seed each iteration and reports which seeds went red, so the shuffle already in
the harness yields a flake rate rather than a single sample. Seeds are printed and replayable.

* fix(test): baseline the environment from whichever runs first

Clearing force_simradio in initializeTestEnvironment() missed the suites that never call it.
test_atak is one, and it also pulls in TestUtil.h, so it got the per-test assert without ever
getting the baseline and aborted on its first case - caught by CI, which is what the assert is
for. test_geocoord_distance, test_meshpacket_serializer and test_utf8 skip the init too, but
include no TestUtil.h at all, so nothing reached them either way.

Move the clear and the socket check into baselineEnvironment(), called from
initializeTestEnvironment() or from the first RUN_TEST, whichever comes first. Suites that
initialise are still asserted from their first case; the rest are baselined at case one and
asserted from case two.

Print the violation on stdout as well as stderr: bin/run-tests.sh filters the program's
stderr, so locally the message vanished and the run reported "exit-time abort (likely
sanitizer)" - the exit code read as a signal number again, with no sign of the real reason.

* test: drop the per-suite simradio exceptions

Three suites had each found that force_simradio disables PKC and cleared it themselves.
initializeTestEnvironment() now clears it once for every suite, so all six sites are dead
code - along with the PortduinoGlue.h include each pulled in for it.

test_event_channel_router's is the one worth removing rather than leaving: it snapshotted the
flag into SavedGlobals and restored it at teardown, which is exactly the shape the per-test
assert exists to catch. Harmless while the snapshot reads false, and a silent PKC-off for
every later case if that ever changed.

The three suites pass unchanged: 54 cases, attribution clean.

* test: tell a deliberate harness abort from a sanitizer fault

A guard in TestUtil.cpp that aborts on purpose - a listening socket, or force_simradio put
back - exits non-zero with no sanitizer report, so it fell through to the exit-time-abort
heuristic and was announced as "RED exit-time abort (tests passed; likely sanitizer)". That
is the same trap as the phantom SIGILL two checks above: a verdict line naming a cause it has
not established, sending the reader after a memory bug that does not exist. It cost hours in
the original investigation and it cost the first read of a test_atak failure today.

Match the FATAL line the guards print on stdout for exactly this purpose, and report the
reason they gave instead of guessing.

* test: say why three suites omit TestUtil.h

They are pure-function - no NodeDB, no router, no sockets, no PKC - so the harness-wide guards
in TestUtil.h would assert conditions they cannot reach, and initializeTestEnvironment()'s RTC
and OSThread setup would pull in portduino globals they otherwise never touch. Suite-level
state cleanliness still applies: bin/pio-test-isolate.sh fingerprints the sandbox from outside
and wraps every suite regardless.

Recorded at the top of each so the omission reads as a decision rather than an oversight - it
looked like the latter when the socket and simradio asserts landed.

* test(traffic): give every case a primary channel

resetTrafficConfig() zeroed channelFile and left channels_count at 0, so the 66 cases that do
not install a channel themselves ran against a device with none. Every router lookup then hit
Channels::getByIndex()'s out-of-range branch and logged, which is 12106 of the suite's 20088
ERROR lines and tests nothing - a real device always has a primary channel, and no case here
asserts channels-unset behaviour.

Install the well-known primary the suite already builds for its precision cases. All 85 pass
unchanged, and the suite's ERROR output drops to 7985, the remainder being decode failures
from test_tm_fuzz_nodenum_blitz's malformed payloads.

* test: budget each suite's LOG_ERROR output

A suite can pass while emitting six figures of ERROR, which buries a real failure and trains
everyone to skim. Count them per suite and grade the count as a second axis, alongside the
CLEAN/DIRTY verdict already computed from the same captured log.

Declared in the same manifest, as a RANGE rather than a ceiling, because for a fuzz suite the
floor is the half that matters: test_fuzz_decode logging ~100k rejections is the suite
working, and the same suite logging none means it stopped feeding malformed input while every
case still passes. Bounds are wide on purpose - they catch a path that has stopped running,
not a drift of a few hundred lines. Undeclared suites get 100, which 50 of 57 already meet.

AMBER, not RED. Three log sites - mesh-pb-constants.cpp:28, Channels.cpp:356, MQTT.cpp:92 -
account for nearly all the remaining volume, and landing this red before they are demoted
would buy exemptions rather than fixes.

* test: canary the attribution check, and run the state self-test in CI

check-test-attribution.py guards against the false green, and nothing guarded the guard. A
checker that has quietly stopped matching looks exactly like a codebase with no problem, which
is how the original went unnoticed for three weeks of green runs.

The canary reproduces the failure deliberately - two suites run with --without-building, so
PlatformIO does not relink and both execute the same leftover binary - and requires the
checker to catch it. It also fails if the reproduction stops reproducing: if PlatformIO ever
relinks per suite under that flag, the reason both harnesses stopped passing it no longer
holds, and the harness should be revisited rather than left on a stale assumption.

bin/test-state-check.sh already existed with fixtures asserting CLEAN/CLEAN/DIRTY/MISSING and
had never run in CI. Wire it in too - the shared-state checker had the same blind spot, and
somebody had already written the test for it.

* fix(ci): run the attribution canary where it cannot clobber the daemon

The canary relinks $BUILD_DIR/$PROGNAME, and in simulator-tests that replaced the daemon
binary with a test suite. The integration test then started it and waited for a listening
socket, which a test binary never opens - by assertion, since initializeTestEnvironment()
now fails a suite that holds one - so the step sat until its 20s timeout and the job exited
124. The canary itself had already passed.

Move it to platformio-tests, where the binary is per-suite already and nothing downstream
needs the daemon, and place it after the coverage capture so its extra runs stay out of the
numbers. The shared-state self-test stays in simulator-tests; it touches no binary.

Fitting failure mode for this branch: one shared program path, two consumers, and the second
one silently getting the first one's build.

* fix(ci): silence the XXE rule on the attribution checker

semgrep blocks xml.etree.ElementTree.parse as XXE-prone. The input here is the JUnit report
PlatformIO wrote moments earlier in the same run, and anything able to plant a hostile report
is already executing its own code in that job, so parsing it defused changes nothing it could
do. defusedxml is in the tree but only under bin/bump_metainfo with its own requirements, and
pulling it onto this path would add an install step to every native test job for no reachable
threat.

Suppressed with a reason at the call site, the same shape as the subprocess-shell-true
suppression in extra_scripts/nrf54l15_linker.py.

* fix(test): address the review findings on the harness guards

Two were real defects rather than style:

- state_count_errors() returned "0\n0" for a log with no ERROR lines, because grep -c prints 0
  and *then* exits 1, so the `|| printf 0` fallback appended a second one. The classifier threw
  a syntax error on it. Dormant only because every suite currently emits at least one ERROR
  line; the planned log-level demotions would have driven most suites to zero and tripped it
  everywhere, looking like the demotions broke the harness.
- check-test-attribution.py returned OK for a report whose cases carry no `file` attribute. It
  cannot prove ownership in that state, so a changed JUnit format would have restored the exact
  false green it exists to catch. Now its own finding, listed and fatal.

The rest: keep the sandbox when an error budget is breached, since that is the one outcome
whose evidence was being deleted; reject a missing or non-numeric option value in
stress-suite.sh instead of running an empty loop and reporting 0/0 as a pass; exit on INT/TERM
rather than cleaning up and carrying on; drive repetitions through pio-test-isolate.sh so a
stress run exercises the real invocation; require the canary to see MISATTRIBUTED rather than
any non-zero exit, so an unreadable report cannot read as a caught mismatch; and check for
listening sockets before every test, since a listener would be opened by the code under test.

resetAdminKeyFallbackBudget() is a new PIO_UNIT_TESTING hook, shaped like the neighbouring
resetRoutingAuthEvaluationCount(). The refill stamp is only meaningful against the clock that
produced it, so a suite switching timebases leaves a stamp from the other one and the next
unsigned subtraction reads as a near-infinite gap - silently refilling the bucket.

Also move the semgrep marker onto its own line: buried mid-sentence in a comment it was
ignored, and the XXE finding stayed blocking.
2026-08-16 11:34:02 +00:00

23 KiB

Native Unit Tests - Authoring Guide

This directory contains C++ unit tests that run on the host machine via PlatformIO's native environment. Tests use the Unity framework.

Running Tests

Preferred: use bin/run-tests.sh - it defaults to the coverage env, cross-checks the number of suites that actually ran, and emits an unambiguous RED/AMBER/GREEN verdict:

./bin/run-tests.sh                          # all suites
./bin/run-tests.sh -f test_traffic_management  # single suite
./bin/run-tests.sh -f test_traffic_management > /tmp/test_out.txt 2>&1; tail -5 /tmp/test_out.txt

Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER, 3 = FILTERED.

The harness is Linux-only, by choice. bin/run-tests.sh and the per-suite isolation it drives need bash 4+ and GNU coreutils/find (find -printf, md5sum), and the script refuses to start anywhere else rather than degrade quietly - a shared-state check that silently mis-hashes a sandbox still prints a verdict, and that verdict would be worthless. The native-macos PlatformIO env is a build target for meshtasticd, not a test host; the isolation wrapper is registered for env:native and env:coverage only. On macOS or Windows, run the suite in a container: ./bin/test-native-docker.sh.

-f is not a gate. A filtered run can pass while a full run fails, because filtering removes the suites that create the state a later suite trips over. Iterate with -f; gate on a full run.

Sanitizers are per env. coverage (the default) has ASan/LSan; native has none, verified. -e native runs are not sanitized.

A signal name in the output is not a crash. exit(UNITY_END()) returns the failure count and PlatformIO renders it as a signal number (4 -> SIGILL, 5 -> SIGTRAP), reporting the suite [ERRORED]. Match it against the failure count before assuming a fault.

Suite order is randomisable, and reproducible. --shuffle runs the suites in a seeded random order; --seed <n> replays an exact one. The seed defaults to the commit SHA - one order per commit, so a red is replayable and attributable rather than flaky - and is printed at the start of the run and on the RESULT: line. On failure the full order is printed, because for an order-dependent failure the order is the diagnostic. A single green seed is not evidence of order independence; vary it.

./bin/run-tests.sh --shuffle              # seed from HEAD, printed
./bin/run-tests.sh --seed 2855893161      # replay that exact order

Randomisation costs one pio invocation per suite (about 4.7s each), because PlatformIO orders suites by its own directory walk and -f only selects.

Copilot interface note: When running tests via the Copilot chat interface, edits made through the chat may not be reflected in the on-disk files that the test binary reads. If tests pass in chat but fail locally (or vice versa), verify the files on disk match what you expect before trusting the result. Always confirm with a local terminal run.

Never add --without-building to a test run. PlatformIO links every native test program to the single $BUILD_DIR/$PROGNAME path and attributes Unity output by text alone, so a run that only builds beforehand executes whichever suite was linked last under every suite's name - all reporting PASSED. Build once with --without-testing to warm the shared src objects if you like; the run itself must still build. bin/check-test-attribution.py grades the JUnit reports for exactly this and is wired into both bin/run-tests.sh (RED) and CI.

Raw pio test (no sanitizers, no verdict logic) - use when you need to override the env or inspect verbose Unity output:

# All test suites
pio test -e native

# Single suite
pio test -e native -f test_your_module

# Verbose (shows build errors in detail)
pio test -e native -f test_your_module -vvv

Never pipe through | tail -N to shorten output. PlatformIO prints build errors at the top of output and test results at the bottom; tail will show stale cached results from a prior successful build while hiding the compile error that caused the current run to fail.

Preferred pattern for raw pio - redirect to file, then grep:

# Redirect all output to a file; grep for errors and results after it exits
pio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
echo "exit: $?"
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
tail -15 /tmp/test_out.txt

Why: piping through | grep line-buffers the output and suppresses all progress until the process exits, making it look hung. The redirect approach lets the build stream normally while still giving you filtered results afterwards.

Viewing verbose test output without truncation (e.g. TEST_MESSAGE group headers):

/tmp/meshtastic-pio-venv/bin/python -m platformio test -e coverage --filter test_mesh_beacon -vv 2>&1 | grep -v "[[:space:]]SKIPPED$"

The -vv flag makes Unity emit INFO: lines from TEST_MESSAGE calls; piping through grep -v SKIPPED removes the noise from platform feature gates while keeping all PASS/FAIL/INFO lines visible.

externally-managed-environment error on Ubuntu/Debian:

If pio test fails immediately with error: externally-managed-environment, the system pio binary is using the OS Python which newer distros lock down. Use PlatformIO's own venv instead:

~/.platformio/penv/bin/python -m platformio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
tail -15 /tmp/test_out.txt

Helper Scripts (Useful Shortcuts)

These wrappers are handy when local host dependencies are missing or when you want repeatable commands.

# Run native tests in Docker (recommended on macOS / non-Linux hosts)
./bin/test-native-docker.sh

# Pass normal PlatformIO test args through to Dockerized test run
./bin/test-native-docker.sh -f test_your_module

# Force Docker image rebuild (after dependency changes)
./bin/test-native-docker.sh --rebuild

# Run simulator integration check (build native first)
pio run -e native && ./bin/test-simulator.sh

# Build and run meshtasticd natively
./bin/native-run.sh

# Build and run under gdbserver on localhost:2345
./bin/native-gdbserver.sh

# Build native release artifact into ./release/
./bin/build-native.sh native

Notes:

  • The repository script name is ./bin/test-simulator.sh (there is no test-native-simulator.sh).
  • ./bin/test-native-docker.sh is the closest match to CI behavior for native tests and avoids host package setup.

System Dependencies (Ubuntu/Debian)

The native build requires several system libraries. Install them all at once:

sudo apt-get install -y \
  libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \
  libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev

See .github/actions/setup-native/action.yml for the canonical list.

Creating a New Test Suite

1. Directory Structure

test/test_your_module/test_main.cpp

One file per suite. No per-test platformio.ini is needed - tests build under the [env:native] environment defined in the root platformio.ini.

2. File Skeleton

#include "MeshTypes.h"      // Include BEFORE TestUtil.h (provides NodeNum, etc.)
#include "TestUtil.h"        // initializeTestEnvironment(), testDelay()
#include <unity.h>

#if YOUR_FEATURE_GUARD       // Same #if guard as the module under test

#include "FSCommon.h"
#include "gps/RTC.h"
#include "mesh/NodeDB.h"
#include "modules/YourModule.h"
#include <cstdio>    // required for printf() - used for blank-line group separators
#include <cstring>
#include <memory>

// --- Test output helpers ---
// printf() writes directly to stdout and appears in -vv output as a plain line (no prefix).
// Use it for blank-line group separators: printf("\n");
// TEST_MESSAGE() emits a "file:line:INFO: <text>" line - visible at -vv and above.
// Use TEST_MSG_FMT for formatted diagnostic lines inside tests.
#define MSG_BUF_LEN 200
#define TEST_MSG_FMT(fmt, ...) do { \
    char _buf[MSG_BUF_LEN]; \
    snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
    TEST_MESSAGE(_buf); \
} while(0)

// --- Tests ---

void test_example()
{
    TEST_MESSAGE("=== Example test ===");
    TEST_ASSERT_TRUE(true);
}

// --- Unity lifecycle ---

void setUp(void) { /* runs before every test */ }
void tearDown(void) { /* runs after every test */ }

void setup()
{
    initializeTestEnvironment();   // MUST call - sets up RTC, OSThread, console
    UNITY_BEGIN();

    printf("\n=== Example group ===\n");           // header line to help find tests

    RUN_TEST(test_example);
    exit(UNITY_END());             // REQUIRED - a bare UNITY_END() leaves the process running
}

void loop() {}

#else // !YOUR_FEATURE_GUARD

void setUp(void) {}
void tearDown(void) {}

void setup()
{
    initializeTestEnvironment();
    UNITY_BEGIN();
    exit(UNITY_END());
}

void loop() {}

#endif

3. Terminate with exit(UNITY_END()), on every branch

A bare UNITY_END() does not end the suite - it ends the reporting. setup() returns, the runtime goes on calling loop(), and the process runs forever. PlatformIO does not notice: it reads the Unity summary off stdout, reports the suite PASSED and moves to the next one, so the run is green while the binary is still resident. Nothing surfaces it, and the leak is one process per suite per run.

The consequences are worse than an idle process:

  • The per-suite sandbox is deleted underneath a live process, so its CLEAN/DIRTY verdict says what the suite had written by the time the harness stopped looking, not what it left behind.
  • .gcda coverage data and LeakSanitizer's report are both flushed by atexit handlers, so a suite that never exits contributes no coverage and gets no leak check - silently.
  • Each survivor pins its own deleted binary on disk (~94 MB), which du cannot see.

So: exit(UNITY_END()) in every setup() branch, including the #else of a feature or architecture guard where the suite does nothing. The empty-suite branch is the easiest one to get wrong, because it looks like there is nothing to clean up.

4. Feature Guard

Wrap the entire test body in the same #if guard the module uses (e.g. #if HAS_VARIABLE_HOPS, #if !MESHTASTIC_EXCLUDE_GPS). When the feature is disabled, the #else branch produces an empty passing suite.

Common Patterns

MockNodeDB

Most module tests need to inject nodes with controlled hop distances and ages:

class MockNodeDB : public NodeDB
{
  public:
    void clearTestNodes()
    {
        testNodes.clear();
        numMeshNodes = 0;
    }

    void addTestNode(NodeNum num, uint8_t hopsAway, bool hasHops,
                     uint32_t ageSecs, bool viaMqtt = false)
    {
        meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
        node.num = num;
        node.has_hops_away = hasHops;
        node.hops_away = hopsAway;
        nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_VIA_MQTT_MASK, viaMqtt);
        node.last_heard = getTime() - ageSecs;
        testNodes.push_back(node);
        meshNodes = &testNodes;
        numMeshNodes = testNodes.size();
    }

    std::vector<meshtastic_NodeInfoLite> testNodes;
};

static MockNodeDB *mockNodeDB = nullptr;

Set nodeDB = mockNodeDB; in setUp().

Test Shim (Exposing Protected/Private Members)

Subclass the module under test to make protected methods callable and private members writable:

class YourModuleTestShim : public YourModule
{
  public:
    // Pull protected methods into public scope via using.
    // IMPORTANT: using requires the method to be protected (or public) in the base -
    // friend alone does NOT satisfy this. See pitfall #6.
    using YourModule::runOnce;
    using YourModule::someProtectedMethod;

    // Wrap private members with setter methods (friend grants direct access here).
    void setPrivateField(int x) { privateField = x; }
};

For methods you want to expose via using, use the conditional access-specifier pattern in the header - not plain friend:

// In YourModule.h, inside the class body:
#ifdef PIO_UNIT_TESTING
  protected:
#else
  private:
#endif
    bool someMethod();

For private member variables that a shim setter needs to touch directly, friend is sufficient (no using involved):

// In YourModule.h, inside the class body:
#ifdef PIO_UNIT_TESTING
    friend class YourModuleTestShim;
#endif

Global Singleton Lifecycle

Most modules use a global pointer (extern YourModule *yourModule;). Manage it carefully:

void setUp(void) {
    // ... setup ...
}

void tearDown(void) {
    yourModule = nullptr;   // prevent dangling pointer between tests
}

void test_something() {
    auto shim = std::unique_ptr<YourModuleTestShim>(new YourModuleTestShim());
    yourModule = shim.get();
    // ... test ...
    yourModule = nullptr;
}

Pitfalls and How to Avoid Them

1. Persisted Filesystem State

You are handed a clean sandbox. Declare what you write.

Each suite runs inside its own scratch $HOME (bin/pio-test-isolate.sh), so state cannot reach the next suite. The files in play are wider than module state, and all but the last live under ~/.portduino/default/prefs/:

File Written by
nodes.proto any NodeDB save - including incidental ones from removeNodeByNum(), resetNodes(), nodeDBSelfCare(), and the constructor itself when the file is absent
config.proto, module.proto, channels.proto, device.proto config/channel saves, admin handlers
warm.dat WarmNodeStore::saveIfDirty(), on the node-DB save cadence
transmit_history.dat retransmission tracking
/prefs/<module>.bin per-module saveState()

NodeDB's constructor calls loadFromDisk(), so any suite that constructs one inherits whatever is there.

What you have to do:

  • Nothing, if your suite is self-contained. That is the default and what almost every suite wants.

  • If your suite mutates persisted state on purpose, add a line to test/state-manifest.tsv with a reason:

    test_nodedb_blocked	state=per-suite writes=nodes.proto,warm.dat	saturates the DB to test the protected-node cap
    

    An undeclared write is reported as DIRTY and grades the run AMBER. A declared write that never happens is reported as MISSING - a warning, and a useful one: it catches persistence that silently stopped working.

  • Use state=per-suite only if a test genuinely needs to observe the previous test's write (persistence round-trips, migration ladders). It relaxes per-test checking to the suite boundary, so make it a deliberate choice rather than an accident of setUp().

Deleting your own state in setUp() is still fine and still a good habit for intra-suite isolation - it is just no longer what stands between you and the next suite:

void setUp(void) {
    // ...
#ifdef FSCom
    FSCom.remove("/prefs/your_module.bin");
#endif
}

2. A Shared Fixture Is Not a Fixture

If your suite touches globals the code under test writes - nodeDB, config, owner, devicestate, channelFile - build and restore them in setUp/tearDown for every test, not just the ones that seem to need it. An opt-in fixture that only some tests arm leaves the rest sharing one never-reset object, and "the other tests set their own state and are unaffected" is a claim that quietly stops being true as tests are added.

test/test_admin_radio/test_main.cpp is the worked example:

void setUp(void) {
    // ...
    replaceAdminRadioGlobals();   // saves the globals, installs a fresh NodeDB
}
void tearDown(void) {
    restoreAdminRadioGlobals();   // restores them, deletes the NodeDB, re-runs initRegion()
    // ...
}

A fresh NodeDB per test costs real time (loadFromDisk() plus, when the region is set, key generation) - in that suite roughly 7% of a ~7½-minute run. Pay it. If a test genuinely needs to observe the previous test's state, that is what state=per-suite in test/state-manifest.tsv is for; say so there rather than achieving it by omission.

3. File-Scope Mutable Globals Persist Across Tests

Variables like static uint8_t someDenominator = 8; in the module .cpp file retain mutations from previous tests. This is distinct from member variables - it affects all instances.

Fix: Add a static void resetGlobal() method to the module and call it in setUp().

4. Randomness Breaks Determinism

If the module uses rand() for jitter or similar, test results become non-reproducible.

Fix: Add a static enable/disable flag:

// Module header:
static void setJitter(bool enabled) { s_jitterEnabled = enabled; }

// Test setUp:
YourModule::setJitter(false);

// Test tearDown:
YourModule::setJitter(true);

5. Time-Dependent Logic Produces Zeros

Rolling averages weighted by elapsedMs / ONE_HOUR_MS collapse to zero when tests complete in microseconds. Sample windows, EMA alphas, and interval-based accumulators all suffer from this.

Fix: Expose the timestamp via friend access and simulate realistic elapsed time:

// In test shim:
void setWindowStartMs(uint32_t ms) { windowStartMs = ms; }

// In test:
shim.setWindowStartMs(millis() - 3600000UL);  // pretend 1 hour elapsed

6. Capacity Limits Cause Cascading Failures

Fixed-size data structures (hash sets, ring buffers) overflow when tests inject more data than fits. This triggers early flushes with near-zero time fractions, compounding the time-dependent-zeros problem.

Fix: Simulate multiple realistic time windows rather than one massive burst. Let adaptive mechanisms (if any) self-tune over several rolls.

7. Granting test access to private/protected members

PlatformIO defines PIO_UNIT_TESTING during pio test builds. Several production headers (TransmitHistory.h, CryptoEngine.h, MQTT.h, RTC.h) use this to gate test-only visibility changes. PlatformIO also defines UNIT_TEST in the same builds for backward compatibility, but that spelling is deprecated - always use PIO_UNIT_TESTING in new code. The established pattern for exposing a private method to a test shim without widening production visibility:

#ifdef PIO_UNIT_TESTING
  protected:
#else
  private:
#endif
    bool myMethod();

Critical C++ rule: a using declaration in a derived class (e.g. using Base::myMethod) requires myMethod to be protected or public in the base - friend alone does not satisfy this. Adding friend class TestShim while leaving the method private will still fail to compile. Use the conditional access-specifier pattern above, not friend.

setUp/tearDown Checklist

  • Create and clear MockNodeDB (if needed)
  • Zero global configs: config, moduleConfig, myNodeInfo
  • Set nodeDB = mockNodeDB
  • Delete your own persisted state files (FSCom.remove(...)) for intra-suite isolation - cross-suite isolation is already guaranteed, see Pitfall 1
  • Declare deliberate writes to shared state in test/state-manifest.tsv, with a reason
  • Reset file-scope mutable globals
  • Reset mock clock to a safe base value (e.g. mockTime = ONE_HOUR_MS) - prevents unsigned subtraction underflow in time-dependent logic
  • Disable randomness/jitter flags
  • In tearDown: null the global singleton pointer, restore flags

Test Organization

A well-structured test suite follows this pattern:

  1. Topology/scenario builders - static helper functions that set up specific test conditions
  2. Injection helpers - simulate realistic traffic, time, or event patterns
  3. Scenario tests - each builds a scenario, runs the module, asserts on outcomes
  4. Lifecycle tests - state persistence, startup from blank, restart recovery
  5. Summary test (optional) - emits a scenario table into the log for quick CI review

Not a Unity suite: bin/test-config-check.sh

Portduino YAML validation is tested by driving a built meshtasticd rather than by a Unity suite, because what it asserts - the exit status and printed report of meshtasticd --check, and the fact that a normal run still refuses a bad config - are properties of the process, not of a linkable function. Fixtures live in test/fixtures/portduino-config/ (see the README there); CI runs it in test_native.yml. It is not a test_* directory, so it sits outside the suite count the harness derives from test/.

pio run -e native && ./bin/test-config-check.sh

Existing Test Suites

This table is a description, not an inventory. The canonical suite total is the number of test_* directories under test/, detected on the fly by bin/run-tests.sh on every full run and cross-checked against the suites that actually ran. That derived count is the only number that should be trusted or quoted. Entries below carry per-suite descriptions the count cannot; do not infer completeness from the row count.

Suite Module Under Test
test_admin_radio Admin + LoRa region config
test_fscommon_getfiles Bounded file-manifest walk
test_atak ATAK integration
test_crypto CryptoEngine
test_default Default configuration helpers
test_hop_scaling Hop scaling algorithm
test_http_content_handler HTTP handling
test_mac_from_string MAC address parsing
test_mesh_module Module framework
test_meshpacket_serializer Packet serialization
test_mqtt MQTT integration
test_packet_history Packet history tracking
test_position_precision Position precision helpers
test_radio Radio interface
test_serial Serial communication
test_module_config AdminModule module config
test_tak_config TAK (ATAK) team/role values
test_traffic_management Traffic management
test_transmit_history Retransmission tracking
test_type_conversions NodeDB v25 type conversions
test_utf8 UTF-8 utilities