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}