Device Testing¶

This guide covers the real-board testing workflow for ChuMicro libraries.
Use it when you want to:
- run
functional_tests/on a connected MicroPython or CircuitPython board - understand how
devices.ymlandworkspace.ymlare structured - use IDE play buttons for
functional_tests/
Host-side tests/ still run through normal CPython pytest. Real-board validation is an extra layer for behavior that mocks and unix-port checks cannot prove.
What gets configured¶
python scripts/run.py setup creates three gitignored files at the repo root when they do not already exist:
devices.yml: your local board registry and default target selectionworkspace.yml: host-side machinery (library_sources, deploy_targets, quality knobs); not a credentials filesecrets.toml: workspace-wide credentials + device-bound defaults (wifi SSID/password, broker host/port/auth) that flow intoruntime_config.msgpackat deploy time. Per-libraryfunctional_tests/config.tomloverrides land on top via deep-merge.
Full per-file detail (shape, purpose, who edits what) lives in config-files.md. The split lands per Decision 0057.
Generate the starter files¶
If the files already exist, setup leaves them alone.
The first-materialized devices.yml ships with an empty devices: [] registry. Use the add-device flow (next section) to populate it; hand-editing the YAML is still supported but no longer the primary path.
Register your boards via add-device¶
This is a thin shim around chumicro-workspace add-device. It probes the connected board (UID, machine type, board_id), writes the entry to devices.yml, and fills in defaults.{micropython,circuitpython} on first registration of each runtime.
Pass --runtime if you want to skip the auto-detect probe (faster), or --description "Desk board" to add a free-form note. See python scripts/run.py add-device --help for the full flag set.
Re-probing, replacing, and deleting entries¶
Three verbs cover an entry's later life, differing by how much they keep:
chumicro-workspace add-device <id> --force: update. Re-probes and refreshes the address + hardware-once fields, but keeps everything you typed (description, deploy_mode). The everyday "this board moved ports / I reflashed it" path.chumicro-workspace reset-device <id> --yes: replace. Re-probes the connected board and rebuilds the entry from silicon, dropping accumulated hand edits (description, deploy_mode, serial_baudrate) and re-deriving the hardware identity. The id and itsdefaults.<runtime>binding survive. Use it when an entry has drifted and you want it rebuilt from truth. The board must be connected; a probe failure points you back here.chumicro-workspace remove-device <id> --yes: delete. Drops the entry and nulls anydefaults:pointer to it so the file stays loadable. No board needed.
reset-device and remove-device are --yes-gated because they discard user-owned metadata a probe cannot regenerate.
Tune devices.yml if you need to¶
add-device writes a complete, usable entry on every registration: id, runtime, address, probed hardware identity, and (on first registration of each runtime) the matching defaults.<runtime> pointer. The day-to-day flow doesn't require hand-editing.
Read on if you want to override per-device deploy mode, change the defaults.ide_runtime, or just understand the shape of what's on disk.
defaults: block, fields and behavior (click to expand)
`defaults:` controls what happens when you run `python scripts/run.py test-libraries-functional` with no board-selection flags, and what the IDE play button targets for `functional_tests/`. Fields: | Field | Meaning | |---|---| | `micropython` | Default MicroPython device ID from the `devices:` list | | `circuitpython` | Default CircuitPython device ID from the `devices:` list | | `deploy_mode` | Workspace-wide default deploy mode: `flash` (the default, per [Decision 0047](https://github.com/ChuMicro/ChuMicro/blob/main/plans/decisions/0047-deploy-mode-flash-default.md)) or `ram` | | `ide_runtime` | Which runtime(s) IDE play buttons target: `micropython`, `circuitpython`, or `both` | Notes: - If `micropython` or `circuitpython` is omitted, ChuMicro falls back to the first configured board of that runtime. - `ide_runtime: both` collects each `functional_tests/test_*.py` function twice, once per runtime, so the IDE shows separate results.devices: entries, fields and per-device overrides (click to expand)
Each device entry must define: - `id` - `runtime` (`micropython` or `circuitpython`) - `address` Typical serial examples:devices:
- id: office-esp32-cp
runtime: circuitpython
address: /dev/cu.usbmodem101
description: Desk board running CircuitPython
deploy_mode: flash
- id: office-esp32-mp
runtime: micropython
address: /dev/cu.usbserial-0001
description: Desk board running MicroPython
deploy_mode: ram # opt out of the flash default for this board
Deploy mode behavior¶
| Deploy mode | MicroPython | CircuitPython |
|---|---|---|
ram |
mpremote mount-based execution |
raw-REPL inline execution |
flash |
mpremote fs cp -r copy mode |
copy to CIRCUITPY drive, then import from flash |
flash is the default for project deploys, examples, and functional tests (Decision 0047), because it runs your code the way a shipped deploy does. Opt into ram per device or per run when you are iterating on a single library that needs no persistence and no multi-library composition, and you want the edits to stay off the board's flash.
When you must use flash¶
ram is fine for single-library unit-style functional tests: no chumicro deps beyond the library under test, no runtime-config file.
flash is required when a test exercises:
- runtime-config-driven setup
kvstorepersistence semantics across resets- full
deploy → wifi → mqttchains extra_filesstaging on CircuitPython (CP RAM-mode deploy doesn't supportextra_files, see Decision 0056)
If a multi-stack test fails under ram, switch the device's deploy_mode to flash rather than chasing fallback paths like staging files via /remote/. They don't exist on CP RAM mode for a reason.
You usually don't have to switch by hand for the common cases: a requested ram deploy automatically falls back to flash, with a printed explanation, when the staged set contains a non-.py data file, when any library in the dependency closure declares requires_flash, or when the device sets supports_ram_mode: false. The run continues in flash. It is never silently mis-deployed. Hand-setting deploy_mode: flash is still the right move when you simply know a board can't hold the RAM payload.
On-device unit sweep (test-unit-on-device)¶
The functional tests above (libraries/<name>/functional_tests/) exercise real I/O on a board. The on-device unit sweep is different: it runs each library's cross-runtime unit suite (libraries/<name>/tests/), the same suite that runs on the unix ports, on real silicon, to catch behaviour that only differs on a real MCU's MicroPython / CircuitPython.
python scripts/run.py test-unit-on-device # both runtimes, all libraries
python scripts/run.py test-unit-on-device --library timing # one library
python scripts/run.py test-unit-on-device --runtime micropython
It is not part of the default preflight. Opt in with python scripts/run.py preflight --with-device-unit (parallel to --with-functional). When no board is configured for a runtime, that runtime is skipped cleanly rather than failing.
Per-library mode resolution, RAM-preferred. The sweep exists to validate RAM-capable libraries on silicon, so its last-resort preference is ram (not the flash default the functional path uses). Each library is resolved through the one shared deploy-mode policy with own-source scoping: a library that declares requires_flash, or ships a non-.py data file, switches to flash, but only that library, not the whole sweep. A library is not poisoned by a dependency's data file: chumicro_ntp depends on chumicro_sockets (which ships _ca_bundle.der) yet stays in the RAM group, because its own source has no data file and a pure unit test never reaches the dependency's bundle path.
Mode grouping. Libraries are grouped by resolved mode and each group runs as one single-mode session per runtime (the flash group first). A ram-preferred run on a RAM-capable board therefore becomes a fast RAM session over the light libraries plus a flash session over the requires_flash / data-file ones, with no per-library transport churn.
Behavioral pass/fail only. coverage.py cannot trace MicroPython / CircuitPython bytecode, so the sweep takes no --coverage-threshold; the per-library coverage gate stays a unix-port / CPython concern. A per-library failure on silicon is the sweep's output, not a preflight-gating quality check: the run completes and reports rather than turning preflight red. "Output, not a gate" governs how the sweep runs; it does not make the finding acceptable. A MemoryError from a large module on the Pi Pico W is a tracked defect to fix by splitting (see below), not an accepted end-state.
Every test file must run green on a freshly-reset Pi Pico W (CP + MP)¶
The on-device unit sweep runs each cross-runtime test file through one device interpreter. On the 264 KB Pi Pico W a very large class-organized module (the library it imports, plus that file's full set of test classes resident at once) can exhaust RAM with a MemoryError even on a freshly reset board running that file alone. PSRAM boards (Lolin S2) mask this; the Pico W under both CircuitPython and MicroPython is a distinct memory HAL, and it is the board class these libraries exist for.
Requirement, per Decision 0072: every cross-runtime test file must run green on a freshly-reset Pi Pico W on CP and MP. A file that OOMs there even with --per-file is a tracked defect, fixed by splitting, not left to run on PSRAM only.
scripts/run.py test-unit-on-device --per-file(orpytest ... --per-file) soft-resets the interpreter before each test file, not just each library, so a file never inherits a sibling's resident state. This is the mechanism the requirement runs on; it adds a reboot per file, so the default per-library reset stays the fast path for PSRAM boards and small libraries.- A file still
MemoryError-ing alone on a fresh Pico W must be split until each sub-file fits, mirroring source modules where one exists, then mechanically (lossless: test bodies stay byte-identical). There is no fixed tests-per-file cap: the ceiling is library-weight-dependent and differs CP vs MP, so the target is found empirically per library on the bench: split until that library's files all run green on a freshly-reset Pico W on both runtimes.
Configure secrets.toml¶
Credentials and device-bound defaults that every functional test inherits at deploy time, in one gitignored file. setup materializes it with placeholder values for wifi.ssid and mqtt.broker.host.
Edit secrets.toml once per clone, filling in your wifi credentials and your broker host. The mqtt library refuses to silently dial a third-party broker, so the broker host is required for mqtt-touching tests. For the file's shape and the full field list, see secrets.toml in the config-files guide.
Per-library functional_tests/config.toml files override secrets.toml at deploy time: both layers share the same section-namespaced shape, and per-library configs win at any nesting depth. The merged result is flattened to dotted keys (wifi.ssid, mqtt.broker.host) and msgpack-encoded into /runtime_config.msgpack; on-device tests read it via chumicro_config.load_runtime_config(), the same API user code uses.
When secrets.toml is missing or still carries the placeholder SSID, tests skip cleanly rather than running with empty credentials, so fresh clones stay green by default.
How the runtime-config staging works (click to expand)
Each library's `functional_tests/conftest.py` calls `chumicro_workspace.compose_runtime_config(secrets_toml=…, project_config=…)` from its `pytest_configure` hook, then hands the merged dict to `chumicro_pytest_device.set_runtime_config(config, payload)`. The pytest-device plugin msgpack-encodes the payload once per session and stages it via `transport.stage(extra_files={"/runtime_config.msgpack": …})`. When `secrets.toml` is missing or carries the placeholder SSID, the conftest registers `None` and the on-device test hits its silent-skip path (no creds, no run).Typical uses:
- WiFi credentials for networking tests (
libraries/{wifi,requests,http_server,mqtt,sockets,websockets}) - broker addresses for MQTT tests
- NTP servers or other environment-specific values
If a library does not need shared environment data, no override file is needed.
Run device tests from the CLI¶
Default target set¶
This uses devices.yml defaults:
- active runtime set from
defaults.ide_runtime - board selection from
defaults.micropython/defaults.circuitpython - deploy mode from each device entry, falling back to
defaults.deploy_mode
Common filters¶
# One runtime only
python scripts/run.py test-libraries-functional --runtime micropython
# Both runtimes, using defaults-backed board selection
python scripts/run.py test-libraries-functional --runtime both
# Override just one selected board
python scripts/run.py test-libraries-functional --micropython-device office-esp32-mp
python scripts/run.py test-libraries-functional --circuitpython-device office-esp32-cp
# Scope to a library
python scripts/run.py test-libraries-functional --library timing
# Scope to files whose name contains the substring
python scripts/run.py test-libraries-functional --library timing --file test_heartbeat
# Scope to functions whose name contains the substring
python scripts/run.py test-libraries-functional --library timing --function heartbeat_fires
# Force flash mode
python scripts/run.py test-libraries-functional --library timing --deploy-mode flash
Scoping flags:
--library <name>: restrict to one library.--file <substring>: match test file names (not function names).--function <substring>: match test function names (not file names).- Flags compose as AND:
--library timing --file test_heartbeat --function fires_on_intervalruns onlyfires_on_intervalinsidetest_heartbeat.pyintiming/. - Any filter that matches nothing exits 2 so typos don't silently pass.
devices.yml is always resolved at <workspace_root>/devices.yml. CI environments or unusual local layouts just need to drop a devices.yml at the workspace root before running tests.
Run functional tests via pytest directly¶
scripts/run.py test-libraries-functional is a thin wrapper over pytest. It runs pytest libraries/<name>/functional_tests/ with the --chumicro-* flags the device plugin exposes. Invoking pytest directly is useful when you want pytest-native UX (a specific folder, file, or method) without going through scripts/run.py.
# Whole directory
pytest libraries/timing/functional_tests/
# One file
pytest libraries/timing/functional_tests/test_heartbeat.py
# One function
pytest libraries/timing/functional_tests/test_heartbeat.py::test_heartbeat_fires
# Keyword filter (pytest-native)
pytest libraries/timing/functional_tests/ -k heartbeat
Target device selection still follows devices.yml defaults. Without a populated devices.yml, the tests skip with a clear message rather than failing.
Plugin options¶
Driving pytest directly gives you access to the same overrides test-libraries-functional passes through the plugin:
| Flag | Purpose |
|---|---|
--target {device,unix-port} |
Pick the execution backend. Default device (real board via chumicro-deploy). unix-port runs each test file in a MicroPython / CircuitPython unix-port subprocess instead. |
--runtime {micropython,circuitpython,both} |
Under --target device: override defaults.ide_runtime from devices.yml. Under --target unix-port: pick which runtime(s) to spawn, defaults to both. |
--micropython-device <id> |
Override defaults.micropython for this run (device target only). |
--circuitpython-device <id> |
Override defaults.circuitpython for this run (device target only). |
--micropython-binary <path> |
Unix-port MicroPython binary override (unix-port target only). Otherwise resolved via .tools/micropython.path then PATH. |
--circuitpython-binary <path> |
Unix-port CircuitPython binary override (unix-port target only). Same resolution order. |
--deploy-mode {ram,flash} |
Override each device's deploy_mode (device target only). |
--pr-summary |
Print the Markdown PR block at session end (paste-ready). Opt-in so IDE play-button runs stay quiet. |
--pr-summary-command <str> |
Literal command string rendered inside the PR block's - Command: line. test-libraries-functional passes its reconstructed CLI invocation here; direct pytest runs can supply their own label or omit it and get a bare pytest. |
Run unix-port unit tests from an IDE¶
Pytest is the single front door for every runtime: bare pytest libraries/<name>/tests/ runs CPython, and pytest libraries/<name>/tests/ --target unix-port --runtime <X> runs the same files under a MicroPython or CircuitPython unix-port subprocess. The plugin owns collection in both cases and stays out of the way of plain CPython collection when --target is left at its device default.
# Whole library's tests under MicroPython unix-port.
pytest libraries/timing/tests --target unix-port --runtime micropython
# One file under both unix-port runtimes (parametrized once per runtime).
pytest libraries/timing/tests/test_heartbeat.py --target unix-port --runtime both
# One function under CircuitPython unix-port.
pytest libraries/timing/tests/test_heartbeat.py::test_heartbeat_fires_on_real_clock --target unix-port --runtime circuitpython
IDE play buttons that target libraries/<name>/tests/ files go through CPython pytest by default. To click play and get the unix-port path instead, add a dedicated run configuration that passes --target unix-port --runtime <X> (and --no-cov, since coverage isn't collected through a subprocess). The --target flag is opt-in by design. The default pytest libraries/timing/tests/ invocation is unchanged.
Run workbench functional tests¶
Workbench packages (workbench/<name>/, Decision 0032) can ship their own functional_tests/ directories. Unlike library functional tests, these run host-side. The workbench tool is the project driving a connected board through its public API rather than code that ships onto the device.
# Run every workbench's functional_tests/ suite.
python scripts/run.py test-workbench-functional
# One workbench package.
python scripts/run.py test-workbench-functional --workbench deploy
# Scope by file / function like test-libraries-functional.
python scripts/run.py test-workbench-functional --file test_deploy_files_hardware --function circuitpython_ram -v
Device selection lives inside each suite's own conftest.py (typically by reading devices.yml defaults), so test-workbench-functional itself exposes no runtime / device flags. Change the target board via devices.yml defaults or by editing the suite's fixtures. Suites skip cleanly when devices.yml is missing or no matching board is configured.
Run functional_tests/ from an IDE¶
The repository registers a pytest plugin that intercepts explicit functional_tests/ targets and routes them to hardware instead of importing them on the host.
What that means in practice:
- host-side test runs still ignore
functional_tests/ - clicking play on a
functional_tests/test_*.pyfile or test function targets the configured board(s) - if
devices.ymldoes not exist yet, the test is skipped with a message telling you to run setup
PyCharm¶
PyCharm is the most-exercised IDE path. Once devices.yml is configured, play buttons on functional_tests/ files, directories, and functions route to hardware.
VS Code¶
VS Code uses the same pytest entrypoint and committed workspace settings/tasks as PyCharm. The structural support is present:
.vscode/settings.jsonenables pytest and points Pylance at all workspace source roots.vscode/tasks.jsonincludes aTest Libraries Functionaltask- explicit
functional_tests/targets go through the same pytest plugin
A dedicated live end-to-end VS Code validation pass remains on plans/next-up.md, so if you hit a VS Code-only issue, treat that as a real bug rather than user error.
How functional tests differ from host tests¶
| Test type | Location | How to run |
|---|---|---|
| Host/unit tests | libraries/<name>/tests/ |
pytest libraries/<name>/tests/ (iteration) or python scripts/run.py test --libraries <name> (with coverage gate) |
| Real-board functional tests | libraries/<name>/functional_tests/ |
python scripts/run.py test-libraries-functional --library <name> or an IDE play button |
| Workbench hardware-gated tests | workbench/<name>/functional_tests/ |
python scripts/run.py test-workbench-functional --workbench <name> |
| Cross-runtime unix-port tests | reuses tests/ |
pytest libraries/ --target unix-port --runtime both |
Wiping a board's filesystem¶
Functional-test runs accumulate stage residue on the device filesystem.
mpremote fs cp -r (and circup install) are append-only, nothing
pre-cleans. Pi Pico W MP boards in particular fill up over time
because the LittleFS partition is only ~850 KB; lolin-s2-mp is
better-off at 2 MB but still eventually hits the wall. When this
happens, deploys fail with cryptic mpremote: cp: ...: No space left
on device errors mid-test.
Two ways to wipe a board:
# Wipe + redeploy in one step.
chumicro-workspace deploy --wipe <project> [--device <id>]
# Wipe only, leave the board idle, no follow-up deploy.
# --yes is required; no flag = exit 2 + safety message.
chumicro-workspace reset-board --device <id> --yes
Both routes share one implementation. Per-runtime recipe matrix:
| Runtime / board family | Recipe |
|---|---|
| CircuitPython (any board) | import storage; storage.erase_filesystem(): reformats FAT, hard-resets the board, host re-enumerates USB-CDC |
| MicroPython on rp2 (Pi Pico W) | os.umount('/'); os.VfsLfs2.mkfs(rp2.Flash()); machine.soft_reset() |
| MicroPython on esp32 (Lolin S2 family) | os.umount('/'); os.VfsLfs2.mkfs(esp32.Partition.find(TYPE_DATA, label='vfs')[0]); machine.soft_reset() |
| Other MicroPython substrates | RuntimeError until a verified recipe lands |
The MicroPython path uses mkfs rather than a recursive os.remove
walk: LittleFS metadata + wear-leveling artifacts survive a
file-by-file delete, so a board with a small partition can still
hit ENOSPC mid-deploy after a non-mkfs "wipe." mkfs recovers
the full block budget every time. Verified on hardware
(pi-pico-w-mp, lolin-s2-mp).
Destructive on every runtime: wipes both managed scope (/lib/*,
/code.py / /main.py, runtime_config.msgpack) and out-of-scope
files (settings.toml, hand-edited boot.py, uploaded assets).
Firmware partitions are untouched.
RAM / mount mode is a no-op (printed). Those modes never wrote to flash so there's nothing persistent to wipe.
Troubleshooting¶
| Symptom | Likely cause | What to do |
|---|---|---|
No devices.yml found |
You have not generated local config yet | Run python scripts/run.py setup |
No devices configured in devices.yml |
The file exists but devices: is empty or defaults do not match real entries |
Add at least one board entry and update defaults: |
| CircuitPython RAM-mode run fails before tests start | Inline payload is too large for available heap | Re-run with --deploy-mode flash or set that board's deploy_mode: flash |
| Flash mode cannot find CIRCUITPY | Host has no CIRCUITPY* mount visible |
Replug the board's USB cable and confirm Finder / mount shows the drive; see docs/troubleshooting/macos-circuitpy.md |
A normal python scripts/run.py test run ignores functional_tests/ |
Expected behavior | Use test-libraries-functional or explicitly target the functional_tests/ path from your IDE |
mpremote: cp: ...: No space left on device mid-deploy |
LittleFS partition full of stage residue from prior runs | chumicro-workspace reset-board --device <id> --yes (see "Wiping a board's filesystem" above) |
| Lolin S2 CircuitPython deploys take 30–60 s per example | The board's flash write speed is the floor, not a recovery or retry signal | Expected; use Pi Pico W as the faster CP development target when iterating. Lolin S2 CP rsync slowness is a baseline characteristic, not a regression |
CIRCUITPY drive visible but every write fails with Read-only file system |
Drive is mounted RO: either the device-side FS flipped read-only, or FSKit handed the mount up with the RO flag. Distinct from the FSKit wedge, where the drive never appears at all | chumicro-workspace reset-board --device <id> --yes or unplug-and-replug; see docs/troubleshooting/macos-circuitpy.md |