Files
firmware/test
Jonathan BennettandClaude Opus 5.5 34cb8d5828 Rework XEdDSA signing policy: Compatible stops signing, AES-CCM overrides, Strict exemptions (#12191)
* Rework XEdDSA signing policy: Compatible stops signing, AES-CCM overrides

Signing costs 66 bytes per packet. On a crowded mesh that matters, and
packets on an AES-CCM channel were paying it on top of the CCM tag.

Sending (perhapsEncode):
- Compatible, the default, no longer signs. Balanced and Strict sign
  broadcasts as before; licensed (ham) nodes also sign unicasts, but now
  only under Balanced or Strict.
- Packets on an AES-CCM (use_aead) channel are never signed. This also
  removes the case where a signed CCM packet passed signedDataFits() but
  then failed the CCM size check with TOO_LARGE.

Receiving (checkXeddsaReceivePolicy):
- Only Strict drops a packet for lacking a signature. Balanced no longer
  drops unsigned broadcasts from known signers; that downgrade rule and
  its canonical-sizing helper are removed.
- An AES-CCM channel overrides Strict: its unsigned or unverifiable
  packets are accepted, since the CCM tag authenticates them.
- A malformed or failing signature still drops the packet in every mode.

Identity (new NodeDB::refusesUnsignedIdentity, used by updateUser,
NodeInfoModule and TrafficManagementModule):
- Balanced and Strict still refuse an unsigned NodeInfo that would change
  a known signer's identity, except on an AES-CCM channel.
- Compatible accepts it, since firmware before 2.8 strips signatures when
  relaying.

Channels::isAEADEnabled() now returns false for an out-of-range index
instead of logging, since receive paths pass a packet's index unchecked.
XEDDSA_SIGNATURE_FIELD_BYTES lost its only user and is removed.

Tests: test_packet_signing drops the cases that pinned the removed
downgrade sizing (A7-A9, A18-A19, E5, E10-E13, D1), flips A4 and E1 to
acceptance, and adds AES-CCM, Compatible and ham cases (A18-A20, B18-B20,
N12-N13). test_mqtt and test_traffic_management pin the same rules on the
plaintext-MQTT and NodeInfo-cache paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* Sign NodeInfo unicasts, forward signed requests, check signed NodeInfo keys

A NodeInfo request or reply carries the key a peer will use for PKI direct
messages, but off ham mode only broadcasts were signed. A Strict node
therefore dropped every NodeInfo reply and could only learn a new peer's key
from a signed broadcast, hours apart.

- perhapsEncode: under Balanced and Strict, NodeInfo is signed even when
  unicast (still never on an AES-CCM channel, never in Compatible).
- TrafficManagementModule: a NodeInfo request that carries a signature is
  forwarded instead of answered from the cache. Its sender may need a signed
  reply, and a reply sent on the target's behalf can never be signed.
- checkXeddsaReceivePolicy: a signed NodeInfo verified against a stored key
  must advertise that same key, and the key must hash to the sender's node
  number, as first contact already requires. Otherwise it is dropped. This
  stops a valid signature vouching for another key, and stops a key planted on
  trust under the wrong node number proving itself with fresh signatures.

Tests: B7 now expects NodeInfo unicasts signed; B21 pins signing per policy;
A21-A23 pin the key-consistency check; a traffic-management case pins that a
signed request is forwarded while the same request unsigned is answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* Strict: accept unsigned acks and traceroutes; never sign traceroutes

Strict dropped every unsigned packet that was not PKI-encrypted. Acks and
traceroutes are never signed, so a Strict node saw no acks, retransmitted
every reliable packet to exhaustion, and could not take part in traceroutes.

- checkXeddsaReceivePolicy: Strict no longer drops an unsigned ROUTING_APP
  packet; an ack carries its own proof (AckProof.h). Traceroutes are accepted
  in every mode, and any signature on one is ignored rather than verified.
- perhapsEncode: a traceroute is never signed, including in ham mode, which
  signs other unicasts. Every relay rewrites the route, so a signature fails
  verification from the second hop on, and a bad signature drops the packet in
  every mode.
- TraceRouteModule::alterReceivedProtobuf: strip any signature when rewriting
  the route, so a traceroute signed by older firmware is not dropped by older
  receivers further along.

Tests: A24-A26 (Strict accepts unsigned acks and traceroutes; a traceroute
signature is ignored), B22 (traceroutes never signed), B7 updated, and
test_traceroute_nexthop pins the signature strip on rewrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* Drop the traceroute signature strip

Only 2.8.0 and 2.8.1 pre-release nodes ever signed a traceroute: ham mode,
which signs every unicast, and client-sent broadcast traceroutes. Stripping
the signature on rewrite only helped those pre-releases interoperate, which
does not justify the code. Receivers still ignore a traceroute signature, and
traceroutes are still never signed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* Sign unicast positions under Balanced and Strict

A position request or reply is PKI-excluded, so a signature is its only
authentication, but off ham mode only broadcasts were signed. Strict dropped
every position request and reply, and under Balanced any channel member could
fake another node's location with an unsigned reply.

perhapsEncode now signs POSITION_APP unicasts under Balanced and Strict, as it
does NodeInfo. Nothing rewrites a position in flight - precision is applied by
the originator before signing - so the signature survives relaying, and it
binds request_id so a reply cannot be retargeted. Compatible and AES-CCM
channels still sign nothing.

Tests: B23 pins position unicast signing per policy and Strict acceptance; B7
expects position unicasts signed; B2 and B5 use PRIVATE_APP as their unsigned
unicast now that position is signed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* Strict: accept unsigned key verification messages

Key verification's bootstrap messages (M1, M2) go out channel-encrypted
because the peer's key is not known yet, so they cannot carry a signature the
receiver could verify. Strict dropped them, so two Strict nodes that did not
already hold each other's keys could never run key verification.

The handshake does not rely on the transport: hash1 binds both public keys to
the security number the users exchange, and a key is only stored after the
numbers match and the user accepts. A forged request can at most raise a
verification prompt, bounded by the single session slot and the 60 s remote
cooldown - as it already can under Balanced.

Test: A27 pins that Strict accepts an unsigned key verification message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* test_nodedb_identity_hygiene: pin the identity refusal to Balanced

Two cases expected updateUser() to refuse an unsigned update from a known
signer under a zeroed config. Zeroed is Compatible, which now accepts it, so
they failed in CI. Run them under Balanced, and add the Compatible
counterpart so both halves of the rule are pinned at the updateUser level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* Ham mode: sign in place of PKI, authenticate acks with the ack proof (#12200)

Ham mode signed every unicast under Balanced and Strict, acks included. A
signed ack from a node whose key a Strict receiver lacks is dropped, so the
sender retransmits to exhaustion, and the signature costs 66 bytes where the
ack proof costs about 10.

Ham mode now signs the unicasts PKI would cover in regular mode, plus
broadcasts, NodeInfo and position as before. Acks are no longer signed; they
carry the ack proof, which needs only the two nodes' keys, not PKI. Strict
already accepts unsigned acks.


Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

Co-authored-by: Claude <noreply@anthropic.com>

* Notify the client when an unsigned identity update is refused

Balanced and Strict refuse an unsigned NodeInfo that would change a known
signer's name or role, but the refusal only reached the device log, so the
user saw a stale name with no reason.

Send a ClientNotification naming the node (by the name already held, never
the refused claim) and how to accept its updates again: remove the node.
Rate limited to once per node per boot, over the last 8 nodes, and to one
notice per minute overall, so a signer relayed through pre-2.8 firmware or a
burst of them cannot flood the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* Refused identity notice: only for a name or role change, drop the warnings

Notify only when the refused NodeInfo would have changed the stored long
name, short name or role, compared through CopyUserToNodeInfoLite so
truncation and sanitizing cannot read as a change. A signer held only in
the warm tier has no stored name to compare and is not reported.

Remove the two LOG_WARNs at the refusal sites; the notice replaces them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* Refused identity notice: name the security setting that refused it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

* Refused identity notice: one global hourly throttle, no per-node tracking

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-22 01:08:36 +00:00
..
2026-09-24 22:41:37 +00:00
2026-07-16 18:35:33 -05:00
2026-10-02 03:48:17 +00:00
2026-09-20 04:11:18 +00:00
2026-10-01 21:33:14 +00:00

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 --quiet   # prints only the RESULT: line - the mode for tool calls
./bin/run-tests.sh --status                 # a run in progress? else the last verdict, marked STALE if the tree changed since
./bin/run-tests.sh --wait                   # attach to the run in progress; exits with its verdict
./bin/run-tests.sh --abort                  # stop it (the whole build tree); its log is kept

Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER, 3 = FILTERED, 4 = BUSY (a run is already in progress; nothing started), 5 = ABORTED, 6 = UNSUPPORTED host.

The RESULT: line is the only verdict. pio prints [PASSED] per suite and N succeeded per invocation long before the wrapper has decided anything, so a captured output file looks green within the first minute; grade on the final RESULT: line and nothing else. One run at a time per checkout: a second invocation is refused with BUSY rather than started, because two pio jobs share .pio/build/ and wipe each other's objects. Never look for a run with pgrep - ask --status. The last verdict and its log live in .pio/runtests/; an interrupted run records ABORTED, not nothing.

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 Windows, bin\run-tests.cmd <same args> forwards into WSL with the exit code passed through; on macOS, or without WSL, 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 libavahi-client-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