Mastering Zephyr RTOS Testing Part 2: C Testing with Ztest

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:

/*
 * Calculator Module - Public API
 *
 * Pure arithmetic operations with no Zephyr kernel dependencies.
 * This module compiles on any platform, including the minimal
 * unit_testing board.
 */

#ifndef CALCULATOR_H
#define CALCULATOR_H

/**
 * @brief Add two integers
 *
 * @param a First operand
 * @param b Second operand
 * @param result Pointer where result will be stored
 *
 * @return 0 on success, -EINVAL if result is NULL
 */
int calculator_add(int a, int b, int *result);

/**
 * @brief Subtract two integers
 *
 * @param a First operand
 * @param b Second operand
 * @param result Pointer where result will be stored
 *
 * @return 0 on success, -EINVAL if result is NULL
 */
int calculator_sub(int a, int b, int *result);

/**
 * @brief Multiply two integers
 *
 * @param a First operand
 * @param b Second operand
 * @param result Pointer where result will be stored
 *
 * @return 0 on success, -EINVAL if result is NULL
 */
int calculator_mul(int a, int b, int *result);

/**
 * @brief Divide two integers (integer truncation toward zero)
 *
 * @param a Dividend
 * @param b Divisor
 * @param result Pointer where result will be stored
 *
 * @return 0 on success, -EINVAL if result is NULL or b is 0
 */
int calculator_div(int a, int b, int *result);

#endif /* CALCULATOR_H */

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:

#include <zephyr/ztest.h>
#include "calculator.h"

ZTEST_SUITE(calculator_add, NULL, NULL, NULL, NULL, NULL);

ZTEST(calculator_add, test_positive_numbers)
{
	int result;

	zassert_ok(calculator_add(2, 3, &result), "Addition should succeed");
	zassert_equal(result, 5, "2 + 3 should equal 5"

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 five NULL arguments are lifecycle callbacks; they are defined as NULL here 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(...) and zassert_equal(...) are the checks. If calculator_add returns non-zero, or result is not 5, 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.

cmake_minimum_required(VERSION 3.20.0)

list(APPEND ZEPHYR_EXTRA_MODULES
    ${CMAKE_CURRENT_SOURCE_DIR}/../../modules
)

find_package(Zephyr REQUIRED HINTS $ENV

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.

cmake_minimum_required(VERSION 3.20.0)

list(APPEND ZEPHYR_EXTRA_MODULES
    ${CMAKE_CURRENT_SOURCE_DIR}/../../modules
)

find_package(Zephyr COMPONENTS unittest REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(calculator_basic_test)

target_sources(testbinary PRIVATE src/main.c)

if(CONFIG_CALCULATOR)
    add_subdirectory(
        ${CMAKE_CURRENT_SOURCE_DIR}

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.

cmake_minimum_required(VERSION 3.20.0)

# Register the modules directory
list(APPEND ZEPHYR_EXTRA_MODULES
    ${CMAKE_CURRENT_SOURCE_DIR}/../../modules
)

if(BOARD MATCHES "unit_testing")
    find_package(Zephyr COMPONENTS unittest REQUIRED HINTS $ENV{ZEPHYR_BASE})
    set(TEST_TARGET testbinary)
else()
    find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
    set(TEST_TARGET app)
endif()

project(calculator_basic_test)

target_sources(${TEST_TARGET} PRIVATE src/main.c)

# unit_testing processes ZEPHYR_EXTRA_MODULES before Kconfig, so
# add_subdirectory_ifdef(CONFIG_CALCULATOR) never fires. Add the module
# explicitly after find_package.
if(BOARD MATCHES "unit_testing" AND CONFIG_CALCULATOR)
    add_subdirectory(
        ${CMAKE_CURRENT_SOURCE_DIR}

prj.conf - Kconfig Configuration

The prj.conf file enables the Kconfig options our test needs:

# Article 2 - Basic Ztest Configuration
#
# Minimal config shared by all platforms. Platform-specific options
# (e.g. ZTEST_SHUFFLE for native_sim) live in boards/<board>.conf.

CONFIG_ZTEST=y
CONFIG_CALCULATOR

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:

# native_sim-specific Ztest options
CONFIG_ZTEST_SHUFFLE

  • CONFIG_ZTEST_SHUFFLE=y randomizes 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:

# Article 2 - Basic Ztest Scenarios
#
# Two scenarios for the same calculator tests:
# - native_sim: full Zephyr OS, useful for tests that may later
# grow to need kernel services
# - unit_testing: minimal POSIX harness, fastest possible execution
# for pure-function tests

common:
  tags:
    - unit
    - calculator
    - ztest
    - article_2

tests:
  article_2.native_sim:
    platform_allow: native_sim
    timeout: 60

  article_2.unit_testing:
    type: unit
    timeout: 60

The common block defines tags shared by all scenarios in this file. Two scenarios are defined:

  • article_2.native_sim runs on the native_sim platform with the full Zephyr OS. The platform_allow field restricts it to native_sim.

  • article_2.unit_testing uses type: unit, which tells Twister to use the special unit testing build flow with the unit_testing pseudo-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:

#include <zephyr/ztest.h>
#include "calculator.h"

ZTEST_SUITE(calculator_add, NULL, NULL, NULL, NULL, NULL

The ZTEST_SUITE macro takes six arguments:

ZTEST_SUITE(suite_name, predicate, setup, before, after, teardown

Argument

Purpose

When it runs

suite_name

Identifier for the suite

-

predicate

Function returning true if the suite should run

Before the suite

setup

Allocates and returns a fixture (shared state)

Once, before the first test

before

Runs before each test

Before each test

after

Runs after each test

After each test

teardown

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:

ZTEST(calculator_add, test_positive_numbers)
{
	int result;

	zassert_ok(calculator_add(2, 3, &result),
		   "Addition should succeed");
	zassert_equal(result, 5, "2 + 3 should equal 5"

Let us look at the full set of addition tests:

ZTEST(calculator_add, test_large_positive_numbers)
{
	int result;

	zassert_ok(calculator_add(100, 200, &result),
		   "Addition should succeed");
	zassert_equal(result, 300,
		      "Expected 100 + 200 = 300, got %d", result);
}

ZTEST(calculator_add, test_negative_numbers)
{
	int result;

	zassert_ok(calculator_add(-2, -3, &result),
		   "Addition should succeed");
	zassert_equal(result, -5, "-2 + -3 should equal -5");
}

ZTEST(calculator_add, test_mixed_signs)
{
	int result;

	zassert_ok(calculator_add(-10, 5, &result),
		   "Addition should succeed");
	zassert_equal(result, -5, "-10 + 5 should equal -5");
}

ZTEST(calculator_add, test_zero)
{
	int result;

	zassert_ok(calculator_add(0, 0, &result),
		   "Addition should succeed");
	zassert_equal(result, 0, "0 + 0 should equal 0");

	zassert_ok(calculator_add(42, 0, &result),
		   "Addition should succeed");
	zassert_equal(result, 42, "42 + 0 should equal 42");
}

ZTEST(calculator_add, test_null_pointer)
{
	int ret = calculator_add(1, 2, NULL);

	zassert_equal(ret, -EINVAL,
		      "NULL result pointer should return -EINVAL"

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:

ZTEST(calculator_div, test_divide_by_zero)
{
	int result = 999;

	int ret = calculator_div(10, 0, &result);

	zassert_equal(ret, -EINVAL,
		      "Division by zero should return -EINVAL");
	zassert_equal(result, 999,
		      "Result should not be modified on error"

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:

ZTEST(calculator_edge_cases, test_multiple_operations)
{
	int result;

	calculator_add(10, 20, &result);
	zexpect_equal(result, 30,
		      "Expected 10 + 20 = 30, got %d", result);

	calculator_sub(result, 5, &result);
	zexpect_equal(result, 25,
		      "Expected 30 - 5 = 25, got %d", result);

	calculator_mul(result, 2, &result);
	zexpect_equal(result, 50,
		      "Expected 25 * 2 = 50, got %d", result);

	calculator_div(result, 10, &result);
	zexpect_equal(result, 5,
		      "Expected 50 / 10 = 5, got %d", result

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.

ZTEST(calculator_edge_cases, test_sign_expectations)
{
	int a, b, product;

	zassume_ok(calculator_add(7, 3, &a), "precondition: add must succeed");
	zassume_ok(calculator_sub(0, 5, &b), "precondition: sub must succeed");

	zexpect_true(a > 0, "a should be positive, got %d", a);
	zexpect_false(b > 0, "b should not be positive, got %d", b);

	zassert_ok(calculator_mul(a, b, &product));
	zexpect_true(product < 0, "positive * negative should be negative, got %d", product

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

zassert_*

Test stops immediately

zexpect_*

Failure recorded, test continues

zassume_*

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

_true(cond, ...)

cond is true

_false(cond, ...)

cond is false

_ok(expr, ...)

expr == 0

_not_ok(expr, ...)

expr != 0

_equal(a, b, ...)

a == b

_not_equal(a, b, ...)

a != b

_equal_ptr(a, b, ...)

pointers are equal

_within(a, b, d, ...)

|a - b| <= d

_between_inclusive(a, l, u, ...)

l <= a <= u

_is_null(ptr, ...)

ptr == NULL

_not_null(ptr, ...)

ptr != NULL

_mem_equal(buf, exp, sz, ...)

first sz bytes match

_str_equal(s1, s2, ...)

strings are equal

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:

source .venv/bin/activate
west twister -T zephyr-rtos-testing/tests/article_2 -p

You should see output similar to this:

INFO    - Using Ninja..
INFO    - Zephyr version: v4.4.1
INFO    - Using 'zephyr/gnu' toolchain variant.
INFO    - Building initial testsuite list...
INFO    - Built testsuite list in 0.00 seconds
INFO    - Writing JSON report /home/user/zephyr-workspace/twister-out/testplan.json
INFO    - JOBS: 12
INFO    - Adding tasks to the queue...
INFO    - Added initial list of jobs to queue
INFO    - Total complete:    1/   1  100%  built (not run):    0, filtered:    0, failed:    0, error:    0
INFO    - 2 test scenarios (1 configurations) selected, 0 configurations filtered (0 by static filter, 0 at runtime).
INFO    - 1 of 1 executed test configurations passed (100.00%), 0 built (not run), 0 failed, 0 errored, with no warnings in 14.52 seconds.
INFO    - 22 of 22 executed test cases passed (100.00%) on 1 out of total 1473 platforms (0.07%).
INFO    - 1 test configurations executed on platforms, 0 test configurations were only built.
INFO    - Saving reports...
INFO    - Writing JSON report /home/user/zephyr-workspace/twister-out/twister.json
INFO    - Writing xunit report /home/user/zephyr-workspace/twister-out/twister.xml...
INFO    - Writing xunit report /home/user/zephyr-workspace/twister-out/twister_report.xml...
INFO    -

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:

west twister -T zephyr-rtos-testing/tests/article_2 -p

Output:

INFO    - Using Ninja..
INFO    - Zephyr version: v4.4.1
INFO    - Using 'zephyr/gnu' toolchain variant.
INFO    - Building initial testsuite list...
INFO    - Built testsuite list in 0.00 seconds
INFO    - Writing JSON report /home/user/zephyr-workspace/twister-out/testplan.json
INFO    - JOBS: 12
INFO    - Adding tasks to the queue...
INFO    - Added initial list of jobs to queue
INFO    - Total complete:    1/   1  100%  built (not run):    0, filtered:    0, failed:    0, error:    0
INFO    - 2 test scenarios (1 configurations) selected, 0 configurations filtered (0 by static filter, 0 at runtime).
INFO    - 1 of 1 executed test configurations passed (100.00%), 0 built (not run), 0 failed, 0 errored, with no warnings in 6.54 seconds.
INFO    - 22 of 22 executed test cases passed (100.00%) on 1 out of total 1473 platforms (0.07%).
INFO    - 1 test configurations executed on platforms, 0 test configurations were only built.
INFO    - Saving reports...
INFO    - Writing JSON report /home/user/zephyr-workspace/twister-out/twister.json
INFO    - Writing xunit report /home/user/zephyr-workspace/twister-out/twister.xml...
INFO    - Writing xunit report /home/user/zephyr-workspace/twister-out/twister_report.xml...
INFO    -

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:

west twister -T

Output:

INFO    - Using Ninja..
INFO    - Zephyr version: v4.4.1
INFO    - Using 'zephyr/gnu' toolchain variant.
INFO    - Selecting default platforms per testsuite scenario
INFO    - Building initial testsuite list...
INFO    - Built testsuite list in 0.00 seconds
INFO    - Writing JSON report /home/user/zephyr-workspace/twister-out/testplan.json
INFO    - JOBS: 12
INFO    - Adding tasks to the queue...
INFO    - Added initial list of jobs to queue
INFO    - Total complete:    1/   2   50%  built (not run):    0, filtered:    0, failed:    0, error:    0
INFO    - Total complete:    2/   2  100%  built (not run):    0, filtered:    0, failed:    0, error:    0
INFO    - 2 test scenarios (2 configurations) selected, 0 configurations filtered (0 by static filter, 0 at runtime).
INFO    - 2 of 2 executed test configurations passed (100.00%), 0 built (not run), 0 failed, 0 errored, with no warnings in 14.55 seconds.
INFO    - 44 of 44 executed test cases passed (100.00%) on 2 out of total 1473 platforms (0.14%).
INFO    - 2 test configurations executed on platforms, 0 test configurations were only built.
INFO    - Saving reports...
INFO    - Writing JSON report /home/user/zephyr-workspace/twister-out/twister.json
INFO    - Writing xunit report /home/user/zephyr-workspace/twister-out/twister.xml...
INFO    - Writing xunit report /home/user/zephyr-workspace/twister-out/twister_report.xml...
INFO    -

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:

west build -p -b

Note: The command points at the test directory, not at a specific scenario from testcase.yaml, and that is enough: a plain west build configures the application from prj.conf and automatically merges the matching boards/<board>.conf fragment (here boards/native_sim.conf). west build does accept a -T <scenario> option (long form --test-item) that additionally applies a scenario's extra_args or extra_configs, but our scenarios declare none so west build -b native_sim -p always zephyr-rtos-testing/tests/article_2 -T article_2.native_sim produces exactly the same configuration as the plain command above. Reach for -T when 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:

*** Booting Zephyr OS build v4.4.1 ***
Running TESTSUITE calculator_add
===================================================================
START - test_large_positive_numbers
 PASS - test_large_positive_numbers in 0.000 seconds
===================================================================
START - test_mixed_signs
 PASS - test_mixed_signs in 0.000 seconds
===================================================================
START - test_negative_numbers
 PASS - test_negative_numbers in 0.000 seconds
===================================================================
START - test_null_pointer
 PASS - test_null_pointer in 0.000 seconds
===================================================================
START - test_positive_numbers
 PASS - test_positive_numbers in 0.000 seconds
===================================================================
START - test_zero
 PASS - test_zero in 0.000 seconds
===================================================================
TESTSUITE calculator_add succeeded

...

------ TESTSUITE SUMMARY START ------

SUITE PASS - 100.00% [calculator_add]: pass = 6, fail = 0, skip = 0, total = 6 duration = 0.000 seconds
 - PASS - [calculator_add.test_large_positive_numbers] duration = 0.000 seconds
 - PASS - [calculator_add.test_mixed_signs] duration = 0.000 seconds
 - PASS - [calculator_add.test_negative_numbers] duration = 0.000 seconds
 - PASS - [calculator_add.test_null_pointer] duration = 0.000 seconds
 - PASS - [calculator_add.test_positive_numbers] duration = 0.000 seconds
 - PASS - [calculator_add.test_zero] duration = 0.000 seconds

SUITE PASS - 100.00% [calculator_div]: pass = 4, fail = 0, skip = 0, total = 4 duration = 0.000 seconds
 - PASS - [calculator_div.test_divide_by_zero] duration = 0.000 seconds
 - PASS - [calculator_div.test_exact_division] duration = 0.000 seconds
 - PASS - [calculator_div.test_integer_truncation] duration = 0.000 seconds
 - PASS - [calculator_div.test_null_pointer] duration = 0.000 seconds

SUITE PASS - 100.00% [calculator_edge_cases]: pass = 5, fail = 0, skip = 0, total = 5 duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_chained_operations] duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_negative_division] duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_result_is_written] duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_result_sign] duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_sign_expectations] duration = 0.000 seconds

SUITE PASS - 100.00% [calculator_mul]: pass = 4, fail = 0, skip = 0, total = 4 duration = 0.000 seconds
 - PASS - [calculator_mul.test_by_zero] duration = 0.000 seconds
 - PASS - [calculator_mul.test_negative_numbers] duration = 0.000 seconds
 - PASS - [calculator_mul.test_null_pointer] duration = 0.000 seconds
 - PASS - [calculator_mul.test_positive_numbers] duration = 0.000 seconds

SUITE PASS - 100.00% [calculator_sub]: pass = 3, fail = 0, skip = 0, total = 3 duration = 0.000 seconds
 - PASS - [calculator_sub.test_negative_result] duration = 0.000 seconds
 - PASS - [calculator_sub.test_null_pointer] duration = 0.000 seconds
 - PASS - [calculator_sub.test_positive_result] duration = 0.000 seconds

------ TESTSUITE SUMMARY END ------

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:

west build -p -b

The output binary is named testbinary (not zephyr.exe):

Test output for the unit_testing platform looks like this:

Running TESTSUITE calculator_add
===================================================================
START - test_large_positive_numbers
 PASS - test_large_positive_numbers in 0.000 seconds
===================================================================
START - test_mixed_signs
 PASS - test_mixed_signs in 0.000 seconds
===================================================================

...

------ TESTSUITE SUMMARY START ------

SUITE PASS - 100.00% [calculator_add]: pass = 6, fail = 0, skip = 0, total = 6 duration = 0.000 seconds
 - PASS - [calculator_add.test_large_positive_numbers] duration = 0.000 seconds
 - PASS - [calculator_add.test_mixed_signs] duration = 0.000 seconds
 - PASS - [calculator_add.test_negative_numbers] duration = 0.000 seconds
 - PASS - [calculator_add.test_null_pointer] duration = 0.000 seconds
 - PASS - [calculator_add.test_positive_numbers] duration = 0.000 seconds
 - PASS - [calculator_add.test_zero] duration = 0.000 seconds

SUITE PASS - 100.00% [calculator_div]: pass = 4, fail = 0, skip = 0, total = 4 duration = 0.000 seconds
 - PASS - [calculator_div.test_divide_by_zero] duration = 0.000 seconds
 - PASS - [calculator_div.test_exact_division] duration = 0.000 seconds
 - PASS - [calculator_div.test_integer_truncation] duration = 0.000 seconds
 - PASS - [calculator_div.test_null_pointer] duration = 0.000 seconds

SUITE PASS - 100.00% [calculator_edge_cases]: pass = 5, fail = 0, skip = 0, total = 5 duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_chained_operations] duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_negative_division] duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_result_is_written] duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_result_sign] duration = 0.000 seconds
 - PASS - [calculator_edge_cases.test_sign_expectations] duration = 0.000 seconds

SUITE PASS - 100.00% [calculator_mul]: pass = 4, fail = 0, skip = 0, total = 4 duration = 0.000 seconds
 - PASS - [calculator_mul.test_by_zero] duration = 0.000 seconds
 - PASS - [calculator_mul.test_negative_numbers] duration = 0.000 seconds
 - PASS - [calculator_mul.test_null_pointer] duration = 0.000 seconds
 - PASS - [calculator_mul.test_positive_numbers] duration = 0.000 seconds

SUITE PASS - 100.00% [calculator_sub]: pass = 3, fail = 0, skip = 0, total = 3 duration = 0.000 seconds
 - PASS - [calculator_sub.test_negative_result] duration = 0.000 seconds
 - PASS - [calculator_sub.test_null_pointer] duration = 0.000 seconds
 - PASS - [calculator_sub.test_positive_result] duration = 0.000 seconds

------ TESTSUITE SUMMARY END ------

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 build is followed by west flash to 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:

ZTEST(calculator_add, test_buggy_sum)
{
	int result;

	zassert_ok(calculator_add(2, 3, &result), "Addition should succeed");
	zassert_equal(result, 6, "2 + 3 should equal %d", 6

Build and run it directly on native_sim. We disable shuffle here so the failing test appears first, making the output easier to follow:

west build -p always -b native_sim zephyr-rtos-testing/tests/article_2 -- -DCONFIG_ZTEST_SHUFFLE

The run now fails, and the top of the output shows precisely why:

*** Booting Zephyr OS build v4.4.1 ***
Running TESTSUITE calculator_add
===================================================================
START - test_buggy_sum

    Assertion failed at WEST_TOPDIR/zephyr-rtos-testing/tests/article_2/src/main.c:38: calculator_add_test_buggy_sum: (result not equal to 6)
2 + 3 should equal 6
 FAIL - test_buggy_sum in 0.000 seconds
===================================================================
START - test_large_positive_numbers
 PASS - test_large_positive_numbers in 0

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_TOPDIR is 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 the test_buggy_sum test in the calculator_add test suite.

  • (result not equal to 6) - the auto-generated reason. Ztest derives this from the macro itself: zassert_equal(result, 6, ...) became "result not equal to 6".

  • 2 + 3 should equal 6 - your message, the third argument to zassert_equal, with the %d already 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:

------ TESTSUITE SUMMARY START ------

SUITE FAIL -  85.71% [calculator_add]: pass = 6, fail = 1, skip = 0, total = 7 duration = 0.000 seconds
 - FAIL - [calculator_add.test_buggy_sum] duration = 0.000 seconds
 - PASS - [calculator_add.test_large_positive_numbers] duration = 0.000 seconds
 - PASS - [calculator_add.test_mixed_signs] duration = 0.000 seconds
 - PASS - [calculator_add.test_negative_numbers] duration = 0.000 seconds
 - PASS - [calculator_add.test_null_pointer] duration = 0.000 seconds
 - PASS - [calculator_add.test_positive_numbers] duration = 0.000 seconds
 - PASS - [calculator_add.test_zero] duration = 0.000 seconds

...

------ TESTSUITE SUMMARY END ------

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:

ERROR   - native_sim/native           article_2.native_sim                               FAILED: rc=1
ERROR   - see: /home/user/zephyr-workspace/twister-out/native_sim_native/host_gnu/zephyr-rtos-testing/tests/article_2/article_2.native_sim/handler.log
INFO    - Total complete:    1/   1  100%  built (not run):    0, filtered:    0, failed:    1, error:    0
INFO    - 2 test scenarios (1 configurations) selected, 0 configurations filtered (0 by static filter, 0 at runtime).
INFO    - 0 of 1 executed test configurations passed (0.00%), 0 built (not run), 1 failed, 0 errored, with no warnings in 7.39 seconds.
INFO    - 0 of 23 executed test cases passed (0.00%), 23 blocked on 1 out of total 1473

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:

zephyr-rtos-testing/
├── west.yml
├── modules/
│   ├── zephyr/
│   │   └── module.yml          # Registers modules/ as a Zephyr module
│   ├── CMakeLists.txt          # Adds calculator/ as a subdirectory
│   ├── Kconfig                 # Sources calculator/Kconfig to expose CONFIG_CALCULATOR
│   └── calculator/
│       ├── CMakeLists.txt      # Defines calculator library target (native_sim + unit_testing)
│       ├── Kconfig             # Defines CONFIG_CALCULATOR bool option
│       ├── include/calculator.h  # Calculator API (add, sub, mul, div)
│       └── src/calculator.c    # Calculator implementation
└── tests/
    └── article_2/
        ├── CMakeLists.txt      # Registers calculator module, selects native_sim or unit_testing build
        ├── prj.conf            # Common Kconfig for all platforms
        ├── testcase.yaml       # Two scenarios: native_sim + unit_testing
        ├── boards/
        │   └── native_sim.conf # native_sim specific Kconfig
        └── src/
            └── main.c          # 22 tests across 5 suites

We covered:

  • Integrating a custom module via ZEPHYR_EXTRA_MODULES, zephyr/module.yml, and CONFIG_CALCULATOR=y in Kconfig.

  • Configuring Ztest via prj.conf with CONFIG_ZTEST=y and board-specific config for CONFIG_ZTEST_SHUFFLE.

  • Structuring test suites with ZTEST_SUITE and writing individual tests with ZTEST.

  • 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_sim and unit_testing.

  • Reading a failure by interpreting Ztest's Assertion failed output.

  • 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.

Contact us

Address

400 Union Ave. SE,

Suite 200 

Olympia, WA 98501

Instant Messenger
Social Media

Contact us

Address

400 Union Ave. SE,

Suite 200 

Olympia, WA 98501

Instant Messenger
Social Media

Contact us

Address

400 Union Ave. SE,

Suite 200 

Olympia, WA 98501

Instant Messenger
Social Media