Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ repository = "https://github.com/XTSoftwareLabs/shimforge"
readme = "README.md"
keywords = ["mock", "testing", "detour"]
categories = ["development-tools::testing"]
include = ["src/**", "tests/**", "Cargo.toml", "COMMERCIAL.md", "LICENSE*", "README.md"]
include = ["src/**", "tests/**", "examples/**", "Cargo.toml", "COMMERCIAL.md", "LICENSE*", "README.md"]

[workspace]
members = ["macros"]
Expand All @@ -27,6 +27,15 @@ hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["client", "client-legacy", "http1"] }
tokio = { version = "1", features = ["io-util", "macros", "net", "rt"], default-features = false }

# Examples double as tests: `test = true` makes `cargo test` run their #[test] functions.
[[example]]
name = "clock"
test = true

[[example]]
name = "environment"
test = true

[profile.test]
opt-level = 0
debug = true
Expand Down
125 changes: 125 additions & 0 deletions examples/clock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//! Time-dependent logic tested at chosen instants, without a clock trait.
//!
//! Code that calls `SystemTime::now()` directly usually has to be rewritten to
//! take a clock before a test can choose the time. Mocking `SystemTime::now`
//! lets the tests below pick the time, step across boundaries, and let time pass
//! without sleeping, while the code under test stays as it is.
//!
//! Run the tests with `cargo test --example clock`.

#![forbid(unsafe_code)]

use std::time::{Duration, SystemTime, UNIX_EPOCH};

// Business code below reads the system clock directly and stays unchanged.

fn is_morning() -> bool {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("the clock is after 1970")
.as_secs();
let hour = seconds % 86_400 / 3_600;
(7..12).contains(&hour)
}

struct Token {
expires_at: SystemTime,
}

impl Token {
/// A token stays valid for 30 seconds past its expiry to absorb clock skew.
fn is_expired(&self) -> bool {
SystemTime::now() > self.expires_at + Duration::from_secs(30)
}
}

fn main() {
println!("morning in UTC: {}", is_morning());
let token = Token {
expires_at: SystemTime::now(),
};
println!(
"a token that expires now is expired: {}",
token.is_expired()
);
}

#[cfg(test)]
mod tests {
use super::*;
use shimforge::{Session, mock};
use std::thread;

/// 2026-01-01T00:00:00Z.
const NEW_YEAR: u64 = 1_767_225_600;

fn at(seconds: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(seconds)
}

#[test]
fn the_morning_check_runs_at_a_chosen_time() {
let mut session = Session::new();
let now = mock!(session, SystemTime::now, fn() -> SystemTime);
now.expect().once().returns(at(NEW_YEAR + 10 * 3_600));
now.expect().once().returns(at(NEW_YEAR + 13 * 3_600));

assert!(is_morning());
assert!(!is_morning());
session.verify();
}

#[test]
fn both_edges_of_the_morning_are_checked() {
let edges = [
(6 * 3_600 + 59 * 60 + 59, false),
(7 * 3_600, true),
(11 * 3_600 + 59 * 60 + 59, true),
(12 * 3_600, false),
];
let mut session = Session::new();
let now = mock!(session, SystemTime::now, fn() -> SystemTime);
let mut times = edges.map(|(offset, _)| at(NEW_YEAR + offset)).into_iter();
now.expect()
.times(4)
.returning(move || times.next().unwrap());

for (offset, morning) in edges {
assert_eq!(is_morning(), morning, "{offset} seconds after midnight");
}
session.verify();
}

#[test]
fn a_token_expires_as_time_passes_without_sleeping() {
let mut session = Session::new();
let now = mock!(session, SystemTime::now, fn() -> SystemTime);
let mut current = at(NEW_YEAR);
// Each reading of the clock is 20 seconds after the previous one.
now.expect().times(4).returning(move || {
let reading = current;
current += Duration::from_secs(20);
reading
});

let token = Token {
expires_at: at(NEW_YEAR + 10),
};
let checks: Vec<bool> = (0..4).map(|_| token.is_expired()).collect();
// Expired only once the clock is more than 30 seconds past the expiry.
assert_eq!(checks, [false, false, false, true]);
session.verify();
}

#[test]
fn other_threads_keep_the_real_clock() {
let mut session = Session::new();
let now = mock!(session, SystemTime::now, fn() -> SystemTime);
now.expect().returns(at(NEW_YEAR));
assert_eq!(SystemTime::now(), at(NEW_YEAR));

// A thread with no session of its own reads the machine clock.
let real = thread::spawn(SystemTime::now).join().unwrap();
assert!(real > at(NEW_YEAR), "{real:?}");
}
}
157 changes: 157 additions & 0 deletions examples/environment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//! Home-directory handling tested without writing `HOME`.
//!
//! Tests that point `HOME` somewhere else usually call `env::set_var`, which is
//! `unsafe` in the 2024 edition and changes the environment of the whole test
//! process. Tests running in parallel then read each other's value and fail at
//! random, so projects add environment locks, run those tests one at a time, or
//! change the code to take the home directory as a parameter. Mocking
//! `env::var_os` gives each test thread its own `HOME` instead: nothing is
//! written, no `unsafe` is needed, and the tests below still run in parallel.
//!
//! Run the tests with `cargo test --example environment`.

#![forbid(unsafe_code)]

use std::env;
use std::path::{Path, PathBuf};

// Business code below reads the process environment directly and stays unchanged.

fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.filter(|home| !home.is_empty())
.map(PathBuf::from)
}

/// Shows a path with the home directory shortened to `~`.
fn display_path(path: &Path) -> String {
let Some(home) = home_dir() else {
return path.display().to_string();
};
match path.strip_prefix(&home) {
Ok(rest) if rest.as_os_str().is_empty() => "~".to_owned(),
Ok(rest) => {
let parts: Vec<_> = rest.iter().map(|part| part.to_string_lossy()).collect();
format!("~/{}", parts.join("/"))
}
Err(_) => path.display().to_string(),
}
}

fn main() {
let current = env::current_dir().expect("the current directory is readable");
println!("{}", display_path(&current));
}

#[cfg(test)]
mod tests {
use super::*;
use shimforge::{Session, mock};
use std::ffi::OsString;
use std::sync::Barrier;
use std::thread;

#[test]
fn a_path_under_home_is_shortened() {
let mut session = Session::new();
let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option<OsString>);
var_os
.expect()
.with(|name| *name == "HOME")
.once()
.returns(Some(OsString::from("/home/ops")));

assert_eq!(
display_path(Path::new("/home/ops/projects/shimforge")),
"~/projects/shimforge"
);
session.verify();
}

#[test]
fn home_itself_is_shown_as_a_tilde() {
let mut session = Session::new();
let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option<OsString>);
var_os
.expect()
.with(|name| *name == "HOME")
.once()
.returns(Some(OsString::from("/home/ops")));

assert_eq!(display_path(Path::new("/home/ops")), "~");
session.verify();
}

#[test]
fn a_sibling_that_shares_the_prefix_is_left_alone() {
let mut session = Session::new();
let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option<OsString>);
var_os
.expect()
.with(|name| *name == "HOME")
.once()
.returns(Some(OsString::from("/home/ops")));

assert_eq!(
display_path(Path::new("/home/opsx/notes")),
"/home/opsx/notes"
);
session.verify();
}

#[test]
fn an_unset_or_empty_home_leaves_paths_alone() {
let mut session = Session::new();
let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option<OsString>);
var_os.expect().once().returns(None);
var_os.expect().once().returns(Some(OsString::new()));

assert_eq!(display_path(Path::new("/home/ops/logs")), "/home/ops/logs");
assert_eq!(display_path(Path::new("/home/ops/logs")), "/home/ops/logs");
session.verify();
}

#[test]
fn parallel_threads_each_get_their_own_home() {
let start = Barrier::new(3);
thread::scope(|scope| {
for (home, shown) in [
("/home/ci", "~/logs"),
("/home", "~/ci/logs"),
("/srv", "/home/ci/logs"),
] {
let start = &start;
scope.spawn(move || {
let mut session = Session::new();
let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option<OsString>);
var_os
.expect()
.with(|name| *name == "HOME")
.times(100)
.returns(Some(OsString::from(home)));
// Start reading together so the three threads really overlap.
start.wait();
for _ in 0..100 {
assert_eq!(display_path(Path::new("/home/ci/logs")), shown);
}
session.verify();
});
}
});
}

#[test]
fn other_threads_still_see_the_real_home() {
let mut session = Session::new();
let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option<OsString>);
var_os
.expect()
.with(|name| *name == "HOME")
.returns(Some(OsString::from("/home/ops")));
assert_eq!(display_path(Path::new("/home/ops")), "~");

// Nothing was written to the process environment.
let real = thread::spawn(|| env::var_os("HOME")).join().unwrap();
assert_ne!(real, Some(OsString::from("/home/ops")));
}
}
2 changes: 1 addition & 1 deletion scripts/verify.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ try {
New-Item -ItemType Directory -Path coverage/windows -Force | Out-Null
Invoke-Cargo -CargoArguments @(
'llvm-cov', '--workspace', '--all-targets', '--locked',
'--ignore-filename-regex', '(^|[/\\])tests([/\\]|\.rs$)',
'--ignore-filename-regex', '(^|[/\\])(tests|examples)([/\\]|\.rs$)',
'--fail-under-lines', '100', '--fail-under-functions', '100',
'--lcov', '--output-path', 'coverage/windows/lcov.info',
'--', '--test-threads=1'
Expand Down
2 changes: 1 addition & 1 deletion scripts/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ case "$(uname -s)" in
esac
mkdir -p "coverage/$platform"
cargo llvm-cov --workspace --all-targets --locked \
--ignore-filename-regex '(^|[/\\])tests([/\\]|\.rs$)' \
--ignore-filename-regex '(^|[/\\])(tests|examples)([/\\]|\.rs$)' \
--fail-under-lines 100 --fail-under-functions 100 \
--lcov --output-path "coverage/$platform/lcov.info" \
-- --test-threads=1
39 changes: 39 additions & 0 deletions tests/examples.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Examples double as test files. `cargo test` runs an example's tests only when
//! `Cargo.toml` declares it with `test = true`, so a new example without that
//! entry would silently drop out of CI.

use std::collections::BTreeSet;
use std::fs;
use std::path::Path;

#[test]
fn every_example_runs_its_tests_under_cargo_test() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let manifest = fs::read_to_string(root.join("Cargo.toml")).unwrap();
let mut tested = BTreeSet::new();
let mut in_example = false;
let mut name = None;
for line in manifest.lines().map(str::trim) {
if line.starts_with('[') {
in_example = line == "[[example]]";
name = None;
} else if in_example {
if let Some(value) = line.strip_prefix("name = ") {
name = Some(value.trim_matches('"').to_owned());
} else if line == "test = true" {
tested.extend(name.clone());
}
}
}

let examples: BTreeSet<String> = fs::read_dir(root.join("examples"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|extension| extension == "rs"))
.map(|path| path.file_stem().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
examples, tested,
"declare every example in Cargo.toml as [[example]] with name first and test = true"
);
}
Loading