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(self, expected: &str, redactions: &Redactions) -> TestResult
305 where
306 T: Display,
307 {
308 let actual = redactions.apply(&self.actual.to_string());
309 let caller = Location::caller();
310 let location = InlineLocation {
311 file: caller.file().to_string(),
312 line: caller.line(),
313 column: caller.column(),
314 };
315 match test_better_snapshot::assert_inline_snapshot(
316 &actual,
317 expected,
318 &location,
319 SnapshotMode::from_env(),
320 ) {
321 Ok(()) => Ok(()),
322 Err(failure) => Err(inline_snapshot_error(self.expr, failure)),
323 }
324 }
325}
326
327/// Builds the error for a matcher that did not match: the expected/actual pair
328/// goes into the payload, the source expression into the message.
329#[track_caller]
330fn mismatch_error(expr: &str, mismatch: Mismatch) -> TestError {
331 TestError::new(ErrorKind::Assertion)
332 .with_message(format!("check!({expr})"))
333 .with_payload(Payload::ExpectedActual {
334 expected: mismatch.expected.to_string(),
335 actual: mismatch.actual,
336 diff: mismatch.diff,
337 })
338}
339
340/// Builds the error for `violates` when the matcher matched but should not
341/// have. There is no `Mismatch` in this case, so the message carries the whole
342/// story.
343#[track_caller]
344fn unexpected_match_error(expr: &str, description: Description) -> TestError {
345 TestError::new(ErrorKind::Assertion).with_message(format!(
346 "check!({expr}): expected it not to be {description}, but it was"
347 ))
348}
349
350/// Builds the error for `completes_within` when the future ran past its
351/// limit. This is a timing failure, not a value mismatch, so it carries only
352/// a message, no payload.
353#[track_caller]
354fn timeout_error(expr: &str, elapsed: Elapsed) -> TestError {
355 TestError::new(ErrorKind::Assertion).with_message(format!(
356 "check!({expr}): did not complete within {:?}",
357 elapsed.limit
358 ))
359}
360
361/// Renders a [`SnapshotFailure`] from `test-better-snapshot` into a `TestError`.
362///
363/// A mismatch becomes an `ExpectedActual` payload, exactly like a value
364/// matcher's mismatch, so the standard renderer shows it (and its diff) the
365/// same way. A missing file or an I/O error has no two sides to compare, so it
366/// carries only a message.
367#[track_caller]
368fn snapshot_error(expr: &str, name: &str, failure: SnapshotFailure) -> TestError {
369 match failure {
370 SnapshotFailure::Mismatch {
371 path,
372 expected,
373 actual,
374 } => {
375 let diff = snapshot_diff(&expected, &actual);
376 TestError::new(ErrorKind::Snapshot)
377 .with_message(format!(
378 "check!({expr}): snapshot {name:?} at {} does not match",
379 path.display()
380 ))
381 .with_payload(Payload::ExpectedActual {
382 expected,
383 actual,
384 diff,
385 })
386 }
387 SnapshotFailure::Missing { path } => {
388 TestError::new(ErrorKind::Snapshot).with_message(format!(
389 "check!({expr}): snapshot {name:?} does not exist at {}; \
390 rerun with UPDATE_SNAPSHOTS=1 to create it",
391 path.display()
392 ))
393 }
394 SnapshotFailure::Io {
395 path,
396 action,
397 source,
398 } => TestError::new(ErrorKind::Snapshot).with_message(format!(
399 "check!({expr}): snapshot {name:?} I/O error {action} ({}): {source}",
400 path.display()
401 )),
402 }
403}
404
405/// Renders an [`InlineSnapshotFailure`] into a `TestError`, the inline-snapshot
406/// counterpart of [`snapshot_error`]. Both sides go into an `ExpectedActual`
407/// payload so the standard renderer (and its diff) show the change.
408#[track_caller]
409fn inline_snapshot_error(expr: &str, failure: InlineSnapshotFailure) -> TestError {
410 let InlineSnapshotFailure { expected, actual } = failure;
411 let diff = snapshot_diff(&expected, &actual);
412 TestError::new(ErrorKind::Snapshot)
413 .with_message(format!(
414 "check!({expr}): inline snapshot does not match; \
415 rerun with UPDATE_SNAPSHOTS=1 to update it"
416 ))
417 .with_payload(Payload::ExpectedActual {
418 expected,
419 actual,
420 diff,
421 })
422}
423
424/// The line-oriented diff for a snapshot mismatch. Unlike a value matcher,
425/// which only diffs multi-line values, a snapshot is text and a diff is always
426/// the readable way to show what changed. With the `diff` feature off this is
427/// `None` and the failure still renders, just without the diff.
428#[cfg(feature = "diff")]
429fn snapshot_diff(expected: &str, actual: &str) -> Option<String> {
430 Some(crate::diff::diff_lines(expected, actual))
431}
432
433#[cfg(not(feature = "diff"))]
434fn snapshot_diff(_expected: &str, _actual: &str) -> Option<String> {
435 None
436}
437
438/// Captures an expression and its source text for assertion with a matcher.
439///
440/// ```
441/// use test_better_core::TestResult;
442/// use test_better_matchers::{check, eq};
443///
444/// fn main() -> TestResult {
445/// check!(2 + 2).satisfies(eq(4))?;
446/// Ok(())
447/// }
448/// ```
449#[macro_export]
450macro_rules! check {
451 ($actual:expr) => {
452 $crate::Subject::new($actual, ::core::stringify!($actual), ::core::module_path!())
453 };
454}
455
456#[cfg(test)]
457mod tests {
458 use test_better_core::TestResult;
459
460 use crate::{eq, is_true};
461
462 #[test]
463 fn satisfies_returns_ok_on_a_match() -> TestResult {
464 let result = check!(2 + 2).satisfies(eq(4));
465 check!(result.is_ok()).satisfies(is_true())?;
466 Ok(())
467 }
468
469 #[test]
470 fn satisfies_failure_mentions_the_expression_and_the_expected_value() -> TestResult {
471 let error = check!(2 + 2).satisfies(eq(5)).expect_err("2 + 2 is not 5");
472 let rendered = error.to_string();
473 check!(rendered.contains("2 + 2")).satisfies(is_true())?;
474 check!(rendered.contains("equal to 5")).satisfies(is_true())?;
475 check!(rendered.contains("actual: 4")).satisfies(is_true())?;
476 Ok(())
477 }
478
479 #[test]
480 fn satisfies_failure_captures_the_caller_location() -> TestResult {
481 let line = line!() + 1;
482 let error = check!(2 + 2).satisfies(eq(5)).expect_err("2 + 2 is not 5");
483 check!(error.location.line()).satisfies(eq(line))?;
484 check!(error.location.file().ends_with("subject.rs")).satisfies(is_true())?;
485 Ok(())
486 }
487
488 #[test]
489 fn violates_returns_ok_when_the_matcher_does_not_match() -> TestResult {
490 let result = check!(2 + 2).violates(eq(5));
491 check!(result.is_ok()).satisfies(is_true())?;
492 Ok(())
493 }
494
495 #[test]
496 fn violates_failure_mentions_the_expression_and_the_matcher() -> TestResult {
497 let error = check!(true).violates(is_true()).expect_err("true is true");
498 let rendered = error.to_string();
499 check!(rendered.contains("check!(true)")).satisfies(is_true())?;
500 check!(rendered.contains("not to be true")).satisfies(is_true())?;
501 Ok(())
502 }
503
504 #[test]
505 fn violates_captures_the_caller_location() -> TestResult {
506 let line = line!() + 1;
507 let error = check!(true).violates(is_true()).expect_err("true is true");
508 check!(error.location.line()).satisfies(eq(line))?;
509 Ok(())
510 }
511
512 #[test]
513 fn resolves_to_returns_ok_when_the_output_matches() -> TestResult {
514 pollster::block_on(async {
515 let result = check!(async { 2 + 2 }).resolves_to(eq(4)).await;
516 check!(result.is_ok()).satisfies(is_true())
517 })
518 }
519
520 #[test]
521 fn resolves_to_failure_mentions_the_expression_and_the_output() -> TestResult {
522 pollster::block_on(async {
523 let error = check!(async { 2 + 2 })
524 .resolves_to(eq(5))
525 .await
526 .expect_err("2 + 2 does not resolve to 5");
527 let rendered = error.to_string();
528 check!(rendered.contains("async { 2 + 2 }")).satisfies(is_true())?;
529 check!(rendered.contains("equal to 5")).satisfies(is_true())?;
530 check!(rendered.contains("actual: 4")).satisfies(is_true())
531 })
532 }
533
534 #[test]
535 fn resolves_to_failure_captures_the_call_site_not_the_await() -> TestResult {
536 // The location is captured where `resolves_to` is *called*, even
537 // though the future is awaited on a later line.
538 pollster::block_on(async {
539 let line = line!() + 1;
540 let pending = check!(async { 2 + 2 }).resolves_to(eq(5));
541 let error = pending.await.expect_err("2 + 2 does not resolve to 5");
542 check!(error.location.line()).satisfies(eq(line))?;
543 check!(error.location.file().ends_with("subject.rs")).satisfies(is_true())
544 })
545 }
546
547 #[test]
548 fn snapshot_mismatch_renders_as_a_snapshot_error_with_a_diff() -> TestResult {
549 use std::path::PathBuf;
550
551 use test_better_core::ErrorKind;
552 use test_better_snapshot::SnapshotFailure;
553
554 let failure = SnapshotFailure::Mismatch {
555 path: PathBuf::from("tests/snapshots/m__page.snap"),
556 expected: "line one\nline two".to_string(),
557 actual: "line one\nline TWO".to_string(),
558 };
559 let error = super::snapshot_error("page", "page", failure);
560 check!(error.kind == ErrorKind::Snapshot).satisfies(is_true())?;
561 let rendered = error.to_string();
562 check!(rendered.contains("snapshot \"page\"")).satisfies(is_true())?;
563 // The expected/actual payload renders, and `diff` is on by default.
564 check!(rendered.contains("line one")).satisfies(is_true())?;
565 #[cfg(feature = "diff")]
566 check!(rendered.contains("-line two")).satisfies(is_true())?;
567 Ok(())
568 }
569
570 #[test]
571 fn snapshot_missing_renders_as_a_snapshot_error_pointing_at_update() -> TestResult {
572 use std::path::PathBuf;
573
574 use test_better_core::ErrorKind;
575 use test_better_snapshot::SnapshotFailure;
576
577 let failure = SnapshotFailure::Missing {
578 path: PathBuf::from("tests/snapshots/m__page.snap"),
579 };
580 let error = super::snapshot_error("page", "page", failure);
581 check!(error.kind == ErrorKind::Snapshot).satisfies(is_true())?;
582 check!(error.to_string().contains("UPDATE_SNAPSHOTS=1")).satisfies(is_true())?;
583 Ok(())
584 }
585
586 #[test]
587 fn inline_snapshot_mismatch_renders_as_a_snapshot_error_with_a_diff() -> TestResult {
588 use test_better_core::ErrorKind;
589 use test_better_snapshot::InlineSnapshotFailure;
590
591 let failure = InlineSnapshotFailure {
592 expected: "one\ntwo".to_string(),
593 actual: "one\nTWO".to_string(),
594 };
595 let error = super::inline_snapshot_error("value", failure);
596 check!(error.kind == ErrorKind::Snapshot).satisfies(is_true())?;
597 let rendered = error.to_string();
598 check!(rendered.contains("inline snapshot does not match")).satisfies(is_true())?;
599 check!(rendered.contains("UPDATE_SNAPSHOTS=1")).satisfies(is_true())?;
600 #[cfg(feature = "diff")]
601 check!(rendered.contains("-two")).satisfies(is_true())?;
602 Ok(())
603 }
604}