Testing files and time
A test that reads the wall clock or a fixed path fails on another machine, at midnight, or when two test runs overlap. The answer is to give the test its own directory and its own notion of time, so the inputs are part of the test and the result is the same everywhere.
The lesson builds a test around a temporary directory, then one around durations and an injected clock, then reads the error for a test that cannot see the function it tests, and ends with the ignored test that is the exception: it needs the real world, so it runs only when asked.
A temporary directory per test
The test creates a directory under env::temp_dir() with the process id in the name, writes a file through the function under test, reads it back and removes the directory. Two runs cannot collide because the process id differs.
The library function takes the directory as a parameter, which is what makes the test possible: nothing in the function knows where the files go. The test output is one pass.
use std::fs;
use std::io;
use std::path::Path;
pub fn save(dir: &Path, name: &str, text: &str) -> io::Result<()> {
fs::create_dir_all(dir)?;
fs::write(dir.join(name), text)
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn saves_and_reads_a_file() {
let dir = env::temp_dir().join(format!("files-test-{}", std::process::id()));
save(&dir, "note.txt", "hello").unwrap();
let text = fs::read_to_string(dir.join("note.txt")).unwrap();
assert_eq!(text, "hello");
fs::remove_dir_all(&dir).unwrap();
}
}
cargo test
Compiling files v0.1.0 (/tmp/rw5/L066/files)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.95s
Running unittests src/lib.rs (target/debug/deps/files-792807a1d6d6e69d)
running 1 test
test tests::saves_and_reads_a_file ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests files
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The cleanup at the end of the test is not a guarantee: a panic leaves the directory behind. A real suite uses a guard that removes it on drop, which the lesson on Drop makes possible.
An injected clock
The duration formatter takes a Duration and the expiry check takes the current time as a number. Both are pure functions, so the tests pass fixed values and the expected strings, and no test waits for a second to pass.
The program formats one and a half seconds and twenty milliseconds, and checks an expiry before and after the limit. The run is two passes.
use std::time::Duration;
pub fn format_duration(duration: Duration) -> String {
let seconds = duration.as_secs();
let millis = duration.subsec_millis();
format!("{seconds}.{millis:03}s")
}
pub fn is_expired(start: u64, now: u64, limit: u64) -> bool {
now.saturating_sub(start) >= limit
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_known_durations() {
assert_eq!(format_duration(Duration::from_millis(1500)), "1.500s");
assert_eq!(format_duration(Duration::from_millis(20)), "0.020s");
}
#[test]
fn expiry_uses_the_injected_time() {
assert!(!is_expired(100, 150, 60));
assert!(is_expired(100, 160, 60));
}
}
cargo test
Compiling files v0.1.0 (/tmp/rw5/L066/files)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.12s
Running unittests src/lib.rs (target/debug/deps/files-792807a1d6d6e69d)
running 2 tests
test tests::expiry_uses_the_injected_time ... ok
test tests::formats_known_durations ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests files
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The clock is a parameter, not a call inside the function, which is the whole trick. The lesson on time uses the same idea for measurement.
A test that cannot see the function
The tests module is a child of the crate root, so it sees the parent items only through super::* or a full path. Without the import the call is E0425, and the help names the import.
The program drops the import and calls the function. The compiler error stops the test build, so cargo reports a compile failure rather than a test failure.
pub fn is_expired(start: u64, now: u64, limit: u64) -> bool {
now.saturating_sub(start) >= limit
}
#[cfg(test)]
mod tests {
#[test]
fn expiry_uses_the_injected_time() {
assert!(!is_expired(100, 150, 60));
}
}
cargo test
Compiling files v0.1.0 (/tmp/rw5/L066/files)
error[E0425]: cannot find function `is_expired` in this scope
--> src/lib.rs:9:18
|
9 | assert!(!is_expired(100, 150, 60));
| ^^^^^^^^^^ not found in this scope
|
help: consider importing this function
|
7 + use crate::is_expired;
|
For more information about this error, try `rustc --explain E0425`.
error: could not compile `files` (lib test) due to 1 previous error
The recommended import is use super::*; at the top of the tests module, which is what the earlier sections do. The path crate::is_expired works as well.
A test that needs the real world
Some checks cannot be made deterministic: reading the real clock, reaching a network, opening a device. #[ignore = "reason"] keeps such a test in the suite but out of the default run, and cargo test -- --ignored runs only those.
The default run reports one passed and one ignored; the second command runs the ignored one and reports a pass. The reason string appears in the summary, so the reader knows why it is skipped.
use std::time::Duration;
pub fn format_duration(duration: Duration) -> String {
let seconds = duration.as_secs();
let millis = duration.subsec_millis();
format!("{seconds}.{millis:03}s")
}
pub fn is_expired(start: u64, now: u64, limit: u64) -> bool {
now.saturating_sub(start) >= limit
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn formats_known_durations() {
assert_eq!(format_duration(Duration::from_millis(1500)), "1.500s");
}
#[test]
#[ignore = "reads the wall clock"]
fn reads_the_wall_clock() {
let now = SystemTime::now();
assert!(now.duration_since(UNIX_EPOCH).is_ok());
}
}
cargo test
cargo test -- --ignored
Compiling files v0.1.0 (/tmp/rw5/L066/files)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.47s
Running unittests src/lib.rs (target/debug/deps/files-792807a1d6d6e69d)
running 2 tests
test tests::reads_the_wall_clock ... ignored, reads the wall clock
test tests::formats_known_durations ... ok
test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests files
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The ignored run is the second output above, and it ends with one pass. The lesson on unit tests covers the rest of the harness.
Exercises
- Write a test that saves a file in a temporary directory and reads it back.
- Test a function that formats a duration with three fixed inputs.
- Mark a test that reads the system clock as ignored and run it with --ignored.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
Why does each test use its own directory?
Answer
So two runs cannot overwrite each other
A unique name per process keeps parallel or repeated runs independent.
-
What does injecting a clock mean?
Answer
Passing the current time as a parameter
The function becomes pure, and the test provides the times it wants.
-
What does #[ignore] do?
Answer
Keeps the test out of the default run
The test stays in the suite and runs only with --ignored or by name.
-
Why does a tests module need use super::*?
Answer
The module is a child and the items are in the parent
Names from the parent are not in scope in a child module without the import.
-
What does a test that removes its temporary directory leave behind when it panics?
Answer
The directory, because the cleanup line is not reached
The removal runs after the assertion, so a panic skips it; a drop guard fixes that.
-
Which command runs only the ignored tests?
Answer
cargo test -- --ignored
The flag after the double dash is passed to the test harness.
-
What makes a duration formatter easy to test?
Answer
It takes a Duration and returns a String
A pure function has no hidden input, so the test can pass any duration.
-
What does the test summary report for one passed and one ignored test?
Answer
1 passed; 0 failed; 1 ignored
The harness counts the ignored tests separately from the passes.
-
What does a compile error in the tests module do to cargo test?
Answer
It fails the build before any test runs
The whole test binary has to compile, so one error stops the run.
-
Why is a test that reads the wall clock fragile?
Answer
Its result depends on when it runs
The input changes between runs, which is exactly what a test must avoid.
The code on this page was built with rustc 1.96.1 and cargo 1.96.1 and tested on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev