Skip to main content

test_better_core/
error.rs

1//! The [`TestError`] data model: the single source of truth for a test failure.
2//!
3//! A `TestError` carries structured data, never pre-rendered text. Two consumers
4//! read it:
5//!
6//! - the human renderer ([`Display`]/[`Debug`], see [`crate::render`]);
7//! - tooling and the runner, via [`TestError::to_structured`].
8
9use std::borrow::Cow;
10use std::fmt;
11use std::panic::Location;
12
13use crate::trace::TraceEntry;
14
15/// The category of a [`TestError`].
16///
17/// The kind selects the headline of the rendered failure and lets tooling group
18/// failures (a setup failure is not the same as an assertion miss).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[non_exhaustive]
22pub enum ErrorKind {
23    /// An assertion did not hold (the common case).
24    Assertion,
25    /// Test setup failed before the assertions could run (fixtures).
26    Setup,
27    /// An operation did not complete within its deadline.
28    Timeout,
29    /// A snapshot did not match its stored value.
30    Snapshot,
31    /// A property failed for some generated input.
32    Property,
33    /// A failure that does not fit the other kinds, including errors propagated
34    /// from non-`test-better` code via `?`.
35    Custom,
36}
37
38impl ErrorKind {
39    /// The headline shown on the first line of a rendered failure.
40    #[must_use]
41    pub fn headline(self) -> &'static str {
42        match self {
43            ErrorKind::Assertion => "assertion failed",
44            ErrorKind::Setup => "test setup failed",
45            ErrorKind::Timeout => "timed out",
46            ErrorKind::Snapshot => "snapshot mismatch",
47            ErrorKind::Property => "property failed",
48            ErrorKind::Custom => "test failed",
49        }
50    }
51}
52
53/// One human-readable frame in a [`TestError`]'s context chain.
54///
55/// Frames render in the order they were added, so the chain reads from the
56/// outermost circumstance to the innermost.
57#[derive(Debug, Clone)]
58pub struct ContextFrame {
59    /// The "while doing X" description.
60    pub message: Cow<'static, str>,
61    /// Where the frame was attached, when known.
62    pub location: Option<&'static Location<'static>>,
63}
64
65impl ContextFrame {
66    /// Creates a frame, capturing the caller's location.
67    #[track_caller]
68    #[must_use]
69    pub fn new(message: impl Into<Cow<'static, str>>) -> Self {
70        Self {
71            message: message.into(),
72            location: Some(Location::caller()),
73        }
74    }
75}
76
77/// Structured detail attached to a [`TestError`] beyond its message.
78#[derive(Debug)]
79#[non_exhaustive]
80pub enum Payload {
81    /// A comparison failure carrying the expected and actual values, and an
82    /// optional structural diff.
83    ExpectedActual {
84        /// `Debug`-rendered expected value.
85        expected: String,
86        /// `Debug`-rendered actual value.
87        actual: String,
88        /// Optional pre-rendered diff between the two.
89        diff: Option<String>,
90    },
91    /// Several failures collected together (soft assertions).
92    Multiple(Vec<TestError>),
93    /// An error propagated from outside `test-better`, preserved so its source
94    /// chain stays walkable.
95    Other(Box<dyn std::error::Error + Send + Sync>),
96}
97
98/// A test failure.
99///
100/// Every fallible `test-better` operation produces a `TestError` on the error
101/// path, so `?` is the single control-flow operator of a test.
102///
103/// # Note on the `message` field
104///
105/// An earlier design sketch had `TestError` without a top-level `message`.
106/// A dedicated `message` field is kept here instead of overloading the first
107/// context frame: the message answers *what* failed, while context frames
108/// answer *while doing what*. This deviation is recorded in `CHANGELOG.md`.
109pub struct TestError {
110    /// The failure category.
111    pub kind: ErrorKind,
112    /// What failed, when there is a concise statement of it.
113    pub message: Option<Cow<'static, str>>,
114    /// Where the failure originated (`#[track_caller]` capture).
115    pub location: &'static Location<'static>,
116    /// The context chain, outermost first.
117    pub context: Vec<ContextFrame>,
118    /// The in-test breadcrumbs ([`Trace`](crate::Trace)) that were active when
119    /// this error was built, oldest first. Empty when no `Trace` was in scope.
120    pub trace: Vec<TraceEntry>,
121    /// Structured detail, when applicable.
122    ///
123    /// Boxed so `TestError` stays small: it is returned by value through every
124    /// `?` in a test, and [`Payload::ExpectedActual`] would otherwise inline
125    /// three `String`s into the struct.
126    pub payload: Option<Box<Payload>>,
127}
128
129impl TestError {
130    /// Builds a bare error at an explicit location. Internal: the public
131    /// surface is the `#[track_caller]` constructors, which capture the
132    /// caller's location for themselves.
133    pub(crate) fn at(kind: ErrorKind, location: &'static Location<'static>) -> Self {
134        Self {
135            kind,
136            message: None,
137            location,
138            context: Vec::new(),
139            // Snapshot the active `Trace` (if any) at construction time, so the
140            // error carries the breadcrumbs that led up to the failure.
141            trace: crate::trace::snapshot(),
142            payload: None,
143        }
144    }
145
146    /// Creates a bare error of the given `kind`, capturing the caller's location.
147    #[track_caller]
148    #[must_use]
149    pub fn new(kind: ErrorKind) -> Self {
150        Self::at(kind, Location::caller())
151    }
152
153    /// Creates an [`ErrorKind::Assertion`] error with the given message.
154    ///
155    /// This is the common path for a hand-written failure: `return
156    /// Err(TestError::assertion("..."))`.
157    #[track_caller]
158    #[must_use]
159    pub fn assertion(message: impl Into<Cow<'static, str>>) -> Self {
160        Self::at(ErrorKind::Assertion, Location::caller()).with_message(message)
161    }
162
163    /// Creates an [`ErrorKind::Custom`] error with the given message, for a
164    /// failure that does not fit a more specific kind.
165    #[track_caller]
166    #[must_use]
167    pub fn custom(message: impl Into<Cow<'static, str>>) -> Self {
168        Self::at(ErrorKind::Custom, Location::caller()).with_message(message)
169    }
170
171    /// Creates an [`ErrorKind::Assertion`] error from a mismatched
172    /// expected/actual pair, capturing each value's `Debug` representation into
173    /// a [`Payload::ExpectedActual`].
174    #[track_caller]
175    #[must_use]
176    pub fn from_expected_actual(expected: impl fmt::Debug, actual: impl fmt::Debug) -> Self {
177        Self::at(ErrorKind::Assertion, Location::caller()).with_payload(Payload::ExpectedActual {
178            expected: format!("{expected:?}"),
179            actual: format!("{actual:?}"),
180            diff: None,
181        })
182    }
183
184    /// Sets the [`message`](Self::message), consuming and returning `self`.
185    #[must_use]
186    pub fn with_message(mut self, message: impl Into<Cow<'static, str>>) -> Self {
187        self.message = Some(message.into());
188        self
189    }
190
191    /// Overrides the [`kind`](Self::kind), consuming and returning `self`.
192    ///
193    /// This is how a failure is re-categorized after the fact: the `#[fixture]`
194    /// macro uses it to turn whatever a fixture body produced into an
195    /// [`ErrorKind::Setup`] failure, so a setup problem never masquerades as an
196    /// assertion miss.
197    #[must_use]
198    pub fn with_kind(mut self, kind: ErrorKind) -> Self {
199        self.kind = kind;
200        self
201    }
202
203    /// Overrides the [`location`](Self::location), consuming and returning
204    /// `self`.
205    ///
206    /// The `#[track_caller]` constructors capture the caller's location for
207    /// themselves, so this is rarely needed. It exists for the case where the
208    /// location must be captured separately from where the error is built: an
209    /// `async fn` cannot be `#[track_caller]`, so the async `check!` methods
210    /// capture [`Location::caller`] synchronously at the call site and thread
211    /// it through here once the awaited assertion has a result.
212    #[must_use]
213    pub fn with_location(mut self, location: &'static Location<'static>) -> Self {
214        self.location = location;
215        self
216    }
217
218    /// Sets the [`payload`](Self::payload), consuming and returning `self`.
219    #[must_use]
220    pub fn with_payload(mut self, payload: Payload) -> Self {
221        self.payload = Some(Box::new(payload));
222        self
223    }
224
225    /// Appends a context frame, consuming and returning `self`.
226    #[must_use]
227    pub fn with_context_frame(mut self, frame: ContextFrame) -> Self {
228        self.context.push(frame);
229        self
230    }
231
232    /// Appends a context frame in place.
233    pub fn push_context(&mut self, frame: ContextFrame) {
234        self.context.push(frame);
235    }
236}
237
238impl fmt::Display for TestError {
239    /// Renders the failure as plain text, never colored.
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        crate::render::render(self, f, false)
242    }
243}
244
245impl fmt::Debug for TestError {
246    /// Renders the full pretty failure message, so the stock `cargo test`
247    /// harness (which prints returned errors with `{:?}`) is already useful.
248    /// Unlike `Display`, this may emit ANSI
249    /// color, gated by the process-wide [`ColorChoice`](crate::ColorChoice).
250    ///
251    /// When the `cargo test-better` runner is driving the run (it sets
252    /// [`RUNNER_ENV`](crate::RUNNER_ENV)), a trailing marker line carrying the
253    /// structured failure is appended after the human-readable render, for the
254    /// runner's structured-output channel. An ordinary `cargo test` run never
255    /// sets that variable, so it never sees that line.
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        crate::render::render(self, f, crate::color::color_enabled())?;
258        crate::runner::write_structured_marker(self, f)
259    }
260}
261
262impl std::error::Error for TestError {
263    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
264        match self.payload.as_deref() {
265            Some(Payload::Other(inner)) => Some(inner.as_ref()),
266            _ => None,
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::{OrFail, TestResult};
275    use test_better_matchers::{check, eq, is_true};
276
277    #[track_caller]
278    fn sample_assertion() -> TestError {
279        TestError::new(ErrorKind::Assertion).with_message("values differ")
280    }
281
282    #[test]
283    fn new_captures_caller_location() -> TestResult {
284        let line = line!() + 1;
285        let error = TestError::new(ErrorKind::Assertion);
286        check!(error.location.line())
287            .satisfies(eq(line))
288            .or_fail()?;
289        check!(error.location.file().ends_with("error.rs"))
290            .satisfies(is_true())
291            .or_fail()?;
292        Ok(())
293    }
294
295    #[test]
296    fn display_includes_headline_message_and_location() -> TestResult {
297        let rendered = sample_assertion().to_string();
298        check!(rendered.contains("assertion failed: values differ"))
299            .satisfies(is_true())
300            .or_fail()?;
301        check!(rendered.contains("  at "))
302            .satisfies(is_true())
303            .or_fail()?;
304        Ok(())
305    }
306
307    #[test]
308    fn debug_matches_display() -> TestResult {
309        // `Debug` may colorize off the global `ColorChoice`; hold the lock so a
310        // concurrent color test cannot flip it mid-render.
311        let _guard = crate::color::TEST_LOCK
312            .lock()
313            .unwrap_or_else(std::sync::PoisonError::into_inner);
314        let error = sample_assertion();
315        // `Debug` also appends the structured-output marker line when the
316        // runner is driving the run (`RUNNER_ENV` set); compare only the
317        // human-readable render, which is what `Display` produces.
318        let debug = format!("{error:?}");
319        let human = debug
320            .split_once(crate::STRUCTURED_MARKER)
321            .map_or(debug.as_str(), |(before, _)| before.trim_end());
322        check!(human)
323            .satisfies(eq(format!("{error}").as_str()))
324            .or_fail()?;
325        Ok(())
326    }
327
328    #[test]
329    fn context_frames_render_in_order() -> TestResult {
330        let error = sample_assertion()
331            .with_context_frame(ContextFrame::new("creating user"))
332            .with_context_frame(ContextFrame::new("loading profile"));
333        let rendered = error.to_string();
334        let first = rendered
335            .find("creating user")
336            .or_fail_with("first frame present")?;
337        let second = rendered
338            .find("loading profile")
339            .or_fail_with("second frame present")?;
340        check!(first < second).satisfies(is_true()).or_fail()?;
341        Ok(())
342    }
343
344    #[test]
345    fn error_source_walks_into_payload_other() -> TestResult {
346        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
347        let error = TestError::new(ErrorKind::Custom).with_payload(Payload::Other(Box::new(io)));
348        let source =
349            std::error::Error::source(&error).or_fail_with("source is the wrapped io error")?;
350        check!(source.to_string())
351            .satisfies(eq("missing file".to_string()))
352            .or_fail()?;
353        Ok(())
354    }
355
356    #[test]
357    fn expected_actual_payload_renders_both_values() -> TestResult {
358        let error = TestError::new(ErrorKind::Assertion).with_payload(Payload::ExpectedActual {
359            expected: "4".to_string(),
360            actual: "5".to_string(),
361            diff: None,
362        });
363        let rendered = error.to_string();
364        check!(rendered.contains("expected: 4"))
365            .satisfies(is_true())
366            .or_fail()?;
367        check!(rendered.contains("actual: 5"))
368            .satisfies(is_true())
369            .or_fail()?;
370        Ok(())
371    }
372
373    #[test]
374    fn multiple_payload_renders_every_sub_failure() -> TestResult {
375        let error = TestError::new(ErrorKind::Assertion).with_payload(Payload::Multiple(vec![
376            TestError::new(ErrorKind::Assertion).with_message("first"),
377            TestError::new(ErrorKind::Assertion).with_message("second"),
378        ]));
379        let rendered = error.to_string();
380        check!(rendered.contains("first"))
381            .satisfies(is_true())
382            .or_fail()?;
383        check!(rendered.contains("second"))
384            .satisfies(is_true())
385            .or_fail()?;
386        Ok(())
387    }
388
389    #[test]
390    fn assertion_constructor_sets_kind_message_and_caller_location() -> TestResult {
391        let line = line!() + 1;
392        let error = TestError::assertion("values differ");
393        check!(error.kind)
394            .satisfies(eq(ErrorKind::Assertion))
395            .or_fail()?;
396        check!(error.message.as_deref())
397            .satisfies(eq(Some("values differ")))
398            .or_fail()?;
399        check!(error.location.line())
400            .satisfies(eq(line))
401            .or_fail()?;
402        check!(error.location.file().ends_with("error.rs"))
403            .satisfies(is_true())
404            .or_fail()?;
405        Ok(())
406    }
407
408    #[test]
409    fn custom_constructor_sets_kind_message_and_caller_location() -> TestResult {
410        let line = line!() + 1;
411        let error = TestError::custom("something off");
412        check!(error.kind)
413            .satisfies(eq(ErrorKind::Custom))
414            .or_fail()?;
415        check!(error.message.as_deref())
416            .satisfies(eq(Some("something off")))
417            .or_fail()?;
418        check!(error.location.line())
419            .satisfies(eq(line))
420            .or_fail()?;
421        check!(error.location.file().ends_with("error.rs"))
422            .satisfies(is_true())
423            .or_fail()?;
424        Ok(())
425    }
426
427    #[test]
428    fn from_expected_actual_captures_debug_values_and_caller_location() -> TestResult {
429        let line = line!() + 1;
430        let error = TestError::from_expected_actual(4, 5);
431        check!(error.kind)
432            .satisfies(eq(ErrorKind::Assertion))
433            .or_fail()?;
434        check!(error.location.line())
435            .satisfies(eq(line))
436            .or_fail()?;
437        match error.payload.map(|payload| *payload) {
438            Some(Payload::ExpectedActual {
439                expected,
440                actual,
441                diff,
442            }) => {
443                check!(expected).satisfies(eq("4".to_string())).or_fail()?;
444                check!(actual).satisfies(eq("5".to_string())).or_fail()?;
445                check!(diff.is_none()).satisfies(is_true()).or_fail()?;
446            }
447            other => panic!("expected ExpectedActual, got {other:?}"),
448        }
449        Ok(())
450    }
451}