Skip to main content

test_better_matchers/
subject.rs

1//! The [`check!`](crate::check) macro and its [`Subject`] type: the entry point for writing
2//! an assertion.
3//!
4//! `check!(value)` captures the value *and the source text of the expression
5//! it came from*, so a failure can name `2 + 2`, not just `4`. The resulting
6//! [`Subject`] is consumed by [`Subject::satisfies`] / [`Subject::violates`],
7//! each of which returns a [`TestResult`] so the assertion chains with `?`.
8//!
9//! Every method on `Subject` reads as a present-tense factual claim about the
10//! value: "x satisfies the matcher", "x matches the snapshot", "the future
11//! completes within 50ms". That shape is the convention for the whole crate.
12//!
13//! # Async
14//!
15//! When the expression handed to `check!` is a [`Future`], the resulting
16//! `Subject` grows an `await`-based method, [`Subject::resolves_to`]. The
17//! design is a single `Subject<T>` with that method added to *this* impl block
18//! under a method-level `where T: Future` bound and a distinct name: a blanket
19//! `impl<T> Subject<T>` and an overlapping `impl<F: Future> Subject<F>` cannot
20//! coexist as inherent impls.
21//!
22//! `resolves_to` is runtime-agnostic: it just awaits the future, so it works
23//! under `#[tokio::test]`, `#[async_std::test]`, `pollster::block_on`, or any
24//! other executor.
25
26use std::fmt::Display;
27use std::future::Future;
28use std::panic::Location;
29use std::time::Duration;
30
31use test_better_async::{Elapsed, RuntimeAvailable, run_within};
32use test_better_core::{ErrorKind, Payload, TestError, TestResult};
33use test_better_snapshot::{
34    InlineLocation, InlineSnapshotFailure, Redactions, SnapshotFailure, SnapshotMode,
35};
36
37use crate::description::Description;
38use crate::matcher::{Matcher, Mismatch};
39
40/// A value under test, paired with the source text of the expression that
41/// produced it.
42///
43/// `Subject` owns its value (the [`check!`](crate::check) macro hands it over by value) and
44/// borrows nothing, so it carries no lifetime parameter.
45pub struct Subject<T> {
46    actual: T,
47    expr: &'static str,
48    module_path: &'static str,
49}
50
51impl<T> Subject<T> {
52    /// Pairs `actual` with the source text it came from and the `module_path!()`
53    /// of the call site. Called by [`check!`](crate::check); rarely
54    /// constructed directly.
55    ///
56    /// `module_path` is only consulted by [`matches_snapshot`](Self::matches_snapshot),
57    /// which uses it to name the snapshot file; every other method ignores it.
58    #[must_use]
59    pub fn new(actual: T, expr: &'static str, module_path: &'static str) -> Self {
60        Self {
61            actual,
62            expr,
63            module_path,
64        }
65    }
66
67    /// Asserts that the value satisfies `matcher`.
68    ///
69    /// Returns `Ok(())` on a match and a [`TestError`] otherwise. The result is
70    /// `#[must_use]` (it is a `Result`), so a forgotten `?` is a compiler
71    /// warning rather than a silently-passing assertion.
72    #[track_caller]
73    pub fn satisfies<M>(self, matcher: M) -> TestResult
74    where
75        M: Matcher<T>,
76    {
77        match matcher.check(&self.actual).failure {
78            None => Ok(()),
79            Some(mismatch) => Err(mismatch_error(self.expr, mismatch)),
80        }
81    }
82
83    /// Asserts that the value does *not* satisfy `matcher`.
84    ///
85    /// Returns `Ok(())` when the matcher does not match, and a [`TestError`]
86    /// when it unexpectedly does. Equivalent to
87    /// [`satisfies`](Self::satisfies)`(`[`not`](crate::not)`(matcher))`; pick
88    /// whichever reads better at the call site.
89    #[track_caller]
90    pub fn violates<M>(self, matcher: M) -> TestResult
91    where
92        M: Matcher<T>,
93    {
94        if matcher.check(&self.actual).matched {
95            Err(unexpected_match_error(self.expr, matcher.description()))
96        } else {
97            Ok(())
98        }
99    }
100
101    /// Awaits the future and asserts that its output satisfies `matcher`.
102    ///
103    /// This is the async counterpart of [`satisfies`](Self::satisfies): reach
104    /// for it when the expression handed to [`check!`](crate::check) is a
105    /// [`Future`]. The matcher runs against the future's *output*, so
106    /// `check!(fut).resolves_to(eq(4))` is exactly
107    /// `check!(fut.await).satisfies(eq(4))` without the intermediate binding.
108    ///
109    /// The method itself is *not* `async`: it is `#[track_caller]` and returns
110    /// a future. The call-site location is captured synchronously when
111    /// `resolves_to` is called (an `async fn` could not be `#[track_caller]`),
112    /// then carried into the failure once the returned future is awaited.
113    ///
114    /// ```
115    /// use test_better_core::TestResult;
116    /// use test_better_matchers::{check, eq};
117    ///
118    /// # fn main() -> TestResult {
119    /// pollster::block_on(async {
120    ///     check!(async { 2 + 2 }).resolves_to(eq(4)).await?;
121    ///     TestResult::Ok(())
122    /// })
123    /// # }
124    /// ```
125    #[track_caller]
126    pub fn resolves_to<M>(self, matcher: M) -> impl Future<Output = TestResult>
127    where
128        T: Future,
129        M: Matcher<T::Output>,
130    {
131        // Captured here, synchronously, before the returned future is ever
132        // polled: this is the user's `check!(..).resolves_to(..)` call site.
133        let location = Location::caller();
134        async move {
135            let output = self.actual.await;
136            match matcher.check(&output).failure {
137                None => Ok(()),
138                Some(mismatch) => Err(mismatch_error(self.expr, mismatch).with_location(location)),
139            }
140        }
141    }
142
143    /// Awaits the future, but fails if it does not finish within `limit`.
144    ///
145    /// Like [`resolves_to`](Self::resolves_to), this is for a future-typed
146    /// subject and returns a future rather than being `async` itself, so the
147    /// `#[track_caller]` location is the call site. Unlike `resolves_to`, it
148    /// does not look at the output: the assertion is purely about *time*.
149    ///
150    /// The timeout needs a runtime to provide its sleep, selected by a cargo
151    /// feature on `test-better`: `tokio`, `async-std`, or `smol`. With none
152    /// enabled, the `T: RuntimeAvailable` bound is unsatisfied and the call is
153    /// a compile error naming those flags.
154    ///
155    /// ```ignore
156    /// use std::time::Duration;
157    /// use test_better::prelude::*;
158    ///
159    /// # async fn run() -> TestResult {
160    /// check!(some_future())
161    ///     .completes_within(Duration::from_millis(50))
162    ///     .await?;
163    /// # Ok(())
164    /// # }
165    /// ```
166    #[track_caller]
167    pub fn completes_within(self, limit: Duration) -> impl Future<Output = TestResult>
168    where
169        T: Future + RuntimeAvailable,
170    {
171        let location = Location::caller();
172        async move {
173            match run_within(limit, self.actual).await {
174                Ok(_) => Ok(()),
175                Err(elapsed) => Err(timeout_error(self.expr, elapsed).with_location(location)),
176            }
177        }
178    }
179
180    /// Asserts that the value matches the snapshot stored under `name`.
181    ///
182    /// The snapshot lives at `tests/snapshots/<module-path>__<name>.snap` in the
183    /// package under test, with `<module-path>` taken from the call site's
184    /// `module_path!()`. On a normal run the value is compared against that
185    /// file; a difference (or a missing file) is a `Snapshot` failure, and the
186    /// difference is rendered with the standard line-oriented diff. Rerun the
187    /// test with `UPDATE_SNAPSHOTS=1` to create the file the first time, or to
188    /// accept an intentional change.
189    ///
190    /// The value only has to be [`Display`]: that is what gets written to and
191    /// compared against the file.
192    ///
193    /// ```no_run
194    /// use test_better_core::TestResult;
195    /// use test_better_matchers::check;
196    ///
197    /// # fn main() -> TestResult {
198    /// // Run once with `UPDATE_SNAPSHOTS=1` to record the snapshot; later runs
199    /// // compare against `tests/snapshots/<module>__homepage.snap`.
200    /// check!("<h1>Hello</h1>").matches_snapshot("homepage")?;
201    /// # Ok(())
202    /// # }
203    /// ```
204    #[track_caller]
205    pub fn matches_snapshot(self, name: &str) -> TestResult
206    where
207        T: Display,
208    {
209        self.matches_snapshot_with(name, &Redactions::new())
210    }
211
212    /// Like [`matches_snapshot`](Self::matches_snapshot), but runs `redactions`
213    /// over the value first.
214    ///
215    /// Use this when the rendered value carries content that is not stable run
216    /// to run (a generated UUID, a timestamp): the redactions rewrite those
217    /// spans to fixed placeholders, so the snapshot file stays meaningful.
218    /// Because the redactions run on every comparison, the placeholder is what
219    /// is stored and what later runs compare against.
220    ///
221    /// ```no_run
222    /// use test_better_core::TestResult;
223    /// use test_better_matchers::check;
224    /// use test_better_snapshot::Redactions;
225    ///
226    /// # fn main() -> TestResult {
227    /// let rendered = format!("created {}", uuid_of_new_record());
228    /// check!(rendered)
229    ///     .matches_snapshot_with("record", &Redactions::new().redact_uuids())?;
230    /// # Ok(())
231    /// # }
232    /// # fn uuid_of_new_record() -> &'static str { "00000000-0000-0000-0000-000000000000" }
233    /// ```
234    #[track_caller]
235    pub fn matches_snapshot_with(self, name: &str, redactions: &Redactions) -> TestResult
236    where
237        T: Display,
238    {
239        let actual = redactions.apply(&self.actual.to_string());
240        match test_better_snapshot::assert_snapshot(self.module_path, name, &actual) {
241            Ok(()) => Ok(()),
242            Err(failure) => Err(snapshot_error(self.expr, name, failure)),
243        }
244    }
245
246    /// Asserts that the value matches the inline snapshot literal `expected`.
247    ///
248    /// Unlike [`matches_snapshot`](Self::matches_snapshot), the snapshot lives
249    /// in the test source: `expected` *is* the snapshot. The literal is
250    /// normalized before comparison (a leading newline and the common
251    /// indentation are cosmetic), so it can be indented to match the
252    /// surrounding code.
253    ///
254    /// On a mismatch with `UPDATE_SNAPSHOTS` unset this fails like any
255    /// assertion. With `UPDATE_SNAPSHOTS=1` it records a *pending patch* under
256    /// `target/test-better-pending/` and passes; the `test-better-accept`
257    /// companion binary rewrites the literal in the source from those patches.
258    /// A proc macro could not do this rewrite (it runs before the test), so the
259    /// work is split: this method captures the call-site location with
260    /// `#[track_caller]`, the accept binary edits the file.
261    ///
262    /// ```no_run
263    /// use test_better_core::TestResult;
264    /// use test_better_matchers::check;
265    ///
266    /// # fn main() -> TestResult {
267    /// check!(2 + 2).matches_inline_snapshot("4")?;
268    /// # Ok(())
269    /// # }
270    /// ```
271    #[track_caller]
272    pub fn matches_inline_snapshot(self, expected: &str) -> TestResult
273    where
274        T: Display,
275    {
276        self.matches_inline_snapshot_with(expected, &Redactions::new())
277    }
278
279    /// Like [`matches_inline_snapshot`](Self::matches_inline_snapshot), but
280    /// runs `redactions` over the value first.
281    ///
282    /// The inline-snapshot counterpart of
283    /// [`matches_snapshot_with`](Self::matches_snapshot_with): redaction
284    /// rewrites non-deterministic spans to fixed placeholders before the
285    /// comparison, so `UPDATE_SNAPSHOTS=1` records the *redacted* value as the
286    /// literal and later runs stay green.
287    ///
288    /// ```no_run
289    /// use test_better_core::TestResult;
290    /// use test_better_matchers::check;
291    /// use test_better_snapshot::Redactions;
292    ///
293    /// # fn main() -> TestResult {
294    /// let rendered = format!("at {}", now_rfc3339());
295    /// check!(rendered).matches_inline_snapshot_with(
296    ///     "at [timestamp]",
297    ///     &Redactions::new().redact_rfc3339_timestamps(),
298    /// )?;
299    /// # Ok(())
300    /// # }
301    /// # fn now_rfc3339() -> &'static str { "2026-05-14T12:34:56Z" }
302    /// ```
303    #[track_caller]
304    pub fn matches_inline_snapshot_with(
305        self,
306        expected: &str,
307        redactions: &Redactions,
308    ) -> TestResult
309    where
310        T: Display,
311    {
312        let actual = redactions.apply(&self.actual.to_string());
313        let caller = Location::caller();
314        let location = InlineLocation {
315            file: caller.file().to_string(),
316            line: caller.line(),
317            column: caller.column(),
318        };
319        match test_better_snapshot::assert_inline_snapshot(
320            &actual,
321            expected,
322            &location,
323            SnapshotMode::from_env(),
324        ) {
325            Ok(()) => Ok(()),
326            Err(failure) => Err(inline_snapshot_error(self.expr, failure)),
327        }
328    }
329}
330
331/// Builds the error for a matcher that did not match: the expected/actual pair
332/// goes into the payload, the source expression into the message.
333#[track_caller]
334fn mismatch_error(expr: &str, mismatch: Mismatch) -> TestError {
335    TestError::new(ErrorKind::Assertion)
336        .with_message(format!("check!({expr})"))
337        .with_payload(Payload::ExpectedActual {
338            expected: mismatch.expected.to_string(),
339            actual: mismatch.actual,
340            diff: mismatch.diff,
341        })
342}
343
344/// Builds the error for `violates` when the matcher matched but should not
345/// have. There is no `Mismatch` in this case, so the message carries the whole
346/// story.
347#[track_caller]
348fn unexpected_match_error(expr: &str, description: Description) -> TestError {
349    TestError::new(ErrorKind::Assertion).with_message(format!(
350        "check!({expr}): expected it not to be {description}, but it was"
351    ))
352}
353
354/// Builds the error for `completes_within` when the future ran past its
355/// limit. This is a timing failure, not a value mismatch, so it carries only
356/// a message, no payload.
357#[track_caller]
358fn timeout_error(expr: &str, elapsed: Elapsed) -> TestError {
359    TestError::new(ErrorKind::Assertion).with_message(format!(
360        "check!({expr}): did not complete within {:?}",
361        elapsed.limit
362    ))
363}
364
365/// Renders a [`SnapshotFailure`] from `test-better-snapshot` into a `TestError`.
366///
367/// A mismatch becomes an `ExpectedActual` payload, exactly like a value
368/// matcher's mismatch, so the standard renderer shows it (and its diff) the
369/// same way. A missing file or an I/O error has no two sides to compare, so it
370/// carries only a message.
371#[track_caller]
372fn snapshot_error(expr: &str, name: &str, failure: SnapshotFailure) -> TestError {
373    match failure {
374        SnapshotFailure::Mismatch {
375            path,
376            expected,
377            actual,
378        } => {
379            let diff = snapshot_diff(&expected, &actual);
380            TestError::new(ErrorKind::Snapshot)
381                .with_message(format!(
382                    "check!({expr}): snapshot {name:?} at {} does not match",
383                    path.display()
384                ))
385                .with_payload(Payload::ExpectedActual {
386                    expected,
387                    actual,
388                    diff,
389                })
390        }
391        SnapshotFailure::Missing { path } => {
392            TestError::new(ErrorKind::Snapshot).with_message(format!(
393                "check!({expr}): snapshot {name:?} does not exist at {}; \
394                 rerun with UPDATE_SNAPSHOTS=1 to create it",
395                path.display()
396            ))
397        }
398        SnapshotFailure::Io {
399            path,
400            action,
401            source,
402        } => TestError::new(ErrorKind::Snapshot).with_message(format!(
403            "check!({expr}): snapshot {name:?} I/O error {action} ({}): {source}",
404            path.display()
405        )),
406    }
407}
408
409/// Renders an [`InlineSnapshotFailure`] into a `TestError`, the inline-snapshot
410/// counterpart of [`snapshot_error`]. Both sides go into an `ExpectedActual`
411/// payload so the standard renderer (and its diff) show the change.
412#[track_caller]
413fn inline_snapshot_error(expr: &str, failure: InlineSnapshotFailure) -> TestError {
414    let InlineSnapshotFailure { expected, actual } = failure;
415    let diff = snapshot_diff(&expected, &actual);
416    TestError::new(ErrorKind::Snapshot)
417        .with_message(format!(
418            "check!({expr}): inline snapshot does not match; \
419             rerun with UPDATE_SNAPSHOTS=1 to update it"
420        ))
421        .with_payload(Payload::ExpectedActual {
422            expected,
423            actual,
424            diff,
425        })
426}
427
428/// The line-oriented diff for a snapshot mismatch. Unlike a value matcher,
429/// which only diffs multi-line values, a snapshot is text and a diff is always
430/// the readable way to show what changed. With the `diff` feature off this is
431/// `None` and the failure still renders, just without the diff.
432#[cfg(feature = "diff")]
433fn snapshot_diff(expected: &str, actual: &str) -> Option<String> {
434    Some(crate::diff::diff_lines(expected, actual))
435}
436
437#[cfg(not(feature = "diff"))]
438fn snapshot_diff(_expected: &str, _actual: &str) -> Option<String> {
439    None
440}
441
442/// Captures an expression and its source text for assertion with a matcher.
443///
444/// ```
445/// use test_better_core::TestResult;
446/// use test_better_matchers::{check, eq};
447///
448/// fn main() -> TestResult {
449///     check!(2 + 2).satisfies(eq(4))?;
450///     Ok(())
451/// }
452/// ```
453#[macro_export]
454macro_rules! check {
455    ($actual:expr) => {
456        $crate::Subject::new($actual, ::core::stringify!($actual), ::core::module_path!())
457    };
458}
459
460#[cfg(test)]
461mod tests {
462    use test_better_core::TestResult;
463
464    use crate::{eq, is_true};
465
466    #[test]
467    fn satisfies_returns_ok_on_a_match() -> TestResult {
468        let result = check!(2 + 2).satisfies(eq(4));
469        check!(result.is_ok()).satisfies(is_true())?;
470        Ok(())
471    }
472
473    #[test]
474    fn satisfies_failure_mentions_the_expression_and_the_expected_value() -> TestResult {
475        let error = check!(2 + 2).satisfies(eq(5)).expect_err("2 + 2 is not 5");
476        let rendered = error.to_string();
477        check!(rendered.contains("2 + 2")).satisfies(is_true())?;
478        check!(rendered.contains("equal to 5")).satisfies(is_true())?;
479        check!(rendered.contains("actual: 4")).satisfies(is_true())?;
480        Ok(())
481    }
482
483    #[test]
484    fn satisfies_failure_captures_the_caller_location() -> TestResult {
485        let line = line!() + 1;
486        let error = check!(2 + 2).satisfies(eq(5)).expect_err("2 + 2 is not 5");
487        check!(error.location.line()).satisfies(eq(line))?;
488        check!(error.location.file().ends_with("subject.rs")).satisfies(is_true())?;
489        Ok(())
490    }
491
492    #[test]
493    fn violates_returns_ok_when_the_matcher_does_not_match() -> TestResult {
494        let result = check!(2 + 2).violates(eq(5));
495        check!(result.is_ok()).satisfies(is_true())?;
496        Ok(())
497    }
498
499    #[test]
500    fn violates_failure_mentions_the_expression_and_the_matcher() -> TestResult {
501        let error = check!(true).violates(is_true()).expect_err("true is true");
502        let rendered = error.to_string();
503        check!(rendered.contains("check!(true)")).satisfies(is_true())?;
504        check!(rendered.contains("not to be true")).satisfies(is_true())?;
505        Ok(())
506    }
507
508    #[test]
509    fn violates_captures_the_caller_location() -> TestResult {
510        let line = line!() + 1;
511        let error = check!(true).violates(is_true()).expect_err("true is true");
512        check!(error.location.line()).satisfies(eq(line))?;
513        Ok(())
514    }
515
516    #[test]
517    fn resolves_to_returns_ok_when_the_output_matches() -> TestResult {
518        pollster::block_on(async {
519            let result = check!(async { 2 + 2 }).resolves_to(eq(4)).await;
520            check!(result.is_ok()).satisfies(is_true())
521        })
522    }
523
524    #[test]
525    fn resolves_to_failure_mentions_the_expression_and_the_output() -> TestResult {
526        pollster::block_on(async {
527            let error = check!(async { 2 + 2 })
528                .resolves_to(eq(5))
529                .await
530                .expect_err("2 + 2 does not resolve to 5");
531            let rendered = error.to_string();
532            check!(rendered.contains("async { 2 + 2 }")).satisfies(is_true())?;
533            check!(rendered.contains("equal to 5")).satisfies(is_true())?;
534            check!(rendered.contains("actual: 4")).satisfies(is_true())
535        })
536    }
537
538    #[test]
539    fn resolves_to_failure_captures_the_call_site_not_the_await() -> TestResult {
540        // The location is captured where `resolves_to` is *called*, even
541        // though the future is awaited on a later line.
542        pollster::block_on(async {
543            let line = line!() + 1;
544            let pending = check!(async { 2 + 2 }).resolves_to(eq(5));
545            let error = pending.await.expect_err("2 + 2 does not resolve to 5");
546            check!(error.location.line()).satisfies(eq(line))?;
547            check!(error.location.file().ends_with("subject.rs")).satisfies(is_true())
548        })
549    }
550
551    #[test]
552    fn snapshot_mismatch_renders_as_a_snapshot_error_with_a_diff() -> TestResult {
553        use std::path::PathBuf;
554
555        use test_better_core::ErrorKind;
556        use test_better_snapshot::SnapshotFailure;
557
558        let failure = SnapshotFailure::Mismatch {
559            path: PathBuf::from("tests/snapshots/m__page.snap"),
560            expected: "line one\nline two".to_string(),
561            actual: "line one\nline TWO".to_string(),
562        };
563        let error = super::snapshot_error("page", "page", failure);
564        check!(error.kind == ErrorKind::Snapshot).satisfies(is_true())?;
565        let rendered = error.to_string();
566        check!(rendered.contains("snapshot \"page\"")).satisfies(is_true())?;
567        // The expected/actual payload renders, and `diff` is on by default.
568        check!(rendered.contains("line one")).satisfies(is_true())?;
569        #[cfg(feature = "diff")]
570        check!(rendered.contains("-line two")).satisfies(is_true())?;
571        Ok(())
572    }
573
574    #[test]
575    fn snapshot_missing_renders_as_a_snapshot_error_pointing_at_update() -> TestResult {
576        use std::path::PathBuf;
577
578        use test_better_core::ErrorKind;
579        use test_better_snapshot::SnapshotFailure;
580
581        let failure = SnapshotFailure::Missing {
582            path: PathBuf::from("tests/snapshots/m__page.snap"),
583        };
584        let error = super::snapshot_error("page", "page", failure);
585        check!(error.kind == ErrorKind::Snapshot).satisfies(is_true())?;
586        check!(error.to_string().contains("UPDATE_SNAPSHOTS=1")).satisfies(is_true())?;
587        Ok(())
588    }
589
590    #[test]
591    fn inline_snapshot_mismatch_renders_as_a_snapshot_error_with_a_diff() -> TestResult {
592        use test_better_core::ErrorKind;
593        use test_better_snapshot::InlineSnapshotFailure;
594
595        let failure = InlineSnapshotFailure {
596            expected: "one\ntwo".to_string(),
597            actual: "one\nTWO".to_string(),
598        };
599        let error = super::inline_snapshot_error("value", failure);
600        check!(error.kind == ErrorKind::Snapshot).satisfies(is_true())?;
601        let rendered = error.to_string();
602        check!(rendered.contains("inline snapshot does not match")).satisfies(is_true())?;
603        check!(rendered.contains("UPDATE_SNAPSHOTS=1")).satisfies(is_true())?;
604        #[cfg(feature = "diff")]
605        check!(rendered.contains("-two")).satisfies(is_true())?;
606        Ok(())
607    }
608}