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 `expect!` 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::{eq, expect, 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        expect!(error.location.line()).to(eq(line)).or_fail()?;
287        expect!(error.location.file().ends_with("error.rs"))
288            .to(is_true())
289            .or_fail()?;
290        Ok(())
291    }
292
293    #[test]
294    fn display_includes_headline_message_and_location() -> TestResult {
295        let rendered = sample_assertion().to_string();
296        expect!(rendered.contains("assertion failed: values differ"))
297            .to(is_true())
298            .or_fail()?;
299        expect!(rendered.contains("  at "))
300            .to(is_true())
301            .or_fail()?;
302        Ok(())
303    }
304
305    #[test]
306    fn debug_matches_display() -> TestResult {
307        // `Debug` may colorize off the global `ColorChoice`; hold the lock so a
308        // concurrent color test cannot flip it mid-render.
309        let _guard = crate::color::TEST_LOCK
310            .lock()
311            .unwrap_or_else(std::sync::PoisonError::into_inner);
312        let error = sample_assertion();
313        // `Debug` also appends the structured-output marker line when the
314        // runner is driving the run (`RUNNER_ENV` set); compare only the
315        // human-readable render, which is what `Display` produces.
316        let debug = format!("{error:?}");
317        let human = debug
318            .split_once(crate::STRUCTURED_MARKER)
319            .map_or(debug.as_str(), |(before, _)| before.trim_end());
320        expect!(human)
321            .to(eq(format!("{error}").as_str()))
322            .or_fail()?;
323        Ok(())
324    }
325
326    #[test]
327    fn context_frames_render_in_order() -> TestResult {
328        let error = sample_assertion()
329            .with_context_frame(ContextFrame::new("creating user"))
330            .with_context_frame(ContextFrame::new("loading profile"));
331        let rendered = error.to_string();
332        let first = rendered
333            .find("creating user")
334            .or_fail_with("first frame present")?;
335        let second = rendered
336            .find("loading profile")
337            .or_fail_with("second frame present")?;
338        expect!(first < second).to(is_true()).or_fail()?;
339        Ok(())
340    }
341
342    #[test]
343    fn error_source_walks_into_payload_other() -> TestResult {
344        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
345        let error = TestError::new(ErrorKind::Custom).with_payload(Payload::Other(Box::new(io)));
346        let source =
347            std::error::Error::source(&error).or_fail_with("source is the wrapped io error")?;
348        expect!(source.to_string())
349            .to(eq("missing file".to_string()))
350            .or_fail()?;
351        Ok(())
352    }
353
354    #[test]
355    fn expected_actual_payload_renders_both_values() -> TestResult {
356        let error = TestError::new(ErrorKind::Assertion).with_payload(Payload::ExpectedActual {
357            expected: "4".to_string(),
358            actual: "5".to_string(),
359            diff: None,
360        });
361        let rendered = error.to_string();
362        expect!(rendered.contains("expected: 4"))
363            .to(is_true())
364            .or_fail()?;
365        expect!(rendered.contains("actual: 5"))
366            .to(is_true())
367            .or_fail()?;
368        Ok(())
369    }
370
371    #[test]
372    fn multiple_payload_renders_every_sub_failure() -> TestResult {
373        let error = TestError::new(ErrorKind::Assertion).with_payload(Payload::Multiple(vec![
374            TestError::new(ErrorKind::Assertion).with_message("first"),
375            TestError::new(ErrorKind::Assertion).with_message("second"),
376        ]));
377        let rendered = error.to_string();
378        expect!(rendered.contains("first"))
379            .to(is_true())
380            .or_fail()?;
381        expect!(rendered.contains("second"))
382            .to(is_true())
383            .or_fail()?;
384        Ok(())
385    }
386
387    #[test]
388    fn assertion_constructor_sets_kind_message_and_caller_location() -> TestResult {
389        let line = line!() + 1;
390        let error = TestError::assertion("values differ");
391        expect!(error.kind).to(eq(ErrorKind::Assertion)).or_fail()?;
392        expect!(error.message.as_deref())
393            .to(eq(Some("values differ")))
394            .or_fail()?;
395        expect!(error.location.line()).to(eq(line)).or_fail()?;
396        expect!(error.location.file().ends_with("error.rs"))
397            .to(is_true())
398            .or_fail()?;
399        Ok(())
400    }
401
402    #[test]
403    fn custom_constructor_sets_kind_message_and_caller_location() -> TestResult {
404        let line = line!() + 1;
405        let error = TestError::custom("something off");
406        expect!(error.kind).to(eq(ErrorKind::Custom)).or_fail()?;
407        expect!(error.message.as_deref())
408            .to(eq(Some("something off")))
409            .or_fail()?;
410        expect!(error.location.line()).to(eq(line)).or_fail()?;
411        expect!(error.location.file().ends_with("error.rs"))
412            .to(is_true())
413            .or_fail()?;
414        Ok(())
415    }
416
417    #[test]
418    fn from_expected_actual_captures_debug_values_and_caller_location() -> TestResult {
419        let line = line!() + 1;
420        let error = TestError::from_expected_actual(4, 5);
421        expect!(error.kind).to(eq(ErrorKind::Assertion)).or_fail()?;
422        expect!(error.location.line()).to(eq(line)).or_fail()?;
423        match error.payload.map(|payload| *payload) {
424            Some(Payload::ExpectedActual {
425                expected,
426                actual,
427                diff,
428            }) => {
429                expect!(expected).to(eq("4".to_string())).or_fail()?;
430                expect!(actual).to(eq("5".to_string())).or_fail()?;
431                expect!(diff.is_none()).to(is_true()).or_fail()?;
432            }
433            other => panic!("expected ExpectedActual, got {other:?}"),
434        }
435        Ok(())
436    }
437}