Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Unit Tests

In the previous exercises, we have built an implementation of a SmartBattery for our Mock Battery, and shown we can implement it into a service registry where it can be called upon by a service.

The next step is to test our implementation through a series of Unit Tests. Unit Tests will insure the implementation produces the results we expect. Early on, we had simply printed some values to the console to verify certain values. This is not a good method of testing because the print action cannot be part of the final build. Instead, we want to use a Unit Test harness that will allow us to inspect our otherwise silent build and report the values within it.

Why test?

We create tests for our components because we need to assert that they perform according to specification. Unlike our println! output, tests are non-intrusive and do not alter the code of the system under test. A test framework is used to call into the tested code and exercise it according to procedures that provide confidence that the system being tested will perform as expected if put into a larger system.

If we decide to add new features (such as support for a removable battery), we can use the test framework to monitor our development progress.

In fact, "Test-Driven Development" (TDD) is a proven software development approach that begins with defining the tests that match the specification of a software system and then builds the software to meet the tests.

We can also use a test framework to continue testing the component when in a different target environment, such as an embedded build. This gives us confidence that the code we are inserting into a system is good to go, as oftentimes subtle differences emerge when cross-compiling to a target.

Types of Tests and where to put them

A Unit Test typically is scoped to test only the capabilities of a single component or "unit" of code. An Integration Test is a test that either tests different implementations of a single unit structure, or else the integration of more than one component and the interactions between these components.

Code for Integration Tests are typically in a separate .rs file (often within a 'test' directory). Unit Tests may also be separate, but it is also conventional for Unit Tests to be included in the same Rust code file as the component code itself. In our Mock Battery case, we will put these first tests within our mock_battery.rs file. This keeps our tests co-located with the implementation and avoids the need for additional test scaffolding. If later the virtual battery or HAL layer is changed to match a different target, or the component is placed into a slightly different service structure, the tests are still valid and since they live with the code, it is good modular hygiene to include the unit tests along with the code file. Since we're implementing traits intended for broader reuse, but are only concerned with our one MockBattery implementation for now, embedding the tests here is both practical and instructive.

Preparing for testing

Rust's Cargo already supports a test framework, so there is no additional framework installation or setup needed.

However, there are some differences in the threading model that is used when we are testing using Embassy Executor.

We need an asynchronous context for testing our asynchronous method traits, so we construct our test flow in the same way we constructed our main() function, and will use the Embassy Executor to spawn asynchronous tasks that call upon the traits we wish to test.

We've already pre-emptively dealt with this when we created different definition for RawMutex depending upon our context in mutex.rs. Now is where that really comes into play.

🗎 In your mock_battery/Cargo.toml file, add this section:

[dev-dependencies]
embassy-executor = { workspace = true, features = ["arch-std"] }

Before testing

We run tests with the cargo test command.

If you issues a cargo test command now, by itself, you should see a compile step followed by a series of unit test reports for each module of the workspace, including all the dependencies. You may also see some test warnings or failures from some of these. Do not be concerned with these. If you are seeing test failures from embassy-executor-macros, this is because these tests are designed against an expected embedded target.

If this bothers you, you can get a clean all-workspace test run with the command cargo test --workspace --exclude embassy-executor-macros

But we are not really interested in the test results of the dependent modules (unless we were planning on contributing to those projects), so we will want to run our tests confined to our own project.

Use the command cargo test -p mock_battery to run the tests we define for our project.

This will report running 0 tests of course, because we haven't created any yet.

A Framework within a Framework - Embedded Unit Testing with Embassy

At this point, it may come as no surprise that the standard #[test] framework presented by Rust/Cargo is insufficient for our needs. The classic Rust test framework is great for standard non-async unit tests. But as we already know the systems we want to test are async. We've already refactored our code to be compatible with differing thread/mutex handling, so what now?

When enough isn't enough

There are several obstacles against us as we try to implement tests in the classic way if we want our code to:

  1. Be async compatible
  2. Be testable in both desktop and embedded contexts
  3. Be transferable to testing on an embedded context without further refactoring

Normal test functions do not have an async entry point, so calling upon async methods becomes problematic at the least.

Tests are assumed to execute in their own thread and succeed when completing that thread.

To maintain consistency with the way we execute our methods in general, we choose to employ Embassy Executor here again. This makes sense because it is the same mechanism by which our main() tasks have been dispatched.

But a test framework assumes the system under test -- in this case what we do in executor.run() -- will exit cleanly when completed. But Embassy executor.run() is designed to be non-returning function and there is no way to break its loop. The only remedy is to exit the process altogether, which is kind of a nuclear option but it does signal to the test framework that tests are complete for this unit.

There are async test harnesses -- our former friend tokio comes to mind -- but this is incompatible with the ultimate goal of having our tests be executable in an embedded target, and comes with refactoring ramifications of its own besides.

So we have created a sort of compatible async test framework pattern that deviates from the standard in order to address these shortcomings.

This pattern gives us a way to execute asynchronous tests in a form that mirrors our runtime execution model, while still remaining compatible with the cargo test harness.

In the next section, we’ll demonstrate this test pattern in action by validating two key SmartBattery methods — voltage() and current() — and then proceed to verify the rest of the initial state.