Mock any Rust function. Change none of your code.
Replace std, third-party and your own functions while a test runs.
use std::fs;
pub fn claim_slot() -> Result<(), String> {
fs::create_dir_all("/var/run/dispatcher")
.map_err(|error| format!("cannot claim a slot: {error}"))
}use shimforge::{Session, mock};
use std::io;
#[test]
fn a_denied_slot_is_reported() {
let mut session = Session::new();
let mkdir = mock!(session, fs::create_dir_all::<&str>, fn(&str) -> io::Result<()>);
mkdir
.expect()
.with(|path| *path == "/var/run/dispatcher")
.once()
.returning(|_| Err(io::ErrorKind::PermissionDenied.into()));
let error = claim_slot().unwrap_err();
assert!(error.starts_with("cannot claim a slot"));
}Why shimforge
Common reasons Rust code is hard to unit test.
std functions are not traits
Trait-based mock crates cannot mock
std::fs,std::envorSystemTime::now.Test-only traits
Production code gets a trait, a struct and an extra parameter that exist only for tests.
Untested error paths
Disk full, permission denied and HTTP 503 are hard to trigger in a test.
Shared environment variables
env::set_varis unsafe in edition 2024 and affects every test running in parallel.Third-party clients
HTTP clients and SDKs are concrete types, so tests need a mock server or a wrapper.
Time
Expiry and backoff logic is tested with real sleeps.
No test-only traits
Trait-based mocking makes you rewrite production code so a test can pass in a fake. With shimforge, the code stays as it is.
use std::{fs, io};
pub trait FileSystem {
fn create_dir_all(&self, path: &str) -> io::Result<()>;
}
pub struct RealFileSystem;
impl FileSystem for RealFileSystem {
fn create_dir_all(&self, path: &str) -> io::Result<()> {
fs::create_dir_all(path)
}
}
pub fn claim_slot(files: &impl FileSystem) -> Result<(), String> {
files
.create_dir_all("/var/run/dispatcher")
.map_err(|error| format!("cannot claim a slot: {error}"))
}
// ...and every caller of claim_slot now passes &RealFileSystem.use std::fs;
pub fn claim_slot() -> Result<(), String> {
fs::create_dir_all("/var/run/dispatcher")
.map_err(|error| format!("cannot claim a slot: {error}"))
}Examples
Production code, and the test that mocks its dependency.
use std::{fs, io, path::Path};
#[derive(Debug)]
pub enum ExportError {
DiskFull,
Io(io::Error),
}
pub fn save_report(path: &Path, csv: &str) -> Result<(), ExportError> {
fs::write(path, csv).map_err(|error| match error.kind() {
io::ErrorKind::StorageFull => ExportError::DiskFull,
_ => ExportError::Io(error),
})
}use shimforge::{Session, mock};
#[test]
fn a_full_disk_is_reported() {
let mut session = Session::new();
let write = mock!(session, fs::write::<&Path, &str>, fn(&Path, &str) -> io::Result<()>);
write
.expect()
.with(|path, _| *path == Path::new("reports/q3.csv"))
.once()
.returning(|_, _| Err(io::ErrorKind::StorageFull.into()));
let result = save_report(Path::new("reports/q3.csv"), "region,total\n");
assert!(matches!(result, Err(ExportError::DiskFull)));
}use std::env;
pub fn database_url() -> String {
env::var("DATABASE_URL").unwrap_or_else(|_| "postgres://localhost/dev".to_owned())
}use shimforge::{Session, mock};
use std::env::VarError;
#[test]
fn uses_the_configured_url() {
let mut session = Session::new(); // this thread only
let var = mock!(session, env::var::<&str>, fn(&str) -> Result<String, VarError>);
var.expect()
.with(|key| *key == "DATABASE_URL")
.returning(|_| Ok("postgres://db.internal/app".to_owned()));
assert_eq!(database_url(), "postgres://db.internal/app");
}
#[test]
fn falls_back_when_unset() {
let mut session = Session::new(); // runs in parallel with the test above
let var = mock!(session, env::var::<&str>, fn(&str) -> Result<String, VarError>);
var.expect().returning(|_| Err(VarError::NotPresent));
assert_eq!(database_url(), "postgres://localhost/dev");
}use std::time::{Duration, SystemTime};
pub struct Token {
pub issued_at: SystemTime,
pub ttl: Duration,
}
impl Token {
pub fn is_expired(&self) -> bool {
SystemTime::now() >= self.issued_at + self.ttl
}
}use shimforge::{Session, mock};
#[test]
fn a_token_expires_after_one_hour() {
let issued_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let token = Token { issued_at, ttl: Duration::from_secs(3600) };
let mut session = Session::new();
let now = mock!(session, SystemTime::now, fn() -> SystemTime);
now.expect().once().returns(issued_at + Duration::from_secs(3599));
now.expect().once().returns(issued_at + Duration::from_secs(3600));
assert!(!token.is_expired());
assert!(token.is_expired()); // one second later, no sleep
}use std::io;
use std::net::{SocketAddr, TcpStream};
use std::thread;
use std::time::Duration;
pub fn connect_with_retry(address: SocketAddr, attempts: u32) -> io::Result<TcpStream> {
let mut delay = Duration::from_millis(100);
let mut attempt = 1;
loop {
match TcpStream::connect_timeout(&address, Duration::from_secs(1)) {
Ok(stream) => return Ok(stream),
Err(error) if attempt >= attempts => return Err(error),
Err(_) => {
thread::sleep(delay);
delay *= 2;
attempt += 1;
}
}
}
}use shimforge::{Session, mock};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
#[test]
fn backs_off_until_the_database_accepts() {
// The attempt that succeeds needs a real stream, so open one first.
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let mut session = Session::new();
let connect = mock!(
session,
TcpStream::connect_timeout,
fn(&SocketAddr, Duration) -> io::Result<TcpStream>
);
connect
.expect()
.times(2)
.returning(|_, _| Err(io::ErrorKind::ConnectionRefused.into()));
connect.expect().once().return_once(Ok(stream));
let delays = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&delays);
let sleep = mock!(session, thread::sleep, fn(Duration));
sleep.expect().returning(move |delay| recorded.lock().unwrap().push(delay));
let database = SocketAddr::from(([10, 0, 0, 5], 5432));
assert!(connect_with_retry(database, 5).is_ok());
assert_eq!(*delays.lock().unwrap(), [Duration::from_millis(100), Duration::from_millis(200)]);
}
#[test]
fn gives_up_after_the_last_attempt() {
let mut session = Session::new();
let connect = mock!(
session,
TcpStream::connect_timeout,
fn(&SocketAddr, Duration) -> io::Result<TcpStream>
);
connect
.expect()
.times(3)
.returning(|_, _| Err(io::ErrorKind::TimedOut.into()));
let sleep = mock!(session, thread::sleep, fn(Duration));
sleep.expect().times(2).returning(|_| {});
let database = SocketAddr::from(([10, 0, 0, 5], 5432));
let error = connect_with_retry(database, 3).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::TimedOut);
}use reqwest::Client;
pub async fn temperature(client: &Client, city: &str) -> Result<f64, String> {
let body = client
.get(format!("https://api.weather.example/v1/now?city={city}"))
.send()
.await
.and_then(|response| response.error_for_status())
.map_err(|error| format!("weather service failed: {error}"))?
.text()
.await
.map_err(|error| error.to_string())?;
body.trim().parse().map_err(|_| format!("unexpected body: {body}"))
}use shimforge::Session;
#[tokio::test]
async fn parses_the_reading() {
let client = Client::builder().no_proxy().build().unwrap();
let mut session = Session::new();
let send = session.mock_async(client.get("http://unused.invalid").send());
send.expect()
.once()
.returning(|| Ok(http::Response::new("21.5").into()));
assert_eq!(temperature(&client, "Oslo").await, Ok(21.5));
}
#[tokio::test]
async fn surfaces_an_outage() {
let client = Client::builder().no_proxy().build().unwrap();
let mut session = Session::new();
let send = session.mock_async(client.get("http://unused.invalid").send());
send.expect().once().returning(|| {
Ok(http::Response::builder().status(503).body("").unwrap().into())
});
let error = temperature(&client, "Oslo").await.unwrap_err();
assert!(error.contains("503"), "{error}");
}use std::process::Command;
pub fn current_commit() -> Result<String, String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.map_err(|error| format!("cannot run git: {error}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}use shimforge::{Session, mock};
use std::io;
use std::process::{ExitStatus, Output};
#[test]
fn reads_the_short_hash() {
let mut session = Session::new();
let output = mock!(session, Command::output, fn(&mut Command) -> io::Result<Output>);
output
.expect()
.with(|command| command.get_program() == "git")
.once()
.returning(|_| {
Ok(Output { status: exit_success(), stdout: b"4f2a9c1\n".to_vec(), stderr: Vec::new() })
});
assert_eq!(current_commit(), Ok("4f2a9c1".to_owned()));
}
#[test]
fn reports_a_missing_git() {
let mut session = Session::new();
let output = mock!(session, Command::output, fn(&mut Command) -> io::Result<Output>);
output.expect().once().returning(|_| Err(io::ErrorKind::NotFound.into()));
let error = current_commit().unwrap_err();
assert!(error.starts_with("cannot run git"), "{error}");
}
fn exit_success() -> ExitStatus {
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
#[cfg(windows)]
use std::os::windows::process::ExitStatusExt;
ExitStatus::from_raw(0)
}use uuid::Uuid;
#[derive(Debug, PartialEq)]
pub struct Order {
pub id: Uuid,
pub sku: String,
pub quantity: u32,
}
pub fn place_order(sku: &str, quantity: u32) -> Order {
Order { id: Uuid::new_v4(), sku: sku.to_owned(), quantity }
}use shimforge::{Session, mock};
#[test]
fn an_order_is_exactly_what_we_expect() {
let id = Uuid::from_u128(0x6f1d4c2e_9a7b_4e1f_8c3d_2b5a1e9f7c40);
let mut session = Session::new();
let new_v4 = mock!(session, Uuid::new_v4, fn() -> Uuid);
new_v4.expect().once().returns(id);
let expected = Order { id, sku: "sku-77".to_owned(), quantity: 3 };
assert_eq!(place_order("sku-77", 3), expected);
}Features
Match arguments, count calls, check order and control return values.
Match arguments
.with(|path| *path == "q3.csv")
Count calls
.once() .times(2..) .never()
Check order
.in_sequence(&order)
Fill output parameters
.returning(|buf| { .. })
Methods and generics
Cache::hit_rate render::<u8>
Async
session.mock_async(fetch(..))
FFI and OS APIs
unsafe extern "C" fn(..)
Thread-local or global
Session::new() Session::new_global()
Restored on drop
drop(session)
Setup
Add shimforge to Cargo.toml, then run your tests.
- 1
Add the dependency
[dev-dependencies] shimforge = "0.1" - 2
Run the tests
cargo test