How to mock time in Rust tests and Cargo gotchas we met. I’m working in a team developing a big Rust project recently. The project has some features depending on time. We, the developers, want to be able to mock the time in the test. In this post, I’ll talk about the problems we have met, mostly related to Cargo.
I have created a GitHub repository doitian/rust-mock-time-demo, which contains all the following examples.
The First Attempt
The requirement looks straightforward at first glance since Rust supports conditional compilation and cfg test
is only active in the test. We can implement a function telling us current time, and the whole program must fetch the current time from it. The function has two different versions, in the non-test code, it just returns the real current time. In the test, it is possible to mock the current time through a thread local variable.
/// cfg-test/src/main.rs
use std::thread;
use std::time::{Duration, SystemTime, SystemTimeError};
#[cfg(not(test))]
pub fn now() -> SystemTime {
SystemTime::now()
}
#[cfg(test)]
pub mod mock_time {
use super::*;
use std::cell::RefCell;
thread_local! {
static MOCK_TIME: RefCell<Option<SystemTime>> = RefCell::new(None);
}
pub fn now() -> SystemTime {
MOCK_TIME.with(|cell| {
cell.borrow()
.as_ref()
.cloned()
.unwrap_or_else(SystemTime::now)
})
}
pub fn set_mock_time(time: SystemTime) {
MOCK_TIME.with(|cell| *cell.borrow_mut() = Some(time));
}
pub fn clear_mock_time() {
MOCK_TIME.with(|cell| *cell.borrow_mut() = None);
}
}
#[cfg(test)]
pub use mock_time::now;
Let’s try it in both cargo run
and cargo test
:
cargo run -p cfg-test
cargo test -p cfg-test
Work as expected, let’s call it a day.
Gochas of cfg test
However, we immediately found the issue when we try to add the mockable now
into the project. The project is complex and organized into many crates, so we create a crate for this utility as well. However, the compiler complains that it cannot find the function mock_time::set_mock_time
when using the cfg(test)
only functions in the test in another crate. Since Cargo builds each file under tests directory in a standalone crate, see how it can reproduce the issue, see cfg-test/tests/test_mock_time.rs
reproduces this issue using the command below
RUSTFLAGS='--cfg cfg_test_crate_tests' cargo test -p cfg-test
The cause is that cfg(test)
does not pass though dependencies. The crate test_mock_time
in tests is built with cfg(test)
, but in its dependency, the crate cfg-test
, cfg(test)
is not set. In simple words, cfg(test)
is only set for the crate current in test.
Feature
“Feature” is a well-known feature of Cargo, where a crate can customize how to build its dependencies. It is easy to switch to feature, just change cfg(test)
to cfg(feature = "...")
, for example:
use std::time::SystemTime;
#[cfg(not(feature = "mock-time"))]
pub fn now() -> SystemTime {
SystemTime::now()
}
The full example is in the cfg-feature-lib
directory in the repository.
Since the feature does not turn on in test automatically, we have to remember to enable the feature when running test. What’s worse, the command line argument --features
does not pass to workspace members, it is only for the top project. Take the repository as an example:
Running following command in the top directory does not enable feature mock-time
in cfg-feature-test-manual
crate.
cargo test --features mock-time -p cfg-feature-test-manual
Instead, it must be executed inside cfg-feature-test-manual
:
cd cfg-feature-test-manual && cargo test --features mock-time
After research, a trick comes out, which adds the dependency in both dependencies
and dev-dependencies
. The feature is only enabled in dev-dependencies
. See example cfg-feature-test-auto/Cargo.toml
.
[dependencies]
cfg-feature-lib = { path = "../cfg-feature-lib" }
[dev-dependencies.cfg-feature-lib]
path = "../cfg-feature-lib"
features = ["mock-time"]
We expect that mock-time
is automatically enabled in tests, but it turns out to be where weird things happen.
In simple words:
- If the top workspace project has added the conditional dependencies in Cargo.toml, than the feature is always enabled, no matter in tests or final executables. See a demo in this PR.
- If the top workspace does not have such trick, then cargo behaves differently when whether
--all
is specified or not. Another PR demonstrates this.
The feature based solution also has a inconvenient drawback. The feature can only be passed from a crate to its direct dependencies. To allow the time mock, a crate must be aware of whether its dependency depending on time
directly or indirectly.
For example, if the crate time
has a feature mock-time
, and
- package foo depends on bar, and bar depends on time.
- package root depends on foo, bar and time.
Then here is what root’s Cargo.toml
looks like:
[dependencies]
foo = "0.1.0"
bar = "0.1.0"
[features]
mock-time = ["foo/mock-time", "bar/mock-time", "time/mock-time"]
Final Solution
Because of the weird behavior of --all
, and how disturbing to set up the feature chain between dependencies, we decide to adopt RUSTFLAGS
. Indeed, I have used it once in an example above. RUSTFLAGS
is automatically enabled for all crates, no matter how deep the dependency is.
However, there is another gotcha about RUSTFLAGS
. The doc test does not observe RUSTFLAGS
, it uses RUSTDOCFLAGS
.
We have built a crate faketime from our experiences. Take a look if you are interested in mocking time in tests.