Skip to main content

Testing From the Outside: pytest

ztest gave you a real superpower: anything you can call from C, you can assert on. But plenty of firmware behaviour only exists outside the binary โ€” what a shell command prints, what crosses the serial line, whether an OTA update takes, whether the device answers a ping. No zassert can reach those. You have to stand where the user stands and poke the device from the outside.

That's the pytest harness: you write the test in Python, on your PC, and it drives the running firmware. Same Twister, same native_sim โ€” ztest and pytest are just two harnesses Twister can pick from to decide pass/fail. The only change is who renders the verdict: a Python assert, not a C one.

Grab the sample

The complete, runnable project is on GitHub โ€” 05_ztest/pytest_calc.



ZTest or pytest? ๐Ÿค”โ€‹

ztestpytest harness
Test written inC (zassert_*)Python (assert)
Runsinside the firmwareon the host, talking to the firmware
Seesfunctions, variables, internal stateonly what the device exposes (shell, serial, network)
Best forunit-testing logic & functionsinteraction / system tests
Pass/fail decided byztest console (PASS/FAIL)pytest assertions

๐Ÿ‘‰ Rule of thumb: testing a function โ†’ ZTest. Testing a behaviour โ†’ pytest.



How the pytest harness worksโ€‹

The build-and-run machine is exactly the same as ztest โ€” only the last mile changes. Instead of grepping the ztest console, Twister hands the running device to pytest:

The pytest harness loop: Twister builds and launches the firmware, hands the device to pytest, which collects test_calc.py, drives the firmware over the shell, reads the output, and reports pass/fail

The glue is the twister_harness pytest plugin (ships with Zephyr). It gives your test functions ready-to-use fixtures:

FixtureWhat it gives you
dutthe raw device (DeviceAdapter) โ€” read/write its serial stream
shella Shell helper โ€” send a command, get the output lines back
mcumgrMCUmgr client โ€” for OTA / DFU flows

For most tests shell is all you need.



The example: test add() through the shellโ€‹

We'll test the same add() as ztest_calc, but from the outside. The firmware exposes add as a shell command; the Python test types add 2 3 and checks the result.

pytest_calc/
โ”œโ”€โ”€ include/calc.h # same add() as ztest_calc
โ”œโ”€โ”€ src/calc.c
โ””โ”€โ”€ tests/
โ”œโ”€โ”€ CMakeLists.txt โ† builds main.c + calc.c
โ”œโ”€โ”€ prj.conf โ† shell on + UART to stdin/stdout
โ”œโ”€โ”€ testcase.yaml โ† harness: pytest
โ”œโ”€โ”€ main.c โ† registers the `add` shell command
โ””โ”€โ”€ pytest/
โ””โ”€โ”€ test_calc.py โ† the test (Python)

1 ยท Expose the function as a shell command โ€” tests/main.cโ€‹

#include <stdlib.h>
#include <zephyr/kernel.h>
#include <zephyr/shell/shell.h>
#include "calc.h"

static int cmd_add(const struct shell *sh, size_t argc, char **argv)
{
int a = atoi(argv[1]);
int b = atoi(argv[2]);

shell_print(sh, "%d", add(a, b)); /* prints the answer for the test to read */
return 0;
}

/* mandatory args = 3 (command + two operands), optional = 0 */
SHELL_CMD_ARG_REGISTER(add, NULL, "Add two integers: add <a> <b>", cmd_add, 3, 0);

int main(void) { return 0; }

2 ยท Turn on the shell โ€” tests/prj.confโ€‹

CONFIG_SHELL=y

# native_sim's UART is a PTY by default, so its output never reaches the
# host stdout the harness reads. Bind UART 0 to stdin/stdout instead:
CONFIG_UART_NATIVE_PTY_0_ON_STDINOUT=y
The #1 gotcha: "Prompt not found"

On native_sim the shell talks over a virtual UART that, by default, is a pseudo-terminal โ€” not the process's stdout. The pytest harness waits for the uart:~$ prompt on stdout, never sees it, and fails every test with Prompt not found. CONFIG_UART_NATIVE_PTY_0_ON_STDINOUT=y wires the UART to stdin/stdout so the harness can actually talk to it.

3 ยท Select the harness โ€” tests/testcase.yamlโ€‹

tests:
calc.testing.pytest:
platform_allow: [native_sim]
integration_platforms: [native_sim]
harness: pytest
harness_config:
pytest_root:
- "pytest/test_calc.py" # path is relative to this yaml; defaults to "pytest/"
tags: test_framework

harness: pytest is the whole switch. pytest_root tells Twister where the Python tests live.

4 ยท Write the test โ€” tests/pytest/test_calc.pyโ€‹

from twister_harness import Shell


def test_add_positives(shell: Shell):
output = "\n".join(shell.exec_command("add 2 3"))
assert "5" in output, f"2 + 3 should be 5, got: {output!r}"


def test_add_negatives(shell: Shell):
output = "\n".join(shell.exec_command("add -3 -4"))
assert "-7" in output, f"-3 + -4 should be -7, got: {output!r}"

shell.exec_command(...) sends the line to the device and returns its output as a list of strings. Everything else is plain pytest โ€” assert, fixtures, parametrize, whatever you already know.



Run it ๐Ÿƒโ€‹

The same Twister command as before โ€” native_sim is the platform, and you need pytest installed in your Python environment:

west twister -p native_sim -v -n -T tests/

INFO - 1/1 native_sim/native calc.testing.pytest PASSED (native 0.42s)
INFO - 2 of 2 executed test cases passed (100.00%) ...


Where's the pytest report? ๐Ÿ“„โ€‹

A common surprise: twister-out/twister.xml is Twister's own roll-up summary โ€” it doesn't look like a pytest report because it isn't one. Twister wraps pytest, so pytest's own artifacts sit deeper, in the scenario's build folder:

FileWritten byWhat it is
twister-out/twister.xmlTwistersummary across all scenarios
โ€ฆ/calc.testing.pytest/report.xmlpytestthe junit-xml report
โ€ฆ/calc.testing.pytest/twister_harness.logpytestthe familiar test session starts โ€ฆ N passed console

Don't hand-type that long path โ€” just find them:

find twister-out -name twister_harness.log # pytest console log
find twister-out -name report.xml # pytest junit report


What a failure looks likeโ€‹

Change an expected value and re-run โ€” pytest reports the failing assertion with the line and the captured shell output:

def test_add_positives(shell: Shell):
output = "\n".join(shell.exec_command("add 2 3"))
assert "6" in output, f"2 + 3 should be 5, got: {output!r}" # 6? it's 5 โ€” this fails
INFO - 1/1 native_sim/native calc.testing.pytest FAILED

FAILED test_calc.py::test_add_positives - AssertionError: 2 + 3 should be 5, got: '5'


Recapโ€‹

  1. Function โ†’ ZTest. Behaviour (shell / serial / OTA / network) โ†’ pytest.
  2. Three switches: CONFIG_SHELL=y (+ UART_NATIVE_PTY_0_ON_STDINOUT on native_sim), harness: pytest in testcase.yaml, and a pytest/test_*.py using the shell fixture.
  3. Run with the same west twister -p native_sim -T tests/.
  4. The pytest report isn't twister.xml โ€” find twister-out -name twister_harness.log.
  5. Prompt not found? You forgot to bind the native_sim UART to stdin/stdout.

Source: Zephyr pytest harness docs