test_better_snapshot/lib.rs
1//! `test-better-snapshot`: file-backed and inline snapshot testing.
2//!
3//! A snapshot test pins a value's rendered form to a file on disk: the first
4//! run records it, later runs compare against it, and an intentional change is
5//! accepted by rerunning with `UPDATE_SNAPSHOTS=1`.
6//!
7//! This crate is the storage-and-comparison core. It is deliberately
8//! `std`-only and `TestError`-free: it knows
9//! how to find a snapshot file, read it, write it, and report *what* differed,
10//! as the structured [`SnapshotFailure`]. Turning that into a `TestError` with
11//! a rendered diff is `test-better-matchers`' job, in
12//! `check!(value).matches_snapshot("name")`.
13//!
14//! Snapshot files live at `tests/snapshots/<module-path>__<name>.snap`, with
15//! `<module-path>` taken from the calling test's `module_path!()` (so two tests
16//! in different modules can both name a snapshot `"output"` without colliding).
17//! [`assert_snapshot`] resolves that directory from the current working
18//! directory, which `cargo test` sets to the package root; [`assert_snapshot_in`]
19//! takes the directory explicitly and is what tests of this crate drive.
20//!
21//! Inline snapshots (the `inline` module: [`assert_inline_snapshot`] and
22//! friends) keep the snapshot literal in the test source instead. A mismatch
23//! under `UPDATE_SNAPSHOTS=1` records a pending patch under `target/`, which
24//! the `test-better-accept` companion binary (built with the `accept` feature)
25//! applies back to the source.
26//!
27//! [`Redactions`] (the `redact` module) stabilize non-deterministic content
28//! (UUIDs, timestamps) before either kind of snapshot is compared or stored, so
29//! the noise never reaches the snapshot.
30
31use std::error::Error;
32use std::fmt;
33use std::fs;
34use std::path::{Path, PathBuf};
35
36mod inline;
37mod redact;
38
39pub use inline::{
40 InlineLocation, InlineSnapshotFailure, assert_inline_snapshot, normalize_inline_literal,
41 parse_pending_patch, pending_patch_dir,
42};
43pub use redact::Redactions;
44
45// The accept step is the only part of the crate that needs `syn`, so it is
46// gated behind the `accept` feature along with the `test-better-accept` binary
47// (`src/bin/test-better-accept.rs`) that drives it.
48#[cfg(feature = "accept")]
49mod accept;
50
51#[cfg(feature = "accept")]
52pub use accept::{
53 AcceptError, Applied, apply_inline_patch, apply_patches_from, apply_pending_patches,
54};
55
56/// Whether a snapshot assertion compares against the stored file or rewrites it.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum SnapshotMode {
59 /// Compare `actual` against the stored snapshot. A missing file is a
60 /// failure: the snapshot has to be created deliberately, in [`Update`]
61 /// mode, not conjured by the first passing run.
62 ///
63 /// [`Update`]: SnapshotMode::Update
64 Compare,
65 /// Write `actual` to the snapshot file, creating or overwriting it. This is
66 /// how a new snapshot is recorded and how an intentional change is
67 /// accepted.
68 Update,
69}
70
71impl SnapshotMode {
72 /// [`Update`](SnapshotMode::Update) when the `UPDATE_SNAPSHOTS` environment
73 /// variable is set to a non-empty value, [`Compare`](SnapshotMode::Compare)
74 /// otherwise.
75 #[must_use]
76 pub fn from_env() -> Self {
77 match std::env::var_os("UPDATE_SNAPSHOTS") {
78 Some(value) if !value.is_empty() => SnapshotMode::Update,
79 _ => SnapshotMode::Compare,
80 }
81 }
82}
83
84/// Why a snapshot assertion did not pass.
85///
86/// This is the structured form: `test-better-matchers` turns it into a
87/// `TestError` (a [`Mismatch`](SnapshotFailure::Mismatch) becomes an
88/// expected/actual payload with a diff), but the failure is described here so
89/// the crate is usable on its own.
90#[derive(Debug)]
91pub enum SnapshotFailure {
92 /// No snapshot file exists and the mode was [`Compare`](SnapshotMode::Compare).
93 Missing {
94 /// Where the snapshot file was expected.
95 path: PathBuf,
96 },
97 /// The snapshot file exists but its contents differ from `actual`.
98 Mismatch {
99 /// The snapshot file that was compared against.
100 path: PathBuf,
101 /// The stored snapshot (the file's contents).
102 expected: String,
103 /// The value under test.
104 actual: String,
105 },
106 /// Resolving the snapshot directory, reading the file, or writing it failed.
107 Io {
108 /// The snapshot file (or directory) the operation concerned.
109 path: PathBuf,
110 /// A short description of what was being attempted, e.g.
111 /// `"reading the snapshot file"`.
112 action: &'static str,
113 /// The underlying I/O error.
114 source: std::io::Error,
115 },
116}
117
118impl fmt::Display for SnapshotFailure {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 match self {
121 SnapshotFailure::Missing { path } => write!(
122 f,
123 "no snapshot at {}; rerun with UPDATE_SNAPSHOTS=1 to create it",
124 path.display()
125 ),
126 SnapshotFailure::Mismatch { path, .. } => {
127 write!(f, "snapshot at {} does not match", path.display())
128 }
129 SnapshotFailure::Io {
130 path,
131 action,
132 source,
133 } => write!(f, "I/O error {action} ({}): {source}", path.display()),
134 }
135 }
136}
137
138impl Error for SnapshotFailure {
139 fn source(&self) -> Option<&(dyn Error + 'static)> {
140 match self {
141 SnapshotFailure::Io { source, .. } => Some(source),
142 _ => None,
143 }
144 }
145}
146
147/// The path of the snapshot file for `module_path` and `name` under `dir`.
148///
149/// The file name is `<module-path>__<name>.snap`, with both components
150/// sanitized: `::` segment separators in a module path collapse to `__`, and
151/// any other character that is not alphanumeric, `_`, or `-` becomes `_`.
152///
153/// ```
154/// use std::path::{Path, PathBuf};
155///
156/// use test_better_core::TestResult;
157/// use test_better_matchers::{eq, check};
158/// use test_better_snapshot::snapshot_path;
159///
160/// # fn main() -> TestResult {
161/// let path = snapshot_path(Path::new("tests/snapshots"), "my_crate::ui", "homepage");
162/// check!(path).satisfies(eq(PathBuf::from("tests/snapshots/my_crate__ui__homepage.snap")))?;
163/// # Ok(())
164/// # }
165/// ```
166#[must_use]
167pub fn snapshot_path(dir: &Path, module_path: &str, name: &str) -> PathBuf {
168 dir.join(format!(
169 "{}__{}.snap",
170 sanitize(module_path),
171 sanitize(name)
172 ))
173}
174
175/// Collapses `::` to `__` so module-path segments survive, then replaces any
176/// remaining character that is not safe in a file name.
177fn sanitize(raw: &str) -> String {
178 raw.replace("::", "__")
179 .chars()
180 .map(|c| {
181 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
182 c
183 } else {
184 '_'
185 }
186 })
187 .collect()
188}
189
190/// Compares `actual` against (or, in [`Update`](SnapshotMode::Update) mode,
191/// writes it to) the snapshot file for `module_path`/`name` under `dir`.
192///
193/// This is the directory-explicit core: [`assert_snapshot`] is the wrapper that
194/// derives `dir` from the current working directory. Tests of this crate use
195/// `assert_snapshot_in` against a temporary directory so they need not depend
196/// on the process's working directory or a committed fixture file.
197///
198/// In [`Compare`](SnapshotMode::Compare) mode it returns [`SnapshotFailure`] on
199/// a mismatch or a missing file. In [`Update`](SnapshotMode::Update) mode it
200/// creates the directory if needed, writes `actual`, and returns `Ok(())`.
201///
202/// # Errors
203///
204/// Returns [`SnapshotFailure`] when the snapshot does not match, does not exist
205/// (in `Compare` mode), or an I/O operation fails.
206pub fn assert_snapshot_in(
207 dir: &Path,
208 module_path: &str,
209 name: &str,
210 actual: &str,
211 mode: SnapshotMode,
212) -> Result<(), SnapshotFailure> {
213 let path = snapshot_path(dir, module_path, name);
214 match mode {
215 SnapshotMode::Update => {
216 if let Some(parent) = path.parent() {
217 fs::create_dir_all(parent).map_err(|source| SnapshotFailure::Io {
218 path: path.clone(),
219 action: "creating the snapshot directory",
220 source,
221 })?;
222 }
223 fs::write(&path, actual).map_err(|source| SnapshotFailure::Io {
224 path,
225 action: "writing the snapshot file",
226 source,
227 })
228 }
229 SnapshotMode::Compare => match fs::read_to_string(&path) {
230 Ok(expected) if expected == actual => Ok(()),
231 Ok(expected) => Err(SnapshotFailure::Mismatch {
232 path,
233 expected,
234 actual: actual.to_string(),
235 }),
236 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
237 Err(SnapshotFailure::Missing { path })
238 }
239 Err(source) => Err(SnapshotFailure::Io {
240 path,
241 action: "reading the snapshot file",
242 source,
243 }),
244 },
245 }
246}
247
248/// Compares `actual` against the snapshot for `module_path`/`name`, with the
249/// snapshot directory resolved as `tests/snapshots` under the current working
250/// directory and the mode read from `UPDATE_SNAPSHOTS`.
251///
252/// `cargo test` runs a test binary with its working directory set to the
253/// package root, so `tests/snapshots` lands in the package being tested. This
254/// is the entry point `check!(value).matches_snapshot("name")` calls; reach
255/// for [`assert_snapshot_in`] when the directory or mode must be explicit.
256///
257/// # Errors
258///
259/// Returns [`SnapshotFailure`] when the snapshot does not match, does not exist
260/// (and `UPDATE_SNAPSHOTS` is unset), or an I/O operation fails (including
261/// failing to resolve the current directory).
262pub fn assert_snapshot(module_path: &str, name: &str, actual: &str) -> Result<(), SnapshotFailure> {
263 let base = std::env::current_dir().map_err(|source| SnapshotFailure::Io {
264 path: PathBuf::from("tests/snapshots"),
265 action: "resolving the current directory",
266 source,
267 })?;
268 assert_snapshot_in(
269 &base.join("tests").join("snapshots"),
270 module_path,
271 name,
272 actual,
273 SnapshotMode::from_env(),
274 )
275}
276
277#[cfg(test)]
278mod tests {
279 use std::path::Path;
280
281 use test_better_core::{OrFail, TestResult};
282 use test_better_matchers::{check, contains_str, eq, is_true};
283
284 use super::*;
285
286 #[test]
287 fn snapshot_path_joins_module_and_name_with_a_snap_extension() -> TestResult {
288 let path = snapshot_path(Path::new("tests/snapshots"), "snapshot", "homepage");
289 check!(path).satisfies(eq(PathBuf::from("tests/snapshots/snapshot__homepage.snap")))
290 }
291
292 #[test]
293 fn snapshot_path_collapses_module_separators_and_sanitizes() -> TestResult {
294 let path = snapshot_path(Path::new("snaps"), "my_crate::ui::pages", "home page/v2");
295 check!(path).satisfies(eq(PathBuf::from(
296 "snaps/my_crate__ui__pages__home_page_v2.snap",
297 )))
298 }
299
300 #[test]
301 fn missing_file_in_compare_mode_is_a_missing_failure() -> TestResult {
302 let dir = scratch_dir("missing");
303 let outcome = assert_snapshot_in(&dir, "t", "absent", "value", SnapshotMode::Compare);
304 let failure = outcome
305 .err()
306 .or_fail_with("a missing snapshot should fail")?;
307 check!(matches!(failure, SnapshotFailure::Missing { .. })).satisfies(is_true())?;
308 // The message points the reader at how to create it.
309 check!(failure.to_string().as_str()).satisfies(contains_str("UPDATE_SNAPSHOTS=1"))?;
310 let _ = fs::remove_dir_all(&dir);
311 Ok(())
312 }
313
314 /// A unique scratch directory under the system temp dir, named for the
315 /// calling test so parallel tests never share one.
316 fn scratch_dir(tag: &str) -> PathBuf {
317 std::env::temp_dir().join(format!(
318 "test-better-snapshot-{}-{}",
319 std::process::id(),
320 tag
321 ))
322 }
323}