insta: Snapshot Testing for Rust with assert_snapshot!
insta captures the output of your code as a snapshot file. On future runs, it compares actual output against the saved snapshot and fails if they differ. When output changes intentionally, you review and approve the new snapshot with cargo insta review. It's the right tool when manually writing expected values would be tedious, error-prone, or unmaintainable.
Key Takeaways
- assert_snapshot! captures any Display/Debug output as a named snapshot file
- assert_json_snapshot! and assert_yaml_snapshot! serialize structured data
- cargo insta review opens an interactive diff for approving snapshot updates
- Snapshot files live next to tests in snapshots/ — commit them to version control
- INSTA_UPDATE=always lets CI re-generate snapshots automatically
What snapshot testing solves
Some test assertions are painful to write by hand. Imagine testing a code formatter that produces 40 lines of output, an API response with 15 fields, or a compiler error message. Writing assert_eq!(output, "line 1\nline 2\n...") is tedious and fragile to maintain.
Snapshot tests solve this by capturing the output the first time and comparing against it on subsequent runs. You review the initial snapshot once, commit it, and future tests catch regressions automatically.
Installation
[dev-dependencies]
insta = { version = "1", features = ["json", "yaml"] }
# Optional: install the CLI
# cargo install cargo-instaEnable the json feature for assert_json_snapshot! and yaml for assert_yaml_snapshot!.
First snapshot
fn format_address(street: &str, city: &str, zip: &str) -> String {
format!("{}\n{}, {}", street, city, zip)
}
#[test]
fn test_address_format() {
let output = format_address("123 Main St", "Springfield", "12345");
insta::assert_snapshot!(output);
}Run this test the first time:
cargo testThe test fails with: snapshot not found. Insta creates a pending snapshot in src/snapshots/my_crate__test_address_format.snap.new.
Review and approve it:
cargo insta reviewThis opens an interactive interface showing the new snapshot. Press a to accept. The .snap.new file becomes .snap:
---
source: src/lib.rs
expression: output
---
123 Main St
Springfield, 12345Commit the .snap file. Future test runs compare against it.
Named snapshots
Give snapshots explicit names to distinguish multiple snapshots in one test:
#[test]
fn test_various_addresses() {
insta::assert_snapshot!("us_address", format_address("123 Main St", "New York", "10001"));
insta::assert_snapshot!("uk_address", format_address("10 Downing St", "London", "SW1A 2AA"));
}Named snapshots are stored as separate files: ...__us_address.snap and ...__uk_address.snap.
JSON snapshots
For structured data, assert_json_snapshot! serializes to JSON and diffs against the stored value:
use serde::Serialize;
#[derive(Serialize)]
struct ApiResponse {
users: Vec<User>,
total: usize,
page: usize,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
email: String,
}
#[test]
fn test_user_list_response() {
let response = ApiResponse {
users: vec![
User { id: 1, name: "Alice".into(), email: "alice@example.com".into() },
User { id: 2, name: "Bob".into(), email: "bob@example.com".into() },
],
total: 2,
page: 1,
};
insta::assert_json_snapshot!(response);
}The snapshot file stores formatted JSON:
{
"users": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com"
},
{
"id": 2,
"name": "Bob",
"email": "bob@example.com"
}
],
"total": 2,
"page": 1
}When a field changes, the diff in cargo insta review shows exactly what changed — much clearer than a line-by-line string comparison.
Redacting dynamic values
Timestamps, IDs, and random values change between runs and would make snapshots fail constantly. Redact them:
#[test]
fn test_created_user_response() {
let response = create_user("Carol");
insta::assert_json_snapshot!(response, {
".id" => "[id]",
".created_at" => "[timestamp]",
".updated_at" => "[timestamp]",
});
}The snapshot stores "[id]" and "[timestamp]" in those positions instead of the actual values. The test passes regardless of what the real ID or timestamp is, but still catches changes to all other fields.
Redaction supports JSONPath-like selectors:
.field_name— specific field.[*].field_name— field in all array elements.**— all values recursively
Inline snapshots
For small outputs, embed the snapshot directly in the test instead of a separate file:
#[test]
fn test_greeting() {
let msg = greet("World");
insta::assert_snapshot!(msg, @"Hello, World!");
}The @"..." syntax stores the expected value in-place. When you run cargo insta review on a changed inline snapshot, it updates the source file directly.
Inline snapshots are useful for simple values where a separate file feels like overkill.
The review workflow
# Run tests — new or changed snapshots become .snap.new files
cargo <span class="hljs-built_in">test
<span class="hljs-comment"># Review all pending snapshots
cargo insta review
<span class="hljs-comment"># In the review UI:
<span class="hljs-comment"># a — accept the new snapshot
<span class="hljs-comment"># r — reject (keep the old one)
<span class="hljs-comment"># s — skip for now
<span class="hljs-comment"># Accept all pending snapshots without interactive review
cargo insta accept
<span class="hljs-comment"># Reject all pending snapshots
cargo insta rejectAccepted snapshots replace the old .snap files. Commit both the test code and the updated snapshot files together.
CI integration
In CI, you want tests to fail if snapshots don't match — never silently accept new ones. The default behavior does this.
To regenerate snapshots in CI (e.g., for a "update snapshots" job):
INSTA_UPDATE=always cargo testThis writes new snapshots without requiring interactive review. Useful in a separate CI job that creates a PR with snapshot updates.
For a stricter setup that fails on any snapshot difference (including new ones not yet reviewed):
INSTA_UPDATE=no cargo testSnapshot file organization
By default, snapshots live in a snapshots/ directory next to the test file:
src/
lib.rs
snapshots/
my_crate__test_address_format.snap
my_crate__test_user_list_response.snapYou can configure the location in Cargo.toml:
[package.metadata.insta]
snapshot-path = "tests/snapshots"When to use insta
Snapshot testing is a good fit when:
- Output is large (formatted text, complex JSON, HTML, SQL)
- Output is derived from many inputs and hard to compute by hand
- You want to lock in behavior and be notified of any change
- The "correct" output is easier to verify visually than to specify in advance
It's less appropriate when:
- The test has a simple, obvious expected value (
assert_eq!(2 + 2, 4)) - Output includes high-cardinality dynamic data that can't be meaningfully redacted
- You're testing behavior rather than output format
Practical example: testing a code formatter
fn format_sql(query: &str) -> String {
// ... SQL formatter logic
}
#[test]
fn test_formats_select() {
let input = "SELECT id,name,email FROM users WHERE active=true ORDER BY name";
insta::assert_snapshot!("select_formatted", format_sql(input));
}
#[test]
fn test_formats_join() {
let input = "SELECT u.name,o.total FROM users u JOIN orders o ON u.id=o.user_id";
insta::assert_snapshot!("join_formatted", format_sql(input));
}When you improve the formatter, cargo test shows exactly what changed in each snapshot. You review the diffs, accept the improvements, and commit. The entire change history is visible in version control.
Summary
insta takes snapshot testing from a JavaScript ecosystem concept and makes it idiomatic in Rust. The assert_snapshot! macros capture any serializable output, cargo insta review makes approvals interactive and clear, and redaction handles dynamic values cleanly. For tests where specifying expected output by hand is impractical, insta replaces fragile string comparisons with readable, reviewable snapshot files.