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