Skip to main content

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}