Skip to content

Commit f41fd2f

Browse files
authored
Create reliable-testing-in-rust-via-dependency-injection.md
1 parent fb6c684 commit f41fd2f

File tree

1 file changed

+181
-0
lines changed

1 file changed

+181
-0
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# 🛡️ Reliable Testing in Rust via Dependency Injection
2+
3+
Writing robust, reliable, and parallelisable tests requires an intentional approach to handling external dependencies such as environment variables, the filesystem, or the system clock. Functions that directly call `std::env::var` or `SystemTime::now()` are difficult to test because they depend on global, non-deterministic state.
4+
5+
This leads to several problems:
6+
7+
- **Flaky Tests:** A test might pass or fail depending on the environment it runs in.
8+
- **Parallel Execution Conflicts:** Tests that modify the same global environment variable (`std::env::set_var`) will interfere with each other when run with `cargo test`.
9+
- **State Corruption:** A test that panics can fail to clean up its changes to the environment, poisoning subsequent tests.
10+
11+
The solution is a classic software design pattern: **Dependency Injection (DI)**. Instead of a function reaching out to the global state, its dependencies are provided as arguments. The `mockable` crate offers a convenient set of traits (`Env`, `Clock`, etc.) to implement this pattern for common system interactions in Rust.
12+
13+
---
14+
15+
## ✨ Mocking Environment Variables
16+
17+
### 1. Add `mockable`
18+
19+
First, add the crate to development dependencies in `Cargo.toml`.
20+
21+
```toml
22+
[dev-dependencies]
23+
mockable = "0.3"
24+
```
25+
26+
### 2. The Untestable Code (Before)
27+
28+
Directly calling `std::env` makes it hard to test all logic paths.
29+
30+
```rust
31+
pub fn get_api_key() -> Option<String> {
32+
match std::env::var("API_KEY") {
33+
Ok(key) if !key.is_empty() => Some(key),
34+
_ => None,
35+
}
36+
}
37+
```
38+
39+
### 3. Refactoring for Testability (After)
40+
41+
The function is refactored to accept a generic type that implements the `mockable::Env` trait.
42+
43+
```rust
44+
use mockable::Env;
45+
46+
pub fn get_api_key(env: &impl Env) -> Option<String> {
47+
match env.var("API_KEY") {
48+
Ok(key) if !key.is_empty() => Some(key),
49+
_ => None,
50+
}
51+
}
52+
```
53+
54+
The function's core logic remains unchanged, but its dependency on the environment is now explicit and injectable.
55+
56+
### 4. Writing Isolated Unit Tests
57+
58+
Tests can use `MockEnv`, an in-memory mock, to simulate any environmental condition without touching the actual process environment.
59+
60+
```rust
61+
#[cfg(test)]
62+
mod tests {
63+
use super::*;
64+
use mockable::{MockEnv, Env};
65+
66+
#[test]
67+
fn test_get_api_key_present() {
68+
let mut env = MockEnv::new();
69+
env.set_var("API_KEY", "secret123");
70+
assert_eq!(get_api_key(&env), Some("secret123".to_string()));
71+
}
72+
73+
#[test]
74+
fn test_get_api_key_missing() {
75+
let env = MockEnv::new();
76+
assert_eq!(get_api_key(&env), None);
77+
}
78+
79+
#[test]
80+
fn test_get_api_key_present_but_empty() {
81+
let mut env = MockEnv::new();
82+
env.set_var("API_KEY", "");
83+
assert_eq!(get_api_key(&env), None);
84+
}
85+
}
86+
```
87+
88+
These tests are fast, completely isolated from each other, and will never fail due to external state.
89+
90+
### 5. Usage in Production Code
91+
92+
In production code, inject the "real" implementation, `RealEnv`, which calls the actual `std::env` functions.
93+
94+
```rust
95+
use mockable::RealEnv;
96+
97+
fn main() {
98+
let env = RealEnv::new();
99+
if let Some(api_key) = get_api_key(&env) {
100+
println!("API Key found!");
101+
} else {
102+
println!("API Key not configured.");
103+
}
104+
}
105+
```
106+
107+
---
108+
109+
## 🔩 Handling Other Non-Deterministic Dependencies
110+
111+
This dependency injection pattern also applies to other non-deterministic dependencies such as the system clock. `mockable` provides a `Clock` trait for this purpose.
112+
113+
### Untestable Code
114+
115+
```rust
116+
use std::time::{SystemTime, Duration};
117+
118+
fn is_cache_entry_stale(creation_time: SystemTime) -> bool {
119+
let timeout = Duration::from_secs(300);
120+
match SystemTime::now().duration_since(creation_time) {
121+
Ok(age) => age > timeout,
122+
Err(_) => false,
123+
}
124+
}
125+
```
126+
127+
### Testable Refactor
128+
129+
```rust
130+
use mockable::Clock;
131+
use std::time::{SystemTime, Duration};
132+
133+
fn is_cache_entry_stale(creation_time: SystemTime, clock: &impl Clock) -> bool {
134+
let timeout = Duration::from_secs(300);
135+
match clock.now().duration_since(creation_time) {
136+
Ok(age) => age > timeout,
137+
Err(_) => false,
138+
}
139+
}
140+
```
141+
142+
### Testing with `MockClock`
143+
144+
```rust
145+
#[cfg(test)]
146+
mod tests {
147+
use super::*;
148+
use mockable::{MockClock, Clock};
149+
use std::time::{Duration, SystemTime};
150+
151+
#[test]
152+
fn test_cache_is_not_stale() {
153+
let mut clock = MockClock::new();
154+
let creation_time = clock.now();
155+
clock.advance(Duration::from_secs(100));
156+
assert!(!is_cache_entry_stale(creation_time, &clock));
157+
}
158+
159+
#[test]
160+
fn test_cache_is_stale() {
161+
let mut clock = MockClock::new();
162+
let creation_time = clock.now();
163+
clock.advance(Duration::from_secs(301));
164+
assert!(is_cache_entry_stale(creation_time, &clock));
165+
}
166+
}
167+
```
168+
169+
In production, an instance of `RealClock::new()` would be used.
170+
171+
---
172+
173+
## 📌 Key Takeaways
174+
175+
- **The Problem is Non-Determinism:** Directly accessing global state like `std::env` or `SystemTime::now` makes code hard to test.
176+
- **The Solution is Dependency Injection:** Pass dependencies into functions as arguments.
177+
- **Use** `mockable` **Traits:** Abstract dependencies behind traits such as `impl Env` or `impl Clock`.
178+
- **`Mock*` for Tests:** Use `MockEnv` and `MockClock` in unit tests for isolated, deterministic control.
179+
- **`Real*` for Production:** Use `RealEnv` and `RealClock` in the application to interact with the actual system.
180+
- **`RealEnv` is NOT a Scope Guard:** `RealEnv` directly mutates the global process environment without automatic cleanup. For integration tests that require modifying the live environment, consider a crate such as `temp_env`. For unit tests, `MockEnv` is preferable.
181+

0 commit comments

Comments
 (0)