๐Ÿฆ€ Functional Rust

750: Snapshot Testing: Expect Files Pattern

Difficulty: 2 Level: Intermediate Save expected output to a file on first run; compare on every subsequent run โ€” catches output regressions automatically.

The Problem This Solves

Some functions produce complex, multi-line output that's hard to `assert_eq!` by hand: formatted reports, JSON, serialized structures, rendered templates. Writing all those expected strings inline in test code is tedious and brittle โ€” they break on cosmetic changes and require manual updates. Snapshot testing flips the workflow: run the test once to capture the expected output, then every future run compares against that snapshot. If the output changes, the test fails and shows a diff. This is the pattern behind popular testing tools like Jest's `.toMatchSnapshot()`, Insta (a Rust crate), and `expect_test` (used in rust-analyzer). It's especially valuable for refactoring: after verifying that your refactored code produces the same output as before, you have strong evidence of behavioral equivalence. The key workflow is: first run creates `tests/snapshots/name.snap`; subsequent runs compare against it; `UPDATE_SNAPSHOTS=1 cargo test` updates snapshots when intentional changes are made.

The Intuition

The snapshot infrastructure is just three operations: create (write actual output to a `.snap` file if it doesn't exist), compare (read the `.snap` file and diff against current output), and update (overwrite the `.snap` file when output changes intentionally). The diff is shown on failure so you can see exactly what changed. Line ending normalization (`\r\n` โ†’ `\n`) prevents false failures on Windows/Linux cross-platform runs.

How It Works in Rust

const SNAPSHOT_DIR: &str = "tests/snapshots";

fn should_update() -> bool {
 std::env::var("UPDATE_SNAPSHOTS").map(|v| v == "1").unwrap_or(false)
}

pub fn assert_snapshot(name: &str, actual: &str) {
 let path = Path::new(SNAPSHOT_DIR).join(format!("{}.snap", name));

 if !path.exists() || should_update() {
     // First run: create/update the snapshot
     fs::create_dir_all(SNAPSHOT_DIR).expect("create snapshot dir");
     fs::write(&path, actual).expect("write snapshot");
     eprintln!("[snapshot:{}] {}", name,
         if should_update() { "Updated" } else { "Created" });
     return;
 }

 let expected = fs::read_to_string(&path).expect("read snapshot");
 // Normalize line endings for cross-platform stability
 let actual_norm   = actual.replace("\r\n", "\n");
 let expected_norm = expected.replace("\r\n", "\n");

 if actual_norm != expected_norm {
     panic!("Snapshot '{}' mismatch!\nTo update: UPDATE_SNAPSHOTS=1 cargo test\n\nDiff:\n{}",
            name, compute_diff(&expected_norm, &actual_norm));
 }
}

#[test]
fn snapshot_sales_report() {
 let data = &[("Apples", 42u32), ("Bananas", 17), ("Cherries", 99)];
 assert_snapshot("sales_report", &render_report(data));
 // First run: creates tests/snapshots/sales_report.snap
 // Subsequent runs: compares against it
}
Commit the `.snap` files to version control โ€” they're the ground truth. Code review shows diffs in snapshot files alongside code diffs, making output changes visible and reviewable.

What This Unlocks

Key Differences

ConceptOCamlRust
Snapshot infrastructure`ppx_expect` (Jane Street)Hand-rolled (this example) or `insta` crate
Update mechanism`EXPECT_TEST_UPDATE=1` / review workflow`UPDATE_SNAPSHOTS=1 cargo test` (env var pattern)
File I/O in tests`open_in` / `read_line``fs::read_to_string` / `fs::write` โ€” stdlib
Cross-platform line endingsUsually not an issueNormalize `\r\n` โ†’ `\n` for portability
/// 750: Snapshot Testing โ€” expect files pattern (std-only)

use std::path::Path;
use std::fs;
use std::env;

// โ”€โ”€ Functions under test (complex output) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

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
}

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 infrastructure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// Directory where snapshots are stored
const SNAPSHOT_DIR: &str = "tests/snapshots";

/// Update snapshots when this env var is set: `UPDATE_SNAPSHOTS=1 cargo test`
fn should_update() -> bool {
    env::var("UPDATE_SNAPSHOTS").map(|v| v == "1").unwrap_or(false)
}

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");
        eprintln!("[snapshot:{}] {}", name,
            if should_update() { "Updated" } else { "Created" });
        return;
    }

    let expected = fs::read_to_string(&path)
        .expect("could not read snapshot file");

    // Normalize line endings
    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
        );
    }
}

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 {
        match (exp_lines.get(i), act_lines.get(i)) {
            (Some(e), Some(a)) if e == a => diff.push_str(&format!("  {}\n", e)),
            (Some(e), Some(a)) => {
                diff.push_str(&format!("- {}\n", e));
                diff.push_str(&format!("+ {}\n", a));
            }
            (Some(e), None) => diff.push_str(&format!("- {}\n", e)),
            (None, Some(a)) => diff.push_str(&format!("+ {}\n", a)),
            (None, None)    => break,
        }
    }
    diff
}

fn main() {
    let data = &[("Apples", 42u32), ("Bananas", 17), ("Cherries", 99)];
    let report = render_report(data);
    println!("{}", report);

    let json = render_json_like(&["alpha", "beta", "gamma"], &[1, 2, 3]);
    println!("{}", json);
}

#[cfg(test)]
mod tests {
    use super::*;

    // Note: These snapshot tests create files in tests/snapshots/.
    // First run creates the snapshots; subsequent runs verify them.

    #[test]
    fn snapshot_sales_report() {
        let data = &[("Apples", 42u32), ("Bananas", 17), ("Cherries", 99)];
        let report = render_report(data);
        assert_snapshot("sales_report", &report);
    }

    #[test]
    fn snapshot_json_like() {
        let json = render_json_like(&["x", "y"], &[100, 200]);
        assert_snapshot("json_like", &json);
    }

    // Unit tests that don't use snapshots
    #[test]
    fn report_contains_header() {
        let r = render_report(&[("Item", 1)]);
        assert!(r.contains("Sales Report"), "missing header: {}", r);
    }

    #[test]
    fn report_total_items_count() {
        let data = &[("A", 1u32), ("B", 2), ("C", 3)];
        let r = render_report(data);
        assert!(r.contains("Total items: 3"), "wrong count: {}", r);
    }
}
(* 750: Snapshot Testing โ€” OCaml manual expect-file pattern *)

let read_file path =
  try
    let ic = open_in path in
    let n = in_channel_length ic in
    let s = Bytes.create n in
    really_input ic s 0 n;
    close_in ic;
    Some (Bytes.to_string s)
  with Sys_error _ -> None

let write_file path content =
  let oc = open_out path in
  output_string oc content;
  close_out oc

(* The function we're snapshot-testing *)
let render_report data =
  let lines = List.mapi (fun i (k, v) ->
    Printf.sprintf "%3d. %-20s %d" (i+1) k v
  ) data in
  "=== Sales Report ===\n"
  ^ String.concat "\n" lines
  ^ "\n==================\n"
  ^ Printf.sprintf "Total: %d items\n" (List.length data)

(* Snapshot assertion *)
let assert_snapshot name actual =
  let path = Printf.sprintf "tests/snapshots/%s.expected" name in
  match read_file path with
  | None ->
    (* First run: create snapshot *)
    (try Unix.mkdir "tests" 0o755 with Unix.Unix_error _ -> ());
    (try Unix.mkdir "tests/snapshots" 0o755 with Unix.Unix_error _ -> ());
    write_file path actual;
    Printf.printf "[snapshot:%s] Created snapshot\n" name
  | Some expected ->
    if actual = expected
    then Printf.printf "[snapshot:%s] OK\n" name
    else begin
      Printf.printf "[snapshot:%s] MISMATCH!\n" name;
      Printf.printf "Expected:\n%s\nActual:\n%s\n" expected actual;
      failwith "snapshot mismatch"
    end

let () =
  let data = [("Apples", 42); ("Bananas", 17); ("Cherries", 99)] in
  let report = render_report data in
  assert_snapshot "sales_report" report;
  Printf.printf "Snapshot tests done!\n"