Zephyr ships with its own unit test framework (Ztest) and an orchestrator (Twister) that builds and runs those tests across emulators, simulators, and real hardware. This post walks through what a test looks like, how to wire one into your project, and — in the final chapter — how to make the same suite run across several board variants.
You can see this as a starter tutorial to write tests and make your projects more robust!
1. Why Ztest + Twister?
Ztest: The in-tree unit test framework (#include <zephyr/ztest.h>). Provides ZTEST_SUITE, ZTEST, zassert_* macros, fixtures, and setup/teardown hooks.
Twister: The test runner. Discovers test cases via testcase.yaml, builds each one for every allowed platform, flashes/runs it, and produces a report.
Both ship with Zephyr, you do not need a third-party framework.
2. Anatomy of a Test Suite
A minimal test suite is a small Zephyr application with three files:
tests/
└── my_feature/
├── CMakeLists.txt # builds the test binary
├── prj.conf # Kconfig for the test build
├── testcase.yaml # tells Twister what to do with it
└── src/
└── main.c # the actual tests
That layout — one folder per suite, sitting under tests/ at the repo root — is the convention every Zephyr-based project I have worked with follows.
3. Writing the Test Code
Open src/main.c and start with a suite declaration, then add cases:
/* ZTEST_SUITE(name, predicate, setup, before, after, teardown) */
ZTEST_SUITE(pid_controller, NULL, NULL, NULL, NULL, NULL);
ZTEST(pid_controller, test_compute_output)
{
/* identical setpoint and measurement -> no correction */
zassert_equal(PID_Compute_Output(/*setpoint=*/100, /*measurement=*/100), 0);
/* positive error -> positive (clamped) output */
zassert_equal(PID_Compute_Output(/*setpoint=*/100, /*measurement=*/ 0), 100);
/* invalid gain configuration -> -EINVAL */
zassert_equal(PID_Compute_Output(/*setpoint=*/-1, /*measurement=*/ 0), -EINVAL);
}
Common assertions
- Boolean checks: zassert_true(x) / zassert_false(x)- Integer / enum equality: zassert_equal(a, b)- Pointer equality: zassert_equal_ptr(a, b)- NULL checks: zassert_is_null(p) / zassert_not_null(p)- Compare n bytes: zassert_mem_equal(p, q, n)
- Floating-point / tolerance: zassert_within(a, b, tol)
Fixtures
When several cases need the same state, use a fixture:
struct fixture {
size_t size;
uint8_t buf[256];
};
static void *suite_setup(void)
{
static struct fixture f;
return &f;
}
static void before_each(void *f)
{
struct fixture *fix = f;
memset(fix->buf, 0xff, sizeof(fix->buf));
fix->size = 0;
}
ZTEST_SUITE(my_suite, NULL, suite_setup, before_each, NULL, NULL);
ZTEST_F(my_suite, test_buffer_initialized) /* note: ZTEST_F, not ZTEST */
{
zassert_equal(fixture->size, 0);
zassert_equal(fixture->buf[0], 0xff);
}
ZTEST_F automatically gives you a fixture pointer inside the test body.
4. Wiring it into the Build
prj.conf
The bare minimum:
CONFIG_ZTEST=y
CONFIG_NEWLIB_LIBC=y # for printf/floats in assertions
CONFIG_DEBUG_OPTIMIZATIONS=y
Add whatever Kconfig your code-under-test needs (drivers, subsystems, etc.).
CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_feature)
target_sources(app PRIVATE
src/main.c
${CMAKE_SOURCE_DIR}/../../src/modules/control/pid_controller.c
)
target_include_directories(app PRIVATE
${CMAKE_SOURCE_DIR}/../../src/modules/control
)
Tip — share common test plumbing. If you have repository-wide includes, mocks, or compile flags, factor them into a
tests/<your_project>_test.cmakefile andinclude(...)it at the top of each suite'sCMakeLists.txt. It keeps every suite's CMake to a single screen.
testcase.yaml
This is Twister's manifest. The simplest possible version:
tests
acme.my_feature
platform_allow
mps2/an521/cpu0
tags
acme
The test name is <group>.<suite> and is what you'll see in reports.
5. Running the Tests
From the project root, with the Zephyr environment activated:
# Run a single suite
west twister --clobber-output --jobs 1 -T tests/my_feature
# Run everything under tests/
west twister --clobber-output --jobs 1 -T tests
# Run on real hardware
west twister --hardware-map hardware-map.yaml \
--device-testing --inline-logs \
--clobber-output --jobs 1 -T tests
Twister deposits build artifacts and logs under twister-out/. Open twister-out/twister.log (or the per-suite handler.log) when something fails — Ztest prints the file, line, and the values that didn't match.
For quick local iteration without Twister, you can west build -b <board> tests/my_feature like any other Zephyr app and flash/run it directly.
6. Targeting Multiple Boards (the Multi-Target Chapter)
Real projects rarely have one board. You typically have:
-
An emulator for tests with no hardware dependencies (
native_sim,mps2/an521/cpu0,qemu_x86, …). These run in CI in seconds. -
One or more real board revisions (
acme_node@0.1.0,acme_node@0.1.1, …) for tests that need actual peripherals (sensors, LEDs, flash).
Twister's platform_allow field is what controls this per test. Pick the narrowest set that still covers the test's intent:
# Pure logic — runs anywhere with a libc. Put it on the emulator. tests: acme.pid_controller: platform_allow: - mps2/an521/cpu0 - acme_node@0.1.0 - acme_node@0.1.1 tags: testing # Driver / peripheral test — on-target only. tests: acme.gpio_isr_ordering: platform_allow: - acme_node@0.1.0 - acme_node@0.1.1 tags: - testing - gpio # Pure emulator (heavy logic that doesn't touch any DTS nodes). tests: acme.signal_filter_stress: platform_allow: mps2/an521/cpu0 tags: acme timeout: 180
Sharing per-board configuration
When every on-target test needs the same Kconfig or DTS overlays (for example, routing the test console out a USB CDC ACM port so Twister can read it), don't repeat it in each suite. Centralize it:
tests/
├── acme_test.cmake # common CMake helper
├── acme_test.conf # common Kconfig (on-target only)
└── boards/
└── acme_console.overlay # common DTS overlay (on-target only)
Then in the shared CMake helper, append them only when the target is a real board, not the emulator:
if(NOT ${BOARD} MATCHES "^mps2")
list(APPEND CONF_FILE ${PROJECT_ROOT}/tests/acme_test.conf)
list(APPEND DTC_OVERLAY_FILE ${PROJECT_ROOT}/tests/boards/acme_console.overlay)
endif()
list(APPEND CONF_FILE ${CMAKE_SOURCE_DIR}/prj.conf)
The benefit: each individual testcase.yaml only declares what platforms the test is valid on, and CMake takes care of supplying the right overlays and configs for each one.
Per-board overlays (when you actually need them)
If a single board revision needs a different pin or a tweaked node, drop a board-specific overlay next to the suite:
tests/my_feature/ ├── boards/ │ ├── acme_node_0_1_0.overlay │ └── acme_node_0_1_1.overlay ├── prj.conf └── ...
Zephyr automatically picks boards/<normalized_board_name>.overlay when building. No CMake changes required.
Rules of thumb
-
Default to the emulator. If the code under test does not touch DTS nodes or peripherals, put it on the QEMU/native platform only — those tests run in CI without hardware.
-
Hardware-only when unavoidable. Driver mutex ordering, LED animation, real flash, ADC scans — these belong on the on-target list.
-
List every supported board revision. It is tempting to list one and trust the others "work the same." They don't. List
acme_node@0.1.0andacme_node@0.1.1so a DTS regression on one revision is caught immediately. -
Tag aggressively. Tags let you slice runs in CI (
--tag testing,--tag gpio) without editing yaml.
8. Conclusion
The mental model that makes Zephyr testing click is this: a Ztest suite is its own standalone Zephyr application — same build system, same Kconfig, same devicetree — that just happens to call ZTEST functions instead of main(). When it runs on mps2/an521/cpu0, it's a tiny firmware image running inside QEMU. When it runs on acme_node@0.1.0, it's a tiny firmware image running on the silicon, talking to real peripherals over real buses.
That framing is what makes the approach so powerful. You can exercise a single module, a single driver, or a single piece of business logic lifted out of the full application flow, with nothing else running around it to muddy the picture. No state machine in another thread, no UI events firing, no background work — just the unit under test and the assertions you wrote against it. If a sensor driver behaves correctly in its standalone suite but misbehaves in the application, you now know the bug is in integration, not in the driver itself. That is an enormous amount of information for very little code.
Whether you are validating a pure algorithm on the emulator or proving that a specific board revision wires up its LEDs the way you expect, the recipe is the same: a small folder, a main.c with a few ZTEST cases, and a one-line platform_allow
Stay tuned
A more advanced guide will come soon, covering some of the remaining more complex features proposed by Zephyr!
