750-snapshot-testing — Snapshot Testing
Tutorial Video
Text description (accessibility)
This video demonstrates the "750-snapshot-testing — Snapshot Testing" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. Complex output — formatted reports, rendered templates, generated code, pretty-printed data structures — is hard to test with `assert_eq!` because the expected string is long and fragile. Key difference from OCaml: 1. **Storage**: Rust's `insta` stores snapshots in separate `.snap` files; OCaml's `expect_test` stores them inline in source code.
Tutorial
The Problem
Complex output — formatted reports, rendered templates, generated code, pretty-printed data structures — is hard to test with assert_eq! because the expected string is long and fragile. Snapshot testing stores the first run's output as a "golden file" and asserts subsequent runs produce identical output. When output legitimately changes, run with UPDATE_SNAPSHOTS=1 to accept the new output. Used in insta (Rust), jest (JavaScript), and expect_test (OCaml/Reason).
🎯 Learning Outcomes
.snap file and compares against actual outputUPDATE_SNAPSHOTS=1 environment variable to regenerate snapshot filesrender_report and render_json_like as examples of complex output worth snapshottingCode Example
pub fn assert_snapshot(name: &str, actual: &str) {
let path = snapshot_path(name);
if !path.exists() || should_update() {
fs::write(&path, actual)?;
return;
}
let expected = fs::read_to_string(&path)?;
if actual != expected {
panic!("Snapshot mismatch!\n{}", compute_diff(&expected, &actual));
}
}Key Differences
insta stores snapshots in separate .snap files; OCaml's expect_test stores them inline in source code.cargo insta review for interactive snapshot review; OCaml uses dune promote to accept all changes at once.expect_test captures all print_* output within a test block.insta can snapshot any Debug-printable value automatically; OCaml's expect_test requires explicit Sexp.to_string or printf calls.OCaml Approach
OCaml's expect_test (Jane Street) embeds expected output directly in source comments: [%expect {| output here |}]. Running dune runtest compares actual vs expected; dune promote accepts changes. The mdx tool serves a similar purpose for documentation examples. Unlike file-based snapshots, expect_test keeps expected output adjacent to the test code, making diffs easier to review.
Full Source
#![allow(clippy::all)]
//! # Snapshot Testing
//!
//! Expect files pattern for testing complex output (std-only).
use std::env;
use std::fs;
use std::path::Path;
/// Render a sales report
pub fn render_report(data: &[(&str, u32)]) -> String {
let mut out = String::new();
out.push_str("=== Sales Report ===\n");
for (i, (name, qty)) in data.iter().enumerate() {
out.push_str(&format!("{:3}. {:<20} {}\n", i + 1, name, qty));
}
out.push_str("====================\n");
let total: u32 = data.iter().map(|(_, q)| q).sum();
out.push_str(&format!("Total items: {}\n", data.len()));
out.push_str(&format!("Total qty: {}\n", total));
out
}
/// Render a JSON-like structure
pub fn render_json_like(keys: &[&str], values: &[i64]) -> String {
let pairs: Vec<String> = keys
.iter()
.zip(values.iter())
.map(|(k, v)| format!(" \"{}\": {}", k, v))
.collect();
format!("{{\n{}\n}}", pairs.join(",\n"))
}
/// Snapshot directory
const SNAPSHOT_DIR: &str = "tests/snapshots";
/// Check if we should update snapshots
pub fn should_update() -> bool {
env::var("UPDATE_SNAPSHOTS")
.map(|v| v == "1")
.unwrap_or(false)
}
/// Get the path to a snapshot file
pub fn snapshot_path(name: &str) -> std::path::PathBuf {
Path::new(SNAPSHOT_DIR).join(format!("{}.snap", name))
}
/// Assert that `actual` matches the stored snapshot.
///
/// Creates the snapshot on first run; fails on mismatch unless UPDATE_SNAPSHOTS=1.
pub fn assert_snapshot(name: &str, actual: &str) {
let path = snapshot_path(name);
if !path.exists() || should_update() {
fs::create_dir_all(SNAPSHOT_DIR).expect("could not create snapshot dir");
fs::write(&path, actual).expect("could not write snapshot");
return;
}
let expected = fs::read_to_string(&path).expect("could not read snapshot file");
let actual_norm = actual.replace("\r\n", "\n");
let expected_norm = expected.replace("\r\n", "\n");
if actual_norm != expected_norm {
let diff = compute_diff(&expected_norm, &actual_norm);
panic!(
"Snapshot '{}' mismatch!\n\
To update: UPDATE_SNAPSHOTS=1 cargo test\n\n\
Diff:\n{}",
name, diff
);
}
}
/// Compute a simple line-by-line diff
pub fn compute_diff(expected: &str, actual: &str) -> String {
let exp_lines: Vec<&str> = expected.lines().collect();
let act_lines: Vec<&str> = actual.lines().collect();
let mut diff = String::new();
let max = exp_lines.len().max(act_lines.len());
for i in 0..max {
let e = exp_lines.get(i).unwrap_or(&"");
let a = act_lines.get(i).unwrap_or(&"");
if e != a {
diff.push_str(&format!("- {}\n+ {}\n", e, a));
}
}
diff
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_report_empty() {
let report = render_report(&[]);
assert!(report.contains("=== Sales Report ==="));
assert!(report.contains("Total items: 0"));
}
#[test]
fn test_render_report_with_data() {
let data = [("Widget", 10), ("Gadget", 20)];
let report = render_report(&data);
assert!(report.contains("Widget"));
assert!(report.contains("Gadget"));
assert!(report.contains("Total qty: 30"));
}
#[test]
fn test_render_json_like() {
let output = render_json_like(&["a", "b"], &[1, 2]);
assert!(output.contains("\"a\": 1"));
assert!(output.contains("\"b\": 2"));
}
#[test]
fn test_compute_diff_identical() {
let diff = compute_diff("hello\nworld", "hello\nworld");
assert!(diff.is_empty());
}
#[test]
fn test_compute_diff_different() {
let diff = compute_diff("hello", "world");
assert!(diff.contains("- hello"));
assert!(diff.contains("+ world"));
}
#[test]
fn test_snapshot_path() {
let path = snapshot_path("my_test");
assert!(path.ends_with("my_test.snap"));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_report_empty() {
let report = render_report(&[]);
assert!(report.contains("=== Sales Report ==="));
assert!(report.contains("Total items: 0"));
}
#[test]
fn test_render_report_with_data() {
let data = [("Widget", 10), ("Gadget", 20)];
let report = render_report(&data);
assert!(report.contains("Widget"));
assert!(report.contains("Gadget"));
assert!(report.contains("Total qty: 30"));
}
#[test]
fn test_render_json_like() {
let output = render_json_like(&["a", "b"], &[1, 2]);
assert!(output.contains("\"a\": 1"));
assert!(output.contains("\"b\": 2"));
}
#[test]
fn test_compute_diff_identical() {
let diff = compute_diff("hello\nworld", "hello\nworld");
assert!(diff.is_empty());
}
#[test]
fn test_compute_diff_different() {
let diff = compute_diff("hello", "world");
assert!(diff.contains("- hello"));
assert!(diff.contains("+ world"));
}
#[test]
fn test_snapshot_path() {
let path = snapshot_path("my_test");
assert!(path.ends_with("my_test.snap"));
}
}
Deep Comparison
OCaml vs Rust: Snapshot Testing
Basic Snapshot Pattern
Rust (std-only)
pub fn assert_snapshot(name: &str, actual: &str) {
let path = snapshot_path(name);
if !path.exists() || should_update() {
fs::write(&path, actual)?;
return;
}
let expected = fs::read_to_string(&path)?;
if actual != expected {
panic!("Snapshot mismatch!\n{}", compute_diff(&expected, &actual));
}
}
OCaml (expect_test)
let%expect_test "render report" =
let report = render_report [("Widget", 10); ("Gadget", 20)] in
print_string report;
[%expect {|
=== Sales Report ===
1. Widget 10
2. Gadget 20
====================
Total items: 2
Total qty: 30
|}]
Updating Snapshots
Rust
UPDATE_SNAPSHOTS=1 cargo test
OCaml
dune runtest --auto-promote
Key Differences
| Aspect | OCaml | Rust |
|---|---|---|
| Library | expect_test (inline) | insta, or std-only |
| Storage | Inline in source | Separate .snap files |
| Update mode | --auto-promote | UPDATE_SNAPSHOTS=1 |
| Inline vs external | Inline in test | External files |
| Diff output | Built-in | Custom or library |
When to Use Snapshots
Exercises
render_html_table function that renders &[(&str, u32)] as an HTML table, and create a snapshot test for it that is stored in tests/snapshots/html_table.snap.diff_snapshots function that shows a colored line-by-line diff when a snapshot fails, making it easier to review what changed.