Skip to content
Chiplab logo
Chiplab logo
Back to Now

Your rust embedded unit test runs on the host. The Book barely mentions it.

cargo test in an embedded Rust project either refuses to compile or passes suspiciously fast. Both outcomes confuse people, and both have the same cause: nobody said which machine the test was going to run on.

Zephyr has native_sim and unit_testing: two host binaries, two different machines, and a docs page that had to print the words "fundamentally different" because engineers kept treating them as two spellings of the same thing.

Rust has the same split. It has far fewer words written about it.

Plain cargo test on a host target is the unit_testing-shaped thing: a native x86 or ARM64 process, std present, the libtest harness, no board, no probe. cargo build --target thumbv7em-none-eabihf with no test run is a different, middle thing: it proves the code compiles for the architecture, nothing about runtime behavior. With a device-side harness and probe-rs run configured as the target runner, cargo test can flash and execute the test binary on silicon. Three checks, three different kinds of evidence.

What the Book says about this

Close to nothing.

The Embedded Rust Book has no unit-testing chapter. The nearest thing is a single bullet in the Portability chapter, listing the flavours a HAL implementation can come in: low-level hardware access via registers, an operating system such as sysfs under Linux, "via adapter, e.g. a mock of types for unit testing", and via a driver for hardware adapters like an I2C multiplexer.1

That is the official treatment. A mock-adapter pattern, mentioned in passing, inside a chapter about something else.

I am not going to inflate that into "the Book already explains the host/target split." It does not. It gestures at swapping the hardware layer for something host-friendly, the same idea the C world argues about every year in CMock is not a simulator, and stops. It does not tell you that cargo test will fail to build, why it fails, or what to type instead.

Someone asked for precisely that. A 2023 issue on rust-embedded/discovery, the Book's companion tutorial project, reads: "its gotten to the point where I need to unit test some of my non-hardware dependent functionality... How should I set up unit tests for testing individual modules in embedded projects? Examples would be massively appreciated."2

The gap is known. It is still a gap. So the explaining happened elsewhere: on Ferrous Systems' blog, in Embassy's CI scripts, and in forum threads where people re-derive the answer one at a time. That pattern is the same shape as the Zephyr confusion: thin official docs, community filling in by hand, everyone arriving surprised. It is most of why this post exists.

Two-column diagram contrasting cargo test running on the host with cargo test using probe-rs run to flash and execute on a real microcontroller

Why cargo test fails at all

The mechanism is boring, which is why it catches people.

cargo test links libtest. libtest depends on std. The defmt-test README says it in one line: the default test harness, the test crate, depends on std.3

Your project almost certainly has a .cargo/config.toml with something like [build] target = "thumbv7em-none-eabihf". Cargo applies that default target to every subcommand, not just build. So cargo test tries to compile a std-dependent harness for a bare-metal triple, and you get error[E0463]: can't find crate for 'test'.

A 2019 internals thread has the cleanest statement of the problem I have found: "Rust's test library requires the standard library so we can't use it in no_std environments. But what we can do is to run platform independent unit tests on the host system... The problem is that cargo uses the specified default target for all subcommands and tries to compile the std-dependent tests for our bare metal system, which of course fails."4

People hit this and reach for an on-target harness, because the error mentions the target and they assume the target is required. Usually it is not. A user in 2020 worked it out mid-thread: "Ah yes — I see now that I've got a .cargo/config file that declares a default build target. Performing cargo test --target x86_64-apple-darwin does indeed work."5 A 2023 answer states the rule better than any doc page: for a lib crate, if your tests can run on the host, meaning you are not testing hardware-specific functionality, the default libtest harness works fine, and you only need defmt-test when the test has to execute on the target architecture.6

Ferrous Systems drew the same line back in 2021: functional, IO-less code gets tested on the host with the built-in #[test] machinery, and code that interacts with hardware gets tested on the target with defmt-test.7 That is the whole taxonomy. It lives on a consultancy blog, not in the Book.

The mechanical fix is one attribute. #![cfg_attr(not(test), no_std)] makes the crate no_std for normal builds and lets std back in under cfg(test), so the same source compiles two ways. Combine it with an explicit --target <host triple>, or split the register-touching code into its own crate away from the logic: PAC access, cortex-m intrinsics, interrupt vectors.7

Annotated diagram showing how cfg_attr(not(test), no_std) lets one crate compile as no_std firmware and as a std host test binary

Embassy already sorted this out in public

The best documentation of the Rust host/target split is not prose. It is a shell script.

Embassy's .github/ci/test.sh runs plain cargo test on the host, and not only against pure-logic crates:

cargo test --manifest-path ./embassy-executor/Cargo.toml --features metadata-name
cargo test --manifest-path ./embassy-sync/Cargo.toml
cargo test --manifest-path ./embassy-time/Cargo.toml --features mock-driver,embassy-time-queue-utils/generic-queue-8
cargo test --manifest-path ./embassy-nrf/Cargo.toml --no-default-features --features nrf52840,time-driver-rtc1,gpiote
cargo test --manifest-path ./embassy-stm32/Cargo.toml --no-default-features --features stm32f429vg,time-driver-any,exti,single-bank,low-power,chrono,test

Those last two lines are the interesting ones. embassy-nrf with nrf52840 and embassy-stm32 with stm32f429vg are chip HAL crates, compiled and tested on a CI runner with no nRF and no STM32 attached.8 The trick is the first line of embassy-stm32/src/lib.rs: #![cfg_attr(not(test), no_std)].9

An Embassy maintainer spelled out the recipe to a user who filed an issue titled "Any guidance on the correct way to allow cargo test to work?": make the crate no_std only when not testing with #![cfg_attr(not(test), no_std)], accept that this builds embedded-only libs for x86 and that this might or might not work, and adjust dependency features: embassy-nrf needs default-features = false to disable rt, because that is how it is tested in CI.10

"Might or might not work" is the honest part. Host-testing a HAL crate gets you the type system, the state machines, the arithmetic. It does not get you the peripheral.

Which is why Embassy keeps the other half somewhere else: tests/stm32/, tests/nrf/, tests/rp/, flashed onto a hardware farm by teleprobe, Embassy's own wrapper that uses probe-rs underneath for flashing, attaching and decoding RTT.11 A review comment on one PR makes the division sound as ordinary as it should: can you add on-hardware tests for HASH here, this is a prime candidate for testing since it needs no connections to external hardware.11

Two directories. Two judges. Nobody in that repo confuses them.

Diagram of Embassy's split between HAL crates tested on the host in CI and tests/stm32 flashed to real boards through teleprobe and probe-rs

The target side, precisely

There is no probe-rs test. probe-rs has run, attach, and a handful of other device commands, but no subcommand dedicated to running tests.12 You point Cargo at probe-rs run as the target runner, so cargo test hands it a binary the way it would hand one to any runner:

[target.thumbv7em-none-eabihf]
runner = 'probe-rs run --chip STM32F767ZITx'

probe-rs run flashes, resets, starts the binary and streams RTT and defmt output plus panics back to your console, for any embedded binary in any language.12 probe-rs attach is the same thing without the reset and without flashing. The maintainers' current guidance is short: prefer probe-rs run for new setups, cargo-embed adds an interactive RTT terminal but is expected to be phased out, and cargo-flash just flashes.13 All of it needs a physical debug probe translating host USB or Ethernet into SWD or JTAG.13

defmt-test is the Knurling harness for the device side: a test harness that lets you write and run unit tests on your device as if you were using the built-in #[test] attribute. You put #[defmt_test::tests] on a mod, write #[test] functions inside, and you get #[init], #[before_each], #[after_each], #[teardown] and #[should_error]. You must set harness = false in Cargo.toml, because the default harness needs std.3

embedded-test, from the probe-rs org, makes the host half explicit instead of implicit. It is a libtest-compatible runner where, for each test case, probe-rs resets the device, signals which test to run via semihosting SYS_GET_CMDLINE, and waits for the device to report success or failure via SYS_EXIT. probe-rs run autodetects whether the ELF is normal firmware or a test binary.14

And it is not theoretical. rp-hal's migration off the deprecated probe-run shows cargo test reaching silicon:15

$ CARGO_TARGET_THUMBV6M_NONE_EABI_RUNNER="probe-rs run" cargo test --test dma_m2m_u8 -- --chip rp2040
    Finished test [optimized + debuginfo] target(s) in 0.03s
     Running tests/dma_m2m_u8.rs (target/thumbv6m-none-eabi/debug/deps/dma_m2m_u8-ebcd141c1ecc1d99)
     Erasing sectors ✓ ...

"Erasing sectors" is the line that tells you which category you are in.

What each pass buys you

A green host cargo test says your logic is correct on x86, your state machines step the way you asserted, your parsers parse, your arithmetic does not overflow, and, if you did the Embassy thing, large parts of a HAL crate compile and behave for a chosen chip feature set. That is a lot, it runs in seconds, it needs no board, and you get the whole host toolchain: coverage, sanitizers, a real debugger.

It says nothing about a register write, a peripheral, an interrupt latency, or the image you are about to ship. Not because host testing is weak, but because that binary was never built for your target.

A green probe-rs run pass says the flashed image did the thing on that specific chip, once, on the desk it was attached to.

Both belong in CI. Most pipelines have neither: firmware CI ends at the linker for a depressing share of teams, and "it compiles for thumbv7em" is not a test result.

The short version: pure logic goes to host cargo test. Target compatibility goes to a target-triple build with no run. Peripheral or interrupt behavior goes to an on-device harness through probe-rs run.

The one thing to stop doing is what the Zephyr crowd does with native_sim: running the fast one and reporting it as the slow one. Rust makes that easier to do by accident, because both invocations start with cargo test.

Zephyr eventually wrote "fundamentally different" into its docs. The Embedded Rust Book has a bullet about mock adapters. The community filled the rest in by hand, one forum thread at a time, and that is worth naming rather than pretending the docs cover it.

Sources

Footnotes

  1. The Embedded Rust Book, "Portability," §HAL implementation, accessed 2026-08-21. https://docs.rust-embedded.org/book/portability/index.html

  2. rust-embedded, "Unit testing?," discovery issue 544, 2023-10-28. https://github.com/rust-embedded/discovery/issues/544

  3. Knurling / Ferrous Systems, "defmt-test," README, accessed 2026-08-21. https://github.com/knurling-rs/defmt/blob/main/firmware/defmt-test/README.md 2

  4. internals.rust-lang.org, "Set default target for cargo build but not for cargo test," 2019-04-06. https://internals.rust-lang.org/t/set-default-target-for-cargo-build-but-not-for-cargo-test/9777/1

  5. users.rust-lang.org, "Running doc and unit tests for nostd," 2020-10-16. https://users.rust-lang.org/t/running-doc-and-unit-tests-for-nostd/50179

  6. users.rust-lang.org, "Cargo test for embedded target fails," 2023-08-25. https://users.rust-lang.org/t/cargo-test-for-embedded-target-fails/98926

  7. Ferrous Systems, "Testing an embedded application," 2021. https://ferrous-systems.com/blog/test-embedded-app/ 2

  8. Embassy, host-side CI script .github/ci/test.sh, commit 63e74af. https://github.com/embassy-rs/embassy/blob/63e74af9badbb4e82580e3121c4b341dcf63d5cc/.github/ci/test.sh

  9. Embassy, embassy-stm32/src/lib.rs line 1, commit 63e74af. https://github.com/embassy-rs/embassy/blob/63e74af9badbb4e82580e3121c4b341dcf63d5cc/embassy-stm32/src/lib.rs#L1

  10. Embassy, "Any guidance on the correct way to allow cargo test to work?," issue 1564, 2023-06-16. https://github.com/embassy-rs/embassy/issues/1564

  11. Embassy, pull request 2528, and the teleprobe repository. https://github.com/embassy-rs/embassy/pull/2528 and https://github.com/embassy-rs/teleprobe 2

  12. probe-rs, "probe-rs CLI," documentation, accessed 2026-08-21. https://probe.rs/docs/tools/probe-rs/ 2

  13. probe-rs, crates.io README, and "About probe-rs." https://crates.io/crates/probe-rs and https://probe.rs/docs/overview/about-probe-rs/ 2

  14. probe-rs, "embedded-test" v0.7.1 documentation. https://docs.rs/embedded-test/latest/embedded_test/ and https://github.com/probe-rs/embedded-test/

  15. rp-hal, "Migrate from probe-run to probe-rs run," pull request 698, and Ferrous Systems, "probe-run deprecation." https://github.com/rp-rs/rp-hal/pull/698 and https://ferrous-systems.com/blog/probe-run-deprecation/

Daniel Frassinelli
Published Aug 17, 2026