ExamplesBy LevelBy TopicLearning Paths
750 Fundamental

750-snapshot-testing — Snapshot Testing

Functional Programming

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

  • • Implement a snapshot assertion that reads from a .snap file and compares against actual output
  • • Use an UPDATE_SNAPSHOTS=1 environment variable to regenerate snapshot files
  • • Understand the commit workflow: generate snapshots, review the diff, commit them alongside code
  • • Recognize which types of output benefit from snapshot testing (complex, multi-line, structured)
  • • Build render_report and render_json_like as examples of complex output worth snapshotting
  • Code 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

  • Storage: Rust's insta stores snapshots in separate .snap files; OCaml's expect_test stores them inline in source code.
  • Review workflow: Rust uses cargo insta review for interactive snapshot review; OCaml uses dune promote to accept all changes at once.
  • Granularity: Rust snapshots are per-assertion; OCaml expect_test captures all print_* output within a test block.
  • Serialization: Rust's 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"));
        }
    }
    ✓ Tests Rust test suite
    #[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

    AspectOCamlRust
    Libraryexpect_test (inline)insta, or std-only
    StorageInline in sourceSeparate .snap files
    Update mode--auto-promoteUPDATE_SNAPSHOTS=1
    Inline vs externalInline in testExternal files
    Diff outputBuilt-inCustom or library

    When to Use Snapshots

  • • Complex output (reports, formatted strings)
  • • Serialized data (JSON, YAML)
  • • UI rendering output
  • • Any output where manual assertions are tedious
  • Exercises

  • Add a 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.
  • Implement a diff_snapshots function that shows a colored line-by-line diff when a snapshot fails, making it easier to review what changed.
  • Write a snapshot test for a recursive JSON structure with nested objects and arrays — verify that indentation and comma placement are stable across runs.
  • Open Source Repos