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.
The complete, runnable project is on GitHub โ 05_ztest/pytest_calc.
ZTest or pytest? ๐คโ
| ztest | pytest harness | |
|---|---|---|
| Test written in | C (zassert_*) | Python (assert) |
| Runs | inside the firmware | on the host, talking to the firmware |
| Sees | functions, variables, internal state | only what the device exposes (shell, serial, network) |
| Best for | unit-testing logic & functions | interaction / system tests |
| Pass/fail decided by | ztest 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 glue is the twister_harness pytest plugin (ships with Zephyr). It gives your test functions ready-to-use fixtures:
| Fixture | What it gives you |
|---|---|
dut | the raw device (DeviceAdapter) โ read/write its serial stream |
shell | a Shell helper โ send a command, get the output lines back |
mcumgr | MCUmgr 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
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:
| File | Written by | What it is |
|---|---|---|
twister-out/twister.xml | Twister | summary across all scenarios |
โฆ/calc.testing.pytest/report.xml | pytest | the junit-xml report |
โฆ/calc.testing.pytest/twister_harness.log | pytest | the 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โ
- Function โ ZTest. Behaviour (shell / serial / OTA / network) โ pytest.
- Three switches:
CONFIG_SHELL=y(+UART_NATIVE_PTY_0_ON_STDINOUTon native_sim),harness: pytestintestcase.yaml, and apytest/test_*.pyusing theshellfixture. - Run with the same
west twister -p native_sim -T tests/. - The pytest report isn't
twister.xmlโfind twister-out -name twister_harness.log. Prompt not found? You forgot to bind the native_sim UART to stdin/stdout.
Source: Zephyr pytest harness docs