Problem
A sensor driver in the Arduino framework (say, HardwareSerial for UART) physically uses APIs available only on the target board. Host-side unit tests without hardware require Serial / I2C / SPI mocks — a lot of wrapper code that can itself contain bugs.
Solution
Split the driver into two parts:
1. Pure C++ functions
Clean logic: frame parsing, checksum verification, raw → SI conversion. No Arduino API.
// tf_luna.h — pure interface
struct TFLunaFrame {
uint16_t distance_cm;
uint16_t amp_raw;
float temp_c;
bool valid;
};
bool tf_luna_parse_frame(const uint8_t *buf, TFLunaFrame &out);
uint8_t tf_luna_checksum(const uint8_t *buf, size_t len);
These are plain C++ — they compile on the host, on ESP32, wherever. Tested like any other logic.
2. Arduino wrapper under #if defined(ARDUINO)
A class with state, using HardwareSerial. Declared in the same .h but with conditional implementation.
// tf_luna.h
#if defined(ARDUINO)
class TFLuna {
public:
TFLuna(HardwareSerial &port, int rx, int tx);
bool tick(TFLunaFrame &out);
private:
HardwareSerial &port_;
uint8_t buf_[512];
size_t len_ = 0;
};
#endif
// tf_luna.cpp
#if defined(ARDUINO)
TFLuna::TFLuna(HardwareSerial &port, int rx, int tx) : port_(port) {
port_.begin(115200, SERIAL_8N1, rx, tx);
port_.setRxBufferSize(512); // not the 256 default!
}
bool TFLuna::tick(TFLunaFrame &out) {
while (port_.available()) {
buf_[len_++] = port_.read();
if (len_ >= 9) {
// scan buf_ for 0x59 0x59, drop partial-sync one byte at a time
...
}
}
return false;
}
#endif
// pure functions — no #if, available everywhere
bool tf_luna_parse_frame(const uint8_t *buf, TFLunaFrame &out) { ... }
uint8_t tf_luna_checksum(const uint8_t *buf, size_t len) { ... }
How this enables host tests without mocks
platformio.ini:
[env:native]
platform = native
test_framework = unity
test_build_src = no ; don't pull the whole src/
The test file directly includes the .cpp:
// test/test_tf_luna/test_main.cpp
#include <unity.h>
#include "../../src/drivers/tf_luna/tf_luna.cpp" // include the .cpp directly!
void test_parse_real_frame() {
uint8_t packet[] = { 0x59, 0x59, 0x6E, 0x00, 0x68, 0x01, 0x2C, 0x09, 0xEE };
TFLunaFrame frame;
TEST_ASSERT_TRUE(tf_luna_parse_frame(packet, frame));
TEST_ASSERT_EQUAL_UINT16(110, frame.distance_cm);
TEST_ASSERT_TRUE(frame.valid);
}
The trick: the ARDUINO macro isn’t defined on native — so the entire #if defined(ARDUINO) block vanishes, leaving only the pure functions. The linker only assembles those + the test. No Serial mocks.
Gotchas of this approach
1. test_build_src = no confuses the linker
If you leave the default (test_build_src = yes), pio tries to build all of src/ including the Arduino bits — fails on HardwareSerial without the target toolchain.
With test_build_src = no, the workaround is a direct #include "...tf_luna.cpp" in the test — only the code being tested compiles, no build_src_filter gymnastics.
2. You can be wrong in the expected values
In the checksum-formula test I had expected 0x8E — but it’s actually 0xEE. The sum was 0x18E (right), & 0xFF gave 0x8E (wrong arithmetic on paper). The test caught it — exactly the right behavior.
Tip: in the test comment, write the step-by-step calculation so a reviewer can verify:
// sum = 0x59+0x59+0x6E+0x00+0x68+0x01+0x2C+0x09 = 0x18E
// 0x18E & 0xFF = 0x8E ← WRONG!
// Real: 0x18E + 0x60(low) - wait, sum 8 bytes ... → 0xEE
TEST_ASSERT_EQUAL_UINT8(0xEE, tf_luna_checksum(packet, 8));
Applicability
This pattern works for any Arduino driver that has clean logic on top of a hardware API: UART parsers, I2C registers, SPI commands, CRC checks. Doesn’t work for logic tied to timing: PWM with microsecond precision, GPIO interrupts, RMT.