
Mastering Zephyr RTOS Testing: Writing Your First C Tests with Ztest

Natalia Pluta
Monday, September 28, 2026
Author's Note: This article was written with the support of Zephyr 4.4.1 (Source: GitHub v4.4.1, Documentation). However, it is highly probable that the concepts and commands will work perfectly fine with older and newer versions of the RTOS.
Writing Your First Zephyr Unit Tests with Ztest
Series: This is Part 2 of Mastering Zephyr RTOS Testing. New to the series? Start with Part 1: Building and Running Your First Zephyr Tests with Twister.
Welcome to the second part of our Mastering Zephyr RTOS Testing series. In Article 1: Building and Running Your First Zephyr Tests with Twister, we learned how Twister discovers, builds, and executes tests across multiple platforms. Now it is time to write our own tests from scratch using Ztest, Zephyr's native C unit testing framework.
Prerequisites: This article assumes you have completed Article 1 and have a functional Zephyr workspace. All source code for this article is in the iomico-public/zephyr-rtos-testing repository - follow the setup instructions in its README before continuing.
The Module Under Test: A Calculator
Every testing tutorial needs something to test. For the purpose of this article a simple calculator module was created. It provides basic arithmetic operations: addition, subtraction, multiplication, and division.
The module lives at zephyr-rtos-testing/modules/calculator/ and exposes a straightforward API:
Four functions, each following the same pattern: take two integer operands and a pointer for the result, return 0 on success or -EINVAL on error. Clean, predictable, and easy to test.
The calculator module design was an intentional choice: by keeping the core logic free of OS dependencies, we can test it on any platform - including the minimal unit_testing pseudo-board that does not even compile the Zephyr kernel.
You can find the full implementation of the calculator module at zephyr-rtos-testing/modules/calculator/src/calculator.c.
A First Look at Ztest
Before we wire up the build system, let us look at what a Ztest test actually is. At its core, a single test is just a function that calls the calculator API and checks the result:
That is a complete, runnable test. Three things are happening here:
ZTEST_SUITE(calculator_add, ...)declares a suite - a named group of related tests. The fiveNULLarguments are lifecycle callbacks; they are defined asNULLhere for simplicity and explained in detail later in the article.ZTEST(calculator_add, test_positive_numbers)defines a single test belonging to that suite.zassert_ok(...)andzassert_equal(...)are the checks. Ifcalculator_addreturns non-zero, orresultis not5, the test fails and Ztest prints exactly where and why.
That is the entire idea: a suite contains tests, and tests contain assertions. Everything else in this article: the CMakeLists.txt, the Kconfig, the testcase.yaml - exists only to compile this code and hand it to a test runner. Let us put those supporting files in place now, then come back and write the full suite of Ztest tests.
The Test Environment
Setting up a Ztest test application requires several files to properly prepare the build environment. Let us walk through each one.
CMakeLists.txt - Build Configuration
This file tells CMake where to find Zephyr and what to compile.
The CMakeLists.txt here is more complex than usual because it supports two fundamentally different build modes at once - a full Zephyr OS build (on native_sim) and a minimal POSIX test harness (unit_testing). In practice, you will rarely need both at the same time.
Below you will find a breakdown of three common forms of CMakeLists.txt for Ztest applications, along with explanations of the differences and when to use each one.
Zephyr targets only (the most common case)
If you are only targeting Zephyr platforms (native_sim, real hardware, QEMU), the CMakeLists.txt is straightforward. ZEPHYR_EXTRA_MODULES registers the out-of-tree modules directory before find_package, so the build system picks up modules/CMakeLists.txt and modules/Kconfig automatically - the same pattern Zephyr uses for its own drivers/ directory. Enabling a module then only requires a Kconfig option in prj.conf.
unit_testing only
If your module has zero Zephyr kernel dependencies, the unit_testing board gives you the fastest possible build - no kernel compiled, just the Ztest framework against a bare POSIX environment. For unit_testing, the module must be added as a plain CMake subdirectory after find_package.
Both platforms (used in this article)
For this article we want to demonstrate both platforms in a single test suite, so the two forms are merged with a board detection guard. This is why the file looks more complex than either standalone version.
prj.conf - Kconfig Configuration
The prj.conf file enables the Kconfig options our test needs:
These two options are all you need to enable the Ztest framework and our calculator module:
CONFIG_ZTEST=y: Enables the Ztest framework itself.CONFIG_CALCULATOR=y: Enables our calculator module.
Without CONFIG_ZTEST, the ZTEST_SUITE and ZTEST macros will not be available. Without CONFIG_CALCULATOR, the module's sources will not be compiled into the build. This Kconfig option is defined in the calculator module's own Kconfig file, which Zephyr discovers via the ZEPHYR_EXTRA_MODULES path we set in CMakeLists.txt.
Platform-specific options go in board overlay files. For example, boards/native_sim.conf adds:
CONFIG_ZTEST_SHUFFLE=yrandomizes the order in which test suites and individual tests run. This is a best practice: if your tests accidentally depend on execution order (for example, one test leaves behind state that another test relies on), shuffle mode will expose that bug.
This board-overlay approach keeps the shared prj.conf minimal. Options that only make sense on specific platforms stay in their own files under boards/. The unit_testing board does not support CONFIG_ZTEST_SHUFFLE since it depends on CONFIG_TEST_RANDOM_GENERATOR which uses hardware entropy that is not available in the unit_testing environment, so shuffle mode is only enabled for native_sim.
testcase.yaml - Test Scenario Definition
This file tells Twister what test scenarios exist and where to run them:
The common block defines tags shared by all scenarios in this file. Two scenarios are defined:
article_2.native_simruns on thenative_simplatform with the full Zephyr OS. Theplatform_allowfield restricts it tonative_sim.article_2.unit_testingusestype: unit, which tells Twister to use the special unit testing build flow with theunit_testingpseudo-board.
Writing Your First Test Suite
With the environment files in place, let us write the actual tests. The complete test file is at zephyr-rtos-testing/tests/article_2/src/main.c.
Suite Registration with ZTEST_SUITE
We already got familiar with ZTEST_SUITE in the first look above. Let us look into that in more detail. Each Ztest file must register one or more test suites (docs). A suite is a logical grouping of related tests:
The ZTEST_SUITE macro takes six arguments:
Argument | Purpose | When it runs |
|---|---|---|
| Identifier for the suite | - |
| Function returning | Before the suite |
| Allocates and returns a fixture (shared state) | Once, before the first test |
| Runs before each test | Before each test |
| Runs after each test | After each test |
| Frees the fixture | Once, after the last test |
Passing NULL for all callbacks creates the simplest possible suite - no shared state, no setup, no cleanup.
Individual Tests with ZTEST
Each test is registered with the ZTEST macro, which takes the suite name and a test name. We already saw test_positive_numbers in the section A First Look at Ztest, here it is again - this time as one member of a larger suite:
Let us look at the full set of addition tests:
Notice how each test covers a distinct case: positive numbers, large numbers, negative numbers, mixed signs, zero, and error handling (NULL pointer). Good test suites exercise both the happy path and the error paths.
Assertions, Expectations, and Assumptions
Ztest provides three families of verification macros: zassert_*, zexpect_*, and zassume_* - that look similar but behave very differently.
zassert_* - Fatal Assertions
When a zassert_* macro fails, the test stops immediately. No further code in that test function executes. This is the right choice when subsequent assertions depend on the previous one succeeding:
If the first zassert_equal fails, the second one never runs. This prevents misleading cascading failures.
zexpect_* - Non-Fatal Expectations
When a zexpect_* macro fails, the failure is recorded but the test continues. The test is still marked as failed at the end, but you get to see all of the failures in a single run. This is useful when you want to verify multiple independent features at once:
If the addition produces the wrong result, you will still see whether subtraction, multiplication, and division also fail or whether the bug is related only to addition.
zassume_* - Assumptions
When a zassume_* macro's condition is not met, the test is skipped rather than failed. This makes them the right tool for preconditions - when a required setup step fails, skipping produces a clean signal instead of a cascade of meaningless failures from every subsequent check.
If calculator_add or calculator_sub fails, the test skips immediately - no misleading chain of zexpect_* failures for steps that never had a valid starting value.
Macro Reference
All three families share the same API suffixes. The only difference is what happens when a condition is not met.
Family | On failing condition |
|---|---|
| Test stops immediately |
| Failure recorded, test continues |
| Test skipped |
All macros take a condition to check and a message to print on failure. The message can be a simple string or a formatted string with arguments, just like printf.
All three families share the same suffixes - only the prefix and the on-failure behavior differ:
Macro suffix | Condition |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Additionally, only zassert_* macros support zassert_unreachable(...) to mark code that should never be executed. If execution reaches that point, the test fails.
Full API references:
Running the Tests with Twister
With the test sources and build files in place, Twister can execute the full suite across both configured platforms with a single command.
On native_sim
From your workspace root (the zephyr_testing_workspace/ directory created by west init), run:
You should see output similar to this:
22 of 22 test cases passed. That is 6 addition tests + 3 subtraction + 4 multiplication + 4 division + 5 edge case tests.
On unit_testing
The same CMakeLists.txt supports both platforms thanks to the BOARD MATCHES "unit_testing" check. To run on the unit_testing board:
Output:
22 of 22 test cases passed. The same tests that ran on native_sim also run on unit_testing with no changes. Notice how much faster the unit_testing platform is - 6.54 seconds vs 14.52 seconds for native_sim. This is because unit_testing does not compile the Zephyr kernel at all, so it has a much smaller build.
On Both Platforms
To run both platforms at once:
Output:
44 of 44 - that is the same 22 tests running on two platforms in under 15 seconds total.
Manual Build
Twister is the recommended way to run tests, but sometimes you want to build and run manually using west build - for example, when debugging a single test under GDB.
Manual Build for native_sim
To build the native_sim scenario manually, run:
Note: The command points at the test directory, not at a specific scenario from
testcase.yaml, and that is enough: a plainwest buildconfigures the application fromprj.confand automatically merges the matchingboards/<board>.conffragment (hereboards/native_sim.conf).west builddoes accept a-T <scenario>option (long form--test-item) that additionally applies a scenario'sextra_argsorextra_configs, but our scenarios declare none sowest build -b native_sim -p always zephyr-rtos-testing/tests/article_2 -T article_2.native_simproduces exactly the same configuration as the plain command above. Reach for-Twhen a scenario carries extra configuration that you would otherwise have to pass by hand. The same reasoning applies also to the other manual builds in this article.
This produces a native Linux executable at build/zephyr/zephyr.exe that can be run directly:
The output shows each test suite and test case in sequence, finishing with a summary:
Note that with CONFIG_ZTEST_SHUFFLE=y (enabled via boards/native_sim.conf), the order of suites and tests within each suite will vary between runs. This is expected and desirable.
Manual Build for unit_testing
To build the unit_testing scenario manually, run:
The output binary is named testbinary (not zephyr.exe):
Test output for the unit_testing platform looks like this:
Notice two differences from the native_sim run: there is no *** Booting Zephyr OS *** banner (because the kernel is not running), and the tests always run in a deterministic order (because CONFIG_ZTEST_SHUFFLE is not available on unit_testing).
Note: On real hardware,
west buildis followed bywest flashto program the board, and test output is read over a serial terminal. Running tests on physical targets will be covered in one of the future articles in this series.
Manual Build vs Twister: When to Use Each
Approach | Use case |
|---|---|
Twister | CI pipelines, multi-platform runs, reporting, running all tests with one command |
Manual build | Debugging with GDB, inspecting build artifacts, executing a single particular test |
In practice, you will use Twister 95% of the time. The manual approach is a backup solution for the remaining 5% of situations when you need low-level control.
Reading a Failure: What Ztest Tells You
Every test so far has passed. Green output is reassuring, but it teaches you nothing about the case that actually matters - when a test fails. A test is only useful if it clearly signals that something is wrong. So let us deliberately break one and see exactly what Ztest reports, then learn to read it.
Note: The failing test below is for demonstration only. It is not present in the final test suite in the repository. Add it temporarily, observe the output, then delete it.
Temporarily add this test to the calculator_add suite in src/main.c. It asserts that 2 + 3 equals 6, which is wrong on purpose:
Build and run it directly on native_sim. We disable shuffle here so the failing test appears first, making the output easier to follow:
The run now fails, and the top of the output shows precisely why:
That single Assertion failed line is packed with information. Let’s analyze each of the important fields:
WEST_TOPDIR/zephyr-rtos-testing/tests/article_2/src/main.c:38- the exact file and line of the failed assertion.WEST_TOPDIRis Ztest's placeholder for the root of your workspace.calculator_add_test_buggy_sum- the failing function, encoded as<suite>_<test>. In this case, it is thetest_buggy_sumtest in thecalculator_addtest suite.(result not equal to 6)- the auto-generated reason. Ztest derives this from the macro itself:zassert_equal(result, 6, ...)became "resultnot equal to6".2 + 3 should equal 6- your message, the third argument tozassert_equal, with the%dalready formatted. This is where you explain intent in simple language.FAIL - test_buggy_sum in 0.000 seconds- the per-test verdict.
Because zassert_equal is a fatal assertion (as we discussed earlier), the test stops the instant it fails and is marked FAIL. Every other test in the suite still runs - notice test_large_positive_numbers passing right after. The summary at the end of the run reflects the single failure:
The suite is now SUITE FAIL at 85.71% (6 of 7 passing), the failing test is listed first with a FAIL marker, and the binary ends with PROJECT EXECUTION FAILED instead of PROJECT EXECUTION SUCCESSFUL.
Run the same broken suite under Twister and you will see that it reports the failure and points you at the log to investigate:
Two things are worth noticing. First, Twister prints the path to a handler.log - that file holds the full device output, including the Assertion failed line above, so a CI failure is always traceable back to the exact assertion. Second, the moment one test in a configuration fails, Twister reports the other 22 tests as blocked rather than passed: a single broken test fails the whole configuration's result, which is exactly the strict behavior you want when validating code.
That handler.log is just one of many files Twister leaves behind in its twister-out/ directory - a directory we will explore in depth in the next article.
Once you have seen the failure, delete test_buggy_sum and rebuild. The suite should pass again. From here on, this is the workflow you should follow every time a test fails: read the Assertion failed line, jump to the file and line it names, compare the auto-generated reason against your own message, and fix the code (or the test).
What We Built
Let us step back and review the file structure we created for this article:
We covered:
Integrating a custom module via
ZEPHYR_EXTRA_MODULES,zephyr/module.yml, andCONFIG_CALCULATOR=yin Kconfig.Configuring Ztest via
prj.confwithCONFIG_ZTEST=yand board-specific config forCONFIG_ZTEST_SHUFFLE.Structuring test suites with
ZTEST_SUITEand writing individual tests withZTEST.Assertions, expectations, and assumptions -
zassert_*for fatal failures,zexpect_*for non-fatal,zassume_*to skip on unmet preconditions.Running tests with Twister on both
native_simandunit_testing.Reading a failure by interpreting Ztest's
Assertion failedoutput.Manual builds for debugging and direct execution.
Frequently Asked Questions
What is Ztest in Zephyr?
Ztest is Zephyr's built-in C unit testing framework. You group related tests into suites with ZTEST_SUITE, define individual tests with ZTEST, and verify results with assertion macros such as zassert_equal. Twister then builds and runs Ztest suites on simulators, the unit_testing board, or real hardware.
What is the difference between zassert, zexpect, and zassume?
They differ only in what happens when a check fails. zassert_* stops the test immediately. zexpect_* records the failure and lets the test continue, so you see every failing check in one run. zassume_* marks the test as skipped, which is the right choice for preconditions.
Should I run Zephyr unit tests on native_sim or unit_testing?
Use native_sim when the code under test needs Zephyr kernel services - it builds the full OS as a native Linux executable. Use unit_testing for pure logic with no kernel dependencies - it compiles only Ztest against a bare POSIX environment and is much faster (6.54 s vs 14.52 s for the same 22 tests in this article).
How do I run Ztest tests?
The recommended way is Twister: west twister -T <path-to-test> -p native_sim. For debugging a single test, build it with west build and run the resulting binary directly.
What's Next
When you ran Twister, it created a twister-out/ directory full of build artifacts, logs, and reports. In Explaining twister-out and Generating Code Coverage, we will crack open that directory and explore everything Twister generates behind the scenes. We will learn how to read the JSON and XML reports, navigate the per-platform build trees, and, most importantly, generate code coverage reports to measure how much of our code is exercised by the tests we wrote. After all, a green test suite is meaningless if it only covers a small percentage of the code.
Building a product on Zephyr? iomico's embedded engineers help teams design, build, and test Zephyr-based firmware. Explore our Zephyr development services and firmware development services.
