ceedless is a small, dependency-light unit-test framework for microcontroller
firmware. It provides assertions, function mocks, exception handling, and
virtual peripherals in pure C11 — no Ruby, no code generation, single static
binary CLI.
Key features:
- Unity-style assertion macros (full set: INT, HEX, FLOAT, STRING, MEMORY, ARRAY)
- Header-only function mocks with argument matchers, callbacks, throw, return-thru-ptr
- Try / Catch / Throw exception handling
- Virtual peripherals: SPI, UART, GPIO, ADC (stateful, register-bank backed)
- On-target execution support — same source compiles for host SIL and Cortex-M PIL
(see
examples/cortex_m/for a QEMU runner) - Pluggable trace transport: host-printf, UART, RTT, ITM, semihosting, ring-buffer
- Parametric
TEST_CASE(...)annotations + property-basedTEST_PROPERTY - Snapshot / golden-byte assertions for protocol & codec tests
- Deterministic test order shuffle (
--shuffle --seed) for flake hunting - Auto test discovery + runner generation (
ceedless test) - gcov coverage, JUnit XML + TAP output, HTML aggregate report
- Sanitizer flag passthrough (
--asan/--ubsan/--msan) andceedless fuzzlibFuzzer harness generator - Build integrations: GNU Make, CMake (
add_ceedless_test), PlatformIO
Zero dynamic allocation in the core. ~2000 LOC of portable C11.
# clone, then add bin/ to your PATH (or symlink ceedless into /usr/local/bin)
export PATH="$PWD/bin:$PATH"
ceedless new my_fw && cd my_fw
ceedless module sensor # creates src/sensor.c, include/sensor.h, test/test_sensor.c
ceedless test # discovers, builds, and runs every test_*.c
ceedless gcov # same, with coverage report
ceedless test --junit-dir reports/ # also emits JUnit XML per test programThe framework also has its own self-tests:
make # builds & runs self-tests, negative test, example, and CLI smokeOutput ends with:
=== result: 33/34 passed, 0 failed, 1 ignored ===
OK: negative test failed as expected
=== result: 3/3 passed, 0 failed, 0 ignored ===
ceedless: 1/1 test programs passed
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
set(CEEDLESS_HOME "${CMAKE_SOURCE_DIR}/external/ceedless")
include(ceedless)
enable_testing()
add_ceedless_test(test_packet
SOURCES test/test_packet.c src/packet.c
INCLUDE include
)Run with cmake -B build && ctest --test-dir build --output-on-failure.
TEST_CASE(0, 0, 0)
TEST_CASE(1, 2, 3)
TEST_CASE(-5, 5, 0)
static void test_add(int a, int b, int expected)
{
TEST_ASSERT_EQUAL_INT(expected, a + b);
}The runner generator emits one RUN_TEST_CASE call per TEST_CASE line —
no manual dispatch needed.
TEST_PROPERTY(x, 256, {
uint32_t y = ceedless_rand_u32();
TEST_ASSERT_EQUAL_UINT32(x + y, y + x);
});Use --seed N (or CEEDLESS_SEED=N) for reproducible failures.
cd examples/cortex_m && make qemuRuns the same assertion suite on an ARM Cortex-M3 under QEMU with
semihosting output. See examples/cortex_m/README.md.
bin/ceedless CLI (single static Go binary)
include/ceedless/
ceedless.h umbrella include
hybrid_runner.h assertions + lifecycle hooks
virtual_peripheral.h generic stateful peripheral primitive
peripherals/vp_spi.h SPI virtual device
peripherals/vp_uart.h UART virtual device
peripherals/vp_gpio.h GPIO virtual bank
peripherals/vp_adc.h ADC virtual device
mock.h CMock-style function mocks
exception.h CException-style Try/Catch/Throw
trace_port.h pluggable trace front-end
trace_buffer.h optional in-memory trace inspection
trace_config.h compile-time backend selector
src/
runner.c suite/test lifecycle + JUnit emitter
mock.c mock framework
exception.c exception stack
virtual_peripheral.c I2C-style device + register-bank base
peripherals/vp_*.c
trace/trace_{port,host,uart,rtt,itm,buffer}.c
tests/ framework self-tests
examples/sensor_driver/ end-to-end test using mocks + virtual I2C
Makefile
Drop-in compatible with Unity. The full set:
TEST_FAIL[_MESSAGE] TEST_PASS[_MESSAGE]
TEST_IGNORE[_MESSAGE] TEST_MESSAGE
TEST_ASSERT[_TRUE|_FALSE|_UNLESS|_NULL|_NOT_NULL|_EMPTY|_NOT_EMPTY]
TEST_ASSERT_EQUAL[_INT|_INT8|_INT16|_INT32|_INT64]
TEST_ASSERT_EQUAL_UINT[8|16|32|64]
TEST_ASSERT_EQUAL_HEX[8|16|32|64]
TEST_ASSERT_EQUAL[_CHAR|_PTR|_STRING|_STRING_LEN|_MEMORY]
TEST_ASSERT_NOT_EQUAL[_INT]
TEST_ASSERT_GREATER_THAN[_INT|_UINT|_FLOAT|_DOUBLE]
TEST_ASSERT_LESS_THAN [_INT|_UINT|_FLOAT|_DOUBLE]
TEST_ASSERT_GREATER_OR_EQUAL[_INT|_FLOAT|_DOUBLE]
TEST_ASSERT_LESS_OR_EQUAL [_INT|_FLOAT|_DOUBLE]
TEST_ASSERT_INT[8|16|32|64]_WITHIN
TEST_ASSERT_UINT[8|16|32|64]_WITHIN TEST_ASSERT_HEX_WITHIN
TEST_ASSERT_BITS / BITS_HIGH / BITS_LOW / BIT_HIGH / BIT_LOW
TEST_ASSERT_EQUAL_{INT,UINT,HEX,CHAR}{8|16|32|64}_ARRAY
TEST_ASSERT_EQUAL_{PTR,STRING,MEMORY}_ARRAY
TEST_ASSERT_EACH_EQUAL_{INT,UINT,HEX,CHAR}{8|16|32|64}
TEST_ASSERT_FLOAT_WITHIN / NOT_WITHIN TEST_ASSERT_EQUAL_FLOAT
TEST_ASSERT_FLOAT_IS_{INF,NEG_INF,NAN,DETERMINATE,NOT_*}
TEST_ASSERT_DOUBLE_* (same set, double precision)
TEST_ASSERT_HW(c) // only enforced on target (compile with -DCEEDLESS_TARGET)
TEST_ASSERT_HOST(c) // only enforced on host
Plus *_MESSAGE variants for the common ones (TEST_ASSERT_EQUAL_INT_MESSAGE,
etc.).
void setUp(void) { /* runs before each test */ }
void tearDown(void) { /* runs after each test */ }
void suiteSetUp(void) { /* runs once before suite */ }
int suiteTearDown(int failures) { return failures; }All hooks are weak — define them in your test file to override.
ceedless's mock framework gives you Unity/CMock semantics
(Expect, ExpectAndReturn, ExpectAnyArgs, Ignore, IgnoreAndReturn,
StopIgnore, ExpectAndThrow, ReturnThruPtr, StubWithCallback, Verify)
without code generation. You write one tiny stub per mocked function:
#include "ceedless/ceedless.h"
struct hal_send_args { uint8_t addr; uint16_t value; };
MOCK_DEFINE(hal_send); /* allocates hal_send_mock */
int hal_send(uint8_t addr, uint16_t value) {
struct hal_send_args a;
memset(&a, 0, sizeof a); /* zero padding (important) */
a.addr = addr; a.value = value;
int rv = -1;
mock_invoke(&hal_send_mock, &a, sizeof a, &rv, sizeof rv);
return rv;
}
/* in a test: */
struct hal_send_args a; memset(&a, 0, sizeof a); a.addr = 0x10; a.value = 0xBEEF;
int rv = 1;
MOCK_EXPECT_AND_RETURN(hal_send, &a, sizeof a, &rv, sizeof rv);
/* …call code-under-test… */
MOCK_VERIFY(hal_send);| Macro | Purpose |
|---|---|
MOCK_DEFINE(name) |
Allocate the mock_t |
MOCK_RESET(name) |
Clear expectations + counters |
MOCK_EXPECT_AND_RETURN(name, args, alen, ret, rlen) |
Queue expected call + return value |
MOCK_EXPECT_ANY_ARGS_AND_RETURN(name, ret, rlen) |
Ignore args, return value |
MOCK_EXPECT_AND_THROW(name, args, alen, code) |
Queue expected call, then Throw(code) |
MOCK_RETURN_THRU_PTR(name, arg_offset, data, dlen) |
Write into a pointer arg |
MOCK_IGNORE / IGNORE_AND_RETURN / STOP_IGNORE |
Ignore all calls (optional return val) |
MOCK_STUB(name, cb) |
Replace mock with callback |
MOCK_VERIFY(name) |
Fail test if unconsumed expectations |
MOCK_CALL_COUNT(name) |
Observed call count |
Padding gotcha. Argument matching is a byte-wise memcmp. ALWAYS
memset()your args struct to zero before populating fields, otherwise unspecified padding bytes will cause false mismatches.
#include "ceedless/exception.h"
volatile EXCEPTION_T e = CEXCEPTION_NONE;
Try {
do_risky_thing();
} Catch (e) {
log("caught %d", e);
}Nested Try blocks up to CEEDLESS_EXC_STACK (default 8). Define
CEEDLESS_EXC_STACK before including to change.
All peripherals are statically allocated, observe register-level state, and expose call counters and event logs for assertions.
static uint8_t regs[64];
vp_i2c_t eep;
vp_i2c_init(&eep, "eeprom", 0x50, regs, sizeof regs);
uint8_t w[] = { 0x10, 0xDE, 0xAD }; // register pointer + payload
vp_i2c_master_write(&eep, w, sizeof w);
uint8_t ptr = 0x10;
vp_i2c_master_write(&eep, &ptr, 1); // re-arm cursor
uint8_t r[2] = {0};
vp_i2c_master_read(&eep, r, 2); // -> {0xDE, 0xAD}
TEST_ASSERT_EQUAL_INT(0x12, eep.cursor);vp_spi_t s; vp_spi_init(&s, "spi", NULL, 0);
uint8_t miso[] = { 0xAA, 0x55 };
vp_spi_load_rx(&s, miso, 2); // queue what the device returns
vp_spi_xfer(&s, mosi, rx, 2);
TEST_ASSERT_EQUAL_HEX8(0xAA, rx[0]);vp_uart_t u; vp_uart_init(&u, "u", 115200);
vp_uart_inject(&u, (uint8_t*)"hello", 5); // simulate other end transmitting
vp_uart_write (&u, (uint8_t*)"OK", 2); // what driver-under-test sent
uint8_t buf[8];
size_t n = vp_uart_tx_take(&u, buf, sizeof buf);vp_gpio_t g; vp_gpio_init(&g, "g");
vp_gpio_set_mode(&g, 3, VP_GPIO_OUT);
vp_gpio_write (&g, 3, 1);
TEST_ASSERT_EQUAL_INT(1, vp_gpio_read(&g, 3));
vp_gpio_set_pull(&g, 4, VP_PULL_UP); // input pull-upvp_adc_t a; vp_adc_init(&a, "adc", 12);
vp_adc_set_fixed(&a, 1, 0x123);
TEST_ASSERT_EQUAL_HEX32(0x123, vp_adc_read(&a, 1));
vp_adc_set_gen(&a, 2, ramp_fn, NULL); // dynamic generatorThe test runner prints test results through trace_*. Select the backend
with one macro:
| Macro | Backend | Notes |
|---|---|---|
CEEDLESS_TRACE_HOST |
fwrite to stdout |
default (host simulator) |
CEEDLESS_TRACE_UART |
weak emtest_uart_* |
BSP supplies init/putc/flush hooks |
CEEDLESS_TRACE_RTT |
weak emtest_rtt_* |
wrap SEGGER_RTT_Write in the hook |
CEEDLESS_TRACE_ITM |
direct / weak | define EMTEST_ITM_DIRECT for raw regs |
CEEDLESS_TRACE_BUFFER |
RAM ring (tees) | used by the framework's own self-tests |
Target build skeleton (ARM Cortex-M + RTT):
arm-none-eabi-gcc -Iinclude -DCEEDLESS_TARGET -DCEEDLESS_TRACE_RTT \
-mcpu=cortex-m4 -mthumb \
src/*.c src/trace/*.c src/peripherals/*.c \
my_tests.c rtt_glue.c -o firmware.elf
ceedless is a single static Go binary — no runtime, no dependencies beyond a C compiler.
ceedless new myapp && cd myapp
ceedless module led
ceedless test # auto-discover, build, run
ceedless watch # re-run on file change
ceedless bench # slowest tests
ceedless cov # gcov + HTML report
ceedless mock include/hw.h # generate MOCK_DEFINE stubs
ceedless doctor # toolchain check
ceedless docker test # same, inside a container
Build it locally with make cli (produces ./bin/ceedless) or install with
go install github.com/tevfik/ceedless/cli@latest.
Auto-runners: tests don't declare main(). The CLI scans every
test_*.c for void test_<name>(void) and synthesizes the runner.
Extra sources: /* TEST_SOURCE_FILE("src/util/crc.c") */.
Detailed reference: docs/CLI.md · Tutorial: docs/TUTORIAL.md
Identical test source compiles on host and on the MCU. Conditional assertions:
TEST_ASSERT_TRUE(cond); // always enforced
TEST_ASSERT_HW(cond); // only on -DCEEDLESS_TARGET builds
TEST_ASSERT_HOST(cond); // only on host simulator- No malloc anywhere in
src/. - Static caps are tunable:
CEEDLESS_MAX_TESTS,CEEDLESS_MOCK_MAX_CALLS,CEEDLESS_MOCK_ARG_BYTES,EMTEST_MOCK_RET_BYTES,CEEDLESS_EXC_STACK,VP_SPI_BUFSZ,VP_UART_BUFSZ,CEEDLESS_TRACE_BUFFER_SIZE. - All trace backends are byte-streamed; no printf is forced on the target
build path (UART/RTT/ITM backends do raw
putc).
MIT.