| name | tdd |
| description | Test-driven development for firmware logic using PlatformIO's native env. TRIGGER when adding or changing code under lib/sampler/, lib/packetizer/, lib/ringbuf/, lib/config/, or any pure-logic C++ in this repo. DO NOT trigger for docs, platformio.ini, scripts, or hardware-only modules (lib/imu/, lib/net/, lib/uploader/) where behavior can't be exercised without a board. |
TDD — host-side firmware loop
Embedded TDD only works if you can run the code on the host. This project
is structured so that every non-hardware module builds and tests under
pio test -e native on the Mac in under a second. Use that ruthlessly.
The cycle
1. Red — failing host test
Add a test file under test/test_native/test_<module>/test_<module>.cpp.
PlatformIO + Unity discovers one void setup() / void loop() per group.
#include <unity.h>
#include "packetizer/Packetizer.h"
void test_batch_respects_size_cap(void) {
Packetizer p(10);
for (int i = 0; i < 15; i++) p.add(sample_at(i));
TEST_ASSERT_EQUAL(10, p.size());
TEST_ASSERT_EQUAL(5, p.dropped());
}
void setup() {
UNITY_BEGIN();
RUN_TEST(test_batch_respects_size_cap);
UNITY_END();
}
void loop() {}
Run it and confirm it fails for the reason you expect, not a link error:
pio test -e native -f test_packetizer
2. Green — minimum implementation
Write the least code that makes it pass. No speculative generality. Sampler
rate logic doesn't need a state machine if an if works.
pio test -e native -f test_packetizer
3. Refactor — run the whole host suite
pio test -e native
All host tests must be green. If a refactor touches the interface between a
pure-logic module and a hardware module, also verify the device build still
links:
pio run -e esp32c3
4. Format
clang-format -i $(git ls-files 'src/*.cpp' 'src/*.h' 'lib/**/*.cpp' 'lib/**/*.h' 'test/**/*.cpp' 'test/**/*.h')
5. Commit
Test and implementation in the same commit. Conventional commit format,
include issue number if applicable: feat(packetizer): cap batch size (#12).
What belongs in host tests
- Packetizer — JSON shape,
seq monotonicity, mount_quat application,
batch boundaries, gzip framing, pre_sntp handling
- Sampler — rate limiting, backpressure when ringbuf is full, clock skew
detection, dropped-sample accounting
- Ringbuf — wrap behavior, oldest-drop-on-overflow, segment recovery
after simulated crash, size accounting
- Config — NVS round-trip (use a fake NVS), schema version migration,
default values
- Anything with math — quaternion multiplication, frame transforms,
heave integration — write a test with known-answer inputs
What does NOT belong in host tests
lib/imu/ device impl — needs Wire and sh2, use ImuFake in host tests
lib/net/ — uses WiFi.h, mock at the interface boundary
lib/uploader/ — uses HTTPClient, inject a fake HTTP sink
main.cpp — no logic lives there by convention; nothing to test
If you catch yourself writing #ifdef ARDUINO inside a module that's supposed
to be pure logic, stop — the hardware dependency needs to move behind an
interface.
Fake injection pattern
class Imu {
public:
virtual ~Imu() = default;
virtual bool begin() = 0;
virtual bool read(Sample& out) = 0;
};
class ImuFake : public Imu {
public:
std::vector<Sample> canned;
size_t idx = 0;
bool begin() override { return true; }
bool read(Sample& out) override {
if (idx >= canned.size()) return false;
out = canned[idx++];
return true;
}
};
Sampler takes Imu&, not Imu* or a concrete type. Tests pass ImuFake.
main.cpp passes the real one.
Known gotchas
- Unity's
TEST_ASSERT_EQUAL_FLOAT uses a fixed epsilon — for quaternion
math, use TEST_ASSERT_FLOAT_WITHIN(1e-6f, expected, actual)
pio test -e native rebuilds slowly on cold cache — first run takes
~15s, subsequent runs are sub-second. Don't conflate the two
- Arduino types leak in — if a host test fails to compile because of
String or HardwareSerial, the module under test has a hidden Arduino
dependency. Move it behind an interface