diff --git a/Cargo.toml b/Cargo.toml index a4af910..8dfc894 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] @@ -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 diff --git a/examples/clock.rs b/examples/clock.rs new file mode 100644 index 0000000..e790fa2 --- /dev/null +++ b/examples/clock.rs @@ -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 = (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:?}"); + } +} diff --git a/examples/environment.rs b/examples/environment.rs new file mode 100644 index 0000000..f976bf1 --- /dev/null +++ b/examples/environment.rs @@ -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 { + 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(¤t)); +} + +#[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); + 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); + 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); + 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); + 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); + 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); + 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"))); + } +} diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 index 17cb1e6..83b7b78 100644 --- a/scripts/verify.ps1 +++ b/scripts/verify.ps1 @@ -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' diff --git a/scripts/verify.sh b/scripts/verify.sh index 6d1821a..94aa089 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -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 diff --git a/tests/examples.rs b/tests/examples.rs new file mode 100644 index 0000000..4e21245 --- /dev/null +++ b/tests/examples.rs @@ -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 = 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" + ); +}