xerv_core/testing/
snapshot.rs

1//! Snapshot testing utilities.
2//!
3//! Snapshot testing captures expected output and compares against it in future runs.
4//! This is useful for testing complex outputs like serialized data structures.
5
6use serde::{Serialize, de::DeserializeOwned};
7use std::env;
8use std::fs;
9use std::path::PathBuf;
10
11/// Get the snapshot directory for the current test.
12fn snapshot_dir() -> PathBuf {
13    // Default to tests/snapshots in the current crate
14    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
15        .join("tests")
16        .join("snapshots")
17}
18
19/// Get the path for a named snapshot.
20fn snapshot_path(name: &str) -> PathBuf {
21    snapshot_dir().join(format!("{}.snap", name))
22}
23
24/// Check if snapshots should be updated.
25fn should_update() -> bool {
26    env::var("UPDATE_SNAPSHOTS")
27        .map(|v| v == "1" || v == "true")
28        .unwrap_or(false)
29}
30
31/// Assert that a value matches a snapshot.
32///
33/// If the snapshot doesn't exist, it will be created.
34/// If `UPDATE_SNAPSHOTS=1` is set, the snapshot will be updated.
35///
36/// # Example
37///
38/// ```ignore
39/// use xerv_core::testing::assert_snapshot;
40/// use serde_json::json;
41///
42/// let output = json!({
43///     "status": "success",
44///     "data": [1, 2, 3]
45/// });
46///
47/// // First run: creates tests/snapshots/my_output.snap
48/// // Subsequent runs: compares against the snapshot
49/// assert_snapshot("my_output", &output);
50/// ```
51///
52/// To update snapshots, run tests with:
53/// ```bash
54/// UPDATE_SNAPSHOTS=1 cargo test
55/// ```
56pub fn assert_snapshot<T>(name: &str, value: &T)
57where
58    T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug,
59{
60    let path = snapshot_path(name);
61    let actual_json = serde_json::to_string_pretty(value).expect("Failed to serialize value");
62
63    if should_update() || !path.exists() {
64        // Create directory if needed
65        if let Some(parent) = path.parent() {
66            fs::create_dir_all(parent).expect("Failed to create snapshot directory");
67        }
68
69        // Write the snapshot
70        fs::write(&path, &actual_json).expect("Failed to write snapshot");
71
72        if !should_update() {
73            println!("Created new snapshot: {}", path.display());
74        } else {
75            println!("Updated snapshot: {}", path.display());
76        }
77        return;
78    }
79
80    // Read and compare
81    let expected_json = fs::read_to_string(&path).expect("Failed to read snapshot");
82
83    if actual_json != expected_json {
84        let expected: T =
85            serde_json::from_str(&expected_json).expect("Failed to deserialize snapshot");
86
87        if value == &expected {
88            // Values are equal but JSON differs (e.g., whitespace)
89            // This is fine, don't fail
90            return;
91        }
92
93        // Generate diff for error message
94        let diff = generate_diff(&expected_json, &actual_json);
95
96        panic!(
97            "Snapshot mismatch for '{}'!\n\n\
98            Run with UPDATE_SNAPSHOTS=1 to update.\n\n\
99            Diff:\n{}\n\n\
100            Snapshot file: {}",
101            name,
102            diff,
103            path.display()
104        );
105    }
106}
107
108/// Generate a simple diff between two strings.
109fn generate_diff(expected: &str, actual: &str) -> String {
110    let expected_lines: Vec<&str> = expected.lines().collect();
111    let actual_lines: Vec<&str> = actual.lines().collect();
112
113    let mut diff = String::new();
114    let max_lines = expected_lines.len().max(actual_lines.len());
115
116    for i in 0..max_lines {
117        let exp = expected_lines.get(i).copied().unwrap_or("");
118        let act = actual_lines.get(i).copied().unwrap_or("");
119
120        if exp == act {
121            diff.push_str(&format!("  {}\n", exp));
122        } else {
123            if !exp.is_empty() {
124                diff.push_str(&format!("- {}\n", exp));
125            }
126            if !act.is_empty() {
127                diff.push_str(&format!("+ {}\n", act));
128            }
129        }
130    }
131
132    diff
133}
134
135/// Assert that a JSON value matches a snapshot.
136///
137/// Convenience wrapper for JSON values.
138pub fn assert_json_snapshot(name: &str, value: &serde_json::Value) {
139    assert_snapshot(name, value);
140}
141
142/// Assert that a string matches a snapshot.
143///
144/// For simple string comparisons without JSON parsing.
145pub fn assert_string_snapshot(name: &str, value: &str) {
146    let path = snapshot_path(name);
147
148    if should_update() || !path.exists() {
149        if let Some(parent) = path.parent() {
150            fs::create_dir_all(parent).expect("Failed to create snapshot directory");
151        }
152        fs::write(&path, value).expect("Failed to write snapshot");
153        return;
154    }
155
156    let expected = fs::read_to_string(&path).expect("Failed to read snapshot");
157
158    if value != expected {
159        let diff = generate_diff(&expected, value);
160        panic!(
161            "String snapshot mismatch for '{}'!\n\n\
162            Run with UPDATE_SNAPSHOTS=1 to update.\n\n\
163            Diff:\n{}\n\n\
164            Snapshot file: {}",
165            name,
166            diff,
167            path.display()
168        );
169    }
170}
171
172/// Inline snapshot assertion (snapshot embedded in test).
173///
174/// Unlike file-based snapshots, this embeds the expected value directly
175/// in the test source code. Useful for small, stable outputs.
176///
177/// # Example
178///
179/// ```ignore
180/// use xerv_core::testing::assert_inline_snapshot;
181///
182/// let output = compute_something();
183/// assert_inline_snapshot!(output, r#"{"result": 42}"#);
184/// ```
185#[macro_export]
186macro_rules! assert_inline_snapshot {
187    ($actual:expr, $expected:expr) => {{
188        let actual = serde_json::to_string_pretty(&$actual).expect("Failed to serialize");
189        let expected = $expected;
190        if actual != expected {
191            panic!(
192                "Inline snapshot mismatch!\n\nExpected:\n{}\n\nActual:\n{}",
193                expected, actual
194            );
195        }
196    }};
197}
198
199/// Redact sensitive or non-deterministic values in snapshots.
200///
201/// # Example
202///
203/// ```
204/// use xerv_core::testing::snapshot::redact_json;
205/// use serde_json::json;
206///
207/// let output = json!({
208///     "id": "abc123",
209///     "timestamp": 1234567890,
210///     "name": "test"
211/// });
212///
213/// let redacted = redact_json(&output, &["id", "timestamp"]);
214/// // redacted["id"] == "[REDACTED]"
215/// // redacted["timestamp"] == "[REDACTED]"
216/// // redacted["name"] == "test"
217/// ```
218pub fn redact_json(value: &serde_json::Value, keys_to_redact: &[&str]) -> serde_json::Value {
219    match value {
220        serde_json::Value::Object(map) => {
221            let mut new_map = serde_json::Map::new();
222            for (k, v) in map {
223                if keys_to_redact.contains(&k.as_str()) {
224                    new_map.insert(
225                        k.clone(),
226                        serde_json::Value::String("[REDACTED]".to_string()),
227                    );
228                } else {
229                    new_map.insert(k.clone(), redact_json(v, keys_to_redact));
230                }
231            }
232            serde_json::Value::Object(new_map)
233        }
234        serde_json::Value::Array(arr) => {
235            serde_json::Value::Array(arr.iter().map(|v| redact_json(v, keys_to_redact)).collect())
236        }
237        other => other.clone(),
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use serde_json::json;
245
246    #[test]
247    fn test_redact_json() {
248        let input = json!({
249            "public": "visible",
250            "secret": "hidden",
251            "nested": {
252                "timestamp": 12345,
253                "data": "ok"
254            },
255            "array": [
256                {"id": "a", "value": 1},
257                {"id": "b", "value": 2}
258            ]
259        });
260
261        let redacted = redact_json(&input, &["secret", "timestamp", "id"]);
262
263        assert_eq!(redacted["public"], "visible");
264        assert_eq!(redacted["secret"], "[REDACTED]");
265        assert_eq!(redacted["nested"]["timestamp"], "[REDACTED]");
266        assert_eq!(redacted["nested"]["data"], "ok");
267        assert_eq!(redacted["array"][0]["id"], "[REDACTED]");
268        assert_eq!(redacted["array"][0]["value"], 1);
269    }
270
271    #[test]
272    fn test_generate_diff() {
273        let expected = "line1\nline2\nline3";
274        let actual = "line1\nmodified\nline3";
275
276        let diff = generate_diff(expected, actual);
277        assert!(diff.contains("- line2"));
278        assert!(diff.contains("+ modified"));
279    }
280}