This document explains how to write and run tests for Samwise. There are two kinds of tests:
| Kind | Runs on | Macro | Purpose |
|---|---|---|---|
| Unit test | Host (your laptop) | samwise_test() |
Fast logic tests using mock drivers |
| Integration test | Real hardware (RP2040/RP2350) | samwise_integration_test() |
End-to-end tests against real peripherals |
Both are defined with Bazel macros in //bzl:defs.bzl.
# Run every unit test
bazel test //src/...
# Run a specific test
bazel test //src/tasks/print:print_testIntegration tests are compiled into the bringup or pico firmware image.
They are executed on-device by the hardware_test_task scheduler task.
# Build the bringup firmware (includes all registered integration tests)
bazel build :samwise --config=picubed-bringup
bazel build :samwise --config=pico # This works tooCreate a test file using normal includes — no special mock headers needed:
// src/tasks/my_task/test/my_task_test.c
#include "my_task.h"
#include <string.h>
slate_t test_slate;
int main()
{
clear_and_init_slate(&test_slate);
LOG_DEBUG("Testing my_task");
ASSERT(my_task.name != NULL);
my_task.task_init(&test_slate);
my_task.task_dispatch(&test_slate);
free_slate(&test_slate); // Do this at the end of every unit test!
return 0;
}load("//bzl:defs.bzl", "samwise_test")
samwise_test(
name = "my_task_test",
srcs = ["test/my_task_test.c"],
deps = [
":my_task",
],
)The samwise_test() macro handles everything automatically:
- Remaps driver and Pico SDK dependencies to mock implementations
- Adds
//src/test_infrastructure - Defines
TEST=1for conditional compilation - Restricts the target to the host platform
- See the list of supported hardware mocks
Integration tests exercise real hardware. They are compiled into a
_hw_lib library that gets linked into the bringup and pico firmware, where
hardware_test_task calls each test in sequence.
samwise_integration_test()creates acc_librarynamed<name>_hw_lib. It produces two kinds of compiled objects:- The integration entry point (
int_src) should contain<name>_int_mainas a function, giving it a unique symbol (e.g.mram_test_int_main) thathardware_test_taskcalls at runtime. - Any shared helper sources (
srcs) are compiled with-Dmain=_unused_<name>_main_, which discards theirmain()so it is never linked or called — letting you reuse the same test file in bothsamwise_test()andsamwise_integration_test()without conflicts.
- The integration entry point (
Therefore, the pattern for writing a test and an int_test is to have a <name>_test.c file which has unit tests and a main() function, a <name>_test.h file with just stubs for the unit tests/shared tests between integration and normal tests, and a <name>_int_test.c file that has a single function, <name>_int_main() that is run by hardware_test_task and can reference anything in <name>_test.h. A good example of this is the filesys and mram tests which are both located in src/filesys/test/.
-
hardware_integration_test_suite()collects all_hw_libtargets specified in the function and auto-generateshardware_tests.hat build time (via//bzl:gen_hw_tests_header). The generated header declares every<name>_int_main()entry point and definesHW_TEST_TABLE, a struct array consumed byhardware_test_task.c. -
hardware_test_task.citerates overHW_TEST_TABLEand runs each test. No manual edits to the task or the header are needed when tests are added or removed — just update thetestsdict.
Create a shared test file with the test logic. This file can (and usually
should) also be used by a corresponding samwise_test() unit test:
// src/drivers/my_driver/test/my_driver_test.c
#include "my_driver_test.h"
void test_my_driver_read_write(void)
{
uint8_t buf[16] = {0};
my_driver_write(buf, sizeof(buf));
my_driver_read(buf, sizeof(buf));
ASSERT(buf[0] == 0xAB);
}Create a separate *_integration_test.c file that calls into the shared tests.
Write it with a <name>_int_main so that hardware_test_task can call it at
runtime:
// src/drivers/my_driver/test/my_driver_integration_test.c
#include "my_driver_test.h"
void main(void)
{
LOG_DEBUG("Starting my_driver integration tests\n");
my_driver_init();
test_my_driver_read_write();
LOG_DEBUG("All my_driver tests passed!\n");
}# src/drivers/my_driver/test/BUILD.bazel
load("//bzl:defs.bzl", "samwise_integration_test", "samwise_test")
_DEPS = [
"//src/common",
"//src/drivers/logger",
"//src/drivers/my_driver",
"@pico-sdk//src/rp2_common/pico_stdlib",
]
# Host unit test (uses mocks)
samwise_test(
name = "my_driver_test",
srcs = ["my_driver_test.c", "my_driver_test.h"],
deps = _DEPS,
)
# Hardware integration test (uses real drivers)
samwise_integration_test(
name = "my_driver_test",
int_src = "my_driver_integration_test.c",
srcs = ["my_driver_test.c"], # shared helper — its main() is discarded
hdrs = ["my_driver_test.h"],
deps = _DEPS,
)Note:
samwise_testandsamwise_integration_testcan share the samenamebecause they produce differently-suffixed targets (my_driver_testvsmy_driver_test_hw_lib) and are compiled at different times (i.e.)bazel testvsbazel build.
Note: Helper sources passed via
srcsmay contain their own standalonemain(). The macro compiles them with-Dmain=_unused_<name>_main_so theirmain()is discarded and never linked — no#ifdefguards needed. This is what lets the same.cfile serve as the unit test entry point (viasamwise_test()) and as a helper library (viasamwise_integration_test()).
In src/tasks/hardware_test/BUILD.bazel, add your new _hw_lib target to the
tests dict:
hardware_integration_test_suite(
name = "hardware_test_lib",
tests = {
"mram_test": "//src/filesys/test:mram_test_hw_lib",
"filesys_test": "//src/filesys/test:filesys_test_hw_lib",
"my_driver_test": "//src/drivers/my_driver/test:my_driver_test_hw_lib", # NEW
},
)That's it! The next bringup or pico build will automatically include your test.
A test harness for running and reporting on groups of test cases has been
provided in src/test_infrastructure/ and is automatically linked during
samwise_test and/or samwise_integration_test.
Each test function must return 0 on success or non-zero on failure. Use the
TEST_ASSERT macro for assertions — it logs the failure message and returns
-1 automatically:
// src/drivers/my_driver/test/my_driver_test.c
#include "my_driver_test.h"
int test_my_driver_read(void)
{
uint8_t buf[16] = {0};
my_driver_read(buf, sizeof(buf));
TEST_ASSERT(buf[0] == 0xAB, "First byte should be 0xAB");
return 0;
}
int test_my_driver_write(void)
{
int rc = my_driver_write(data, sizeof(data));
TEST_ASSERT(rc == 0, "Write should succeed");
return 0;
}Build an array of test_harness_case_t entries. Each entry has a unique
numeric ID, a function pointer, and a human-readable name:
// src/drivers/my_driver/test/my_driver_test.c (continued)
const test_harness_case_t my_driver_tests[] = {
{0, test_my_driver_read, "Read"},
{1, test_my_driver_write, "Write"},
};
const size_t my_driver_tests_len =
sizeof(my_driver_tests) / sizeof(my_driver_tests[0]);Export the table and its length in the shared header so both unit and integration entry points can reference it:
// src/drivers/my_driver/test/my_driver_test.h
#include "test_harness.h"
int test_my_driver_read(void);
int test_my_driver_write(void);
extern const test_harness_case_t my_driver_tests[];
extern const size_t my_driver_tests_len;Call test_harness_run from your entry point (main() for unit tests,
and for integration tests). It runs every case, logs
pass/fail results, and returns 0 if all tests passed or -1 if any failed:
// Unit test entry point
int main(void)
{
return test_harness_run("My Driver", my_driver_tests, my_driver_tests_len, my_init_func);
}A slate initialized with clear_and_init_slate, or my_init_func if it is non-NULL, is provided to every test. When a custom my_init_func is provided, it is responsible for fully initializing the slate itself (typically by calling clear_and_init_slate at the start, or by performing equivalent initialization), because the harness does not call clear_and_init_slate automatically before invoking your custom initializer.
To run only specific tests by ID, use test_harness_include_run:
uint16_t ids[] = {0, 2};
test_harness_include_run("My Driver", my_driver_tests, my_driver_tests_len, my_init_func,
ids, 2);To run all tests except certain IDs, use test_harness_exclude_run:
uint16_t skip[] = {1};
test_harness_exclude_run("My Driver", my_driver_tests, my_driver_tests_len, my_init_func,
skip, 1);Note that because we export the tests array in the header file, we can easily use the same test_harness_run function in integration tests! In fact, you can copy paste the same code described in (3) or (4) into the integration test file as well.
See src/filesys/test/ for a complete working example of the test harness
in use.
The samwise_test() macro uses a mapping table (_MOCK_MAPPINGS in
bzl/defs.bzl) to rewrite dependency labels at analysis time. When your test
declares a dependency like //src/drivers/rfm9x, the macro transparently
replaces it with //src/drivers/rfm9x:rfm9x_mock. This means:
- Your test source files use the same
#includepaths as production code. - No wrapper headers, special include directories, or
#ifdef TESTguards are needed. - The mock implementations are linked instead of the real hardware drivers.
The following embedded dependencies are automatically mocked when using
samwise_test():
| Real Header | Mock Location | Functionality |
|---|---|---|
adcs_driver.h |
adcs_driver_mock.c | Stubs ADCS init, power, and telemetry |
adm1176.h |
adm1176_mock.c | Returns fixed voltage/current readings |
burn_wire.h |
burn_wire_mock.c | Stubs burn wire init and activation |
device_status.h |
device_status_mock.c | Stubs solar/panel/RBF status queries |
logger.h |
logger_mock.c | Redirects log output to stdout/viz file |
mppt.h |
mppt_mock.c | Returns fixed MPPT voltage/current values |
mram.h |
mram_mock.c | In-memory MRAM read/write/allocation emulation |
neopixel.h |
neopixel_mock.c | Stubs NeoPixel RGB color setting |
onboard_led.h |
onboard_led_mock.c | Stubs onboard LED set/get/toggle |
payload_uart.h |
payload_uart_mock.c | Stubs payload UART read/write/power control |
rfm9x.h |
rfm9x_mock.c | Stubs RFM9x radio TX/RX/FIFO operations |
watchdog.h |
watchdog_mock.c | Stubs watchdog init and feed |
error.h |
error_mock.c | Prints fatal error and calls exit(1) |
state_ids.h / state_machine.h |
state_mock.c | Defines stub scheduler states with no-op transitions |
| Real Header | Mock Location | Functionality |
|---|---|---|
hardware/flash.h |
flash.h | Pico flash memory API (no-op) |
hardware/gpio.h |
gpio.h | GPIO pin control (no-op) |
hardware/i2c.h |
i2c.h | I2C bus API (no-op) |
hardware/pwm.h |
pwm.h | PWM output API (no-op) |
hardware/resets.h |
resets.h | Hardware resets API (no-op) |
hardware/spi.h |
spi.h | SPI bus API (no-op) |
hardware/sync.h |
sync.h | Hardware synchronization primitives (no-op) |
hardware/uart.h |
uart.h | UART serial API (no-op) |
pico/stdlib.h |
stdlib.h | Pico standard library functions (no-op) |
pico/time.h |
time.h + time.c |
Timing functions (provides mock_time_us) |
pico/types.h |
types.h | Pico SDK type definitions |
pico/unique_id.h |
unique_id.h | Board unique ID API (no-op) |
pico/util/queue.h |
queue.h | Pico queue utility API (no-op) |
samwise_integration_test() keeps the unit test entry point and the
integration test entry point completely separate:
- Shared helper sources (
srcs) — compiled with-Dmain=_unused_<name>_main_, which discards anymain()they contain so it is never linked or called. This lets you reuse the same test.cfile in bothsamwise_test()(where itsmain()is the entry point) andsamwise_integration_test()(where it is not). - Integration entry point (
int_src) — compiled with-Dmain=<name>_int_main, giving itsmain()a unique symbol likemram_test_int_main(). This is the function thathardware_test_taskcalls at runtime.
Because each integration test gets its own _int_main symbol, multiple tests
can coexist in the same firmware binary without main() conflicts.
hardware_integration_test_suite() runs bzl/gen_hw_tests_header.py as a
Bazel genrule. The script takes each test name as a positional argument and
outputs a header that:
- Declares
void <name>_int_main(void);for every test. - Defines
HW_TEST_TABLE, a compile-time array of{name, function_pointer}entries thathardware_test_task.citerates over.
This means no hand-maintained header — adding or removing a test is a
one-line edit in the tests dict.
- Keep tests simple — test logic, not hardware (for unit tests)
- One test per file — easier to debug
- Use meaningful names —
my_task_test.c, nottest1.c - Use
ASSERT()to verify behavior - Initialize state — create a fresh
slate_tfor each test, usingclear_and_init_slate() - Share test logic — write core test functions in a shared
.c/.hpair and reuse them in bothsamwise_test()andsamwise_integration_test()
Make sure your dependency is listed in deps:
samwise_test(
name = "my_test",
srcs = ["my_test.c"],
deps = [
":my_task", # Don't forget this!
],
)Check that the dependency label in your deps exactly matches a key in
_MOCK_MAPPINGS (in bzl/defs.bzl). Both the short-form (//src/drivers/rfm9x)
and explicit-target form (//src/drivers/rfm9x:rfm9x) are mapped.
- Verify
samwise_integration_test()produces a<name>_hw_libtarget (bazel query //path/to:my_test_hw_lib). - Verify the
_hw_libtarget is listed in thetestsdict ofhardware_integration_test_suite()insrc/tasks/hardware_test/BUILD.bazel. - Build with a bringup or pico config:
bazel build :samwise --config=picubed-bringup,bazel build :samwise --config=pico.
See src/filesys/test/ for a complete working example:
mram_test.c/mram_test.h— shared test functionsmram_integration_test.c— hardware entry point (main()→mram_test_int_main())BUILD.bazel— declares bothsamwise_testandsamwise_integration_testsrc/tasks/hardware_test/BUILD.bazel— registers the_hw_libin the suite