Skip to main content

test_better_core/
structured.rs

1//! The structured (plain-data) form of a [`TestError`].
2//!
3//! [`TestError`] holds borrowed data (`&'static Location`, `Cow<'static, str>`)
4//! and a non-cloneable `Box<dyn Error>` payload, which makes it awkward to
5//! serialize, compare, or send across a process boundary. [`StructuredError`]
6//! is its fully-owned, `PartialEq`, optionally-`serde` mirror.
7//!
8//! This is the form tooling and the runner consume: no consumer ever
9//! recovers structure by parsing rendered text.
10
11use std::panic::Location;
12
13use crate::error::{ErrorKind, Payload, TestError};
14use crate::trace::TraceEntry;
15
16/// A source location, owned and serializable (the plain-data form of
17/// [`std::panic::Location`]).
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct SourceLocation {
21    /// Source file path, as reported by the compiler.
22    pub file: String,
23    /// 1-based line number.
24    pub line: u32,
25    /// 1-based column number.
26    pub column: u32,
27}
28
29impl SourceLocation {
30    fn from_std(location: &Location<'_>) -> Self {
31        Self {
32            file: location.file().to_string(),
33            line: location.line(),
34            column: location.column(),
35        }
36    }
37}
38
39/// The plain-data form of [`crate::ContextFrame`].
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct StructuredContextFrame {
43    /// The "while doing X" description.
44    pub message: String,
45    /// Where the frame was attached, when known.
46    pub location: Option<SourceLocation>,
47}
48
49/// The plain-data form of [`Payload`].
50///
51/// [`Payload::Other`] holds a `Box<dyn Error>`, which cannot be serialized; it
52/// is flattened here into its `Display` string plus its source chain.
53#[derive(Debug, Clone, PartialEq, Eq)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
55pub enum StructuredPayload {
56    /// Mirrors [`Payload::ExpectedActual`].
57    ExpectedActual {
58        /// `Debug`-rendered expected value.
59        expected: String,
60        /// `Debug`-rendered actual value.
61        actual: String,
62        /// Optional pre-rendered diff between the two.
63        diff: Option<String>,
64    },
65    /// Mirrors [`Payload::Multiple`].
66    Multiple(Vec<StructuredError>),
67    /// Mirrors [`Payload::Other`], flattened to strings.
68    Other {
69        /// `Display` of the wrapped error.
70        message: String,
71        /// `Display` of each error in the wrapped error's source chain.
72        chain: Vec<String>,
73    },
74}
75
76impl StructuredPayload {
77    fn from_payload(payload: &Payload) -> Self {
78        match payload {
79            Payload::ExpectedActual {
80                expected,
81                actual,
82                diff,
83            } => StructuredPayload::ExpectedActual {
84                expected: expected.clone(),
85                actual: actual.clone(),
86                diff: diff.clone(),
87            },
88            Payload::Multiple(errors) => {
89                StructuredPayload::Multiple(errors.iter().map(TestError::to_structured).collect())
90            }
91            Payload::Other(inner) => {
92                let mut chain = Vec::new();
93                let mut source = inner.source();
94                while let Some(current) = source {
95                    chain.push(current.to_string());
96                    source = current.source();
97                }
98                StructuredPayload::Other {
99                    message: inner.to_string(),
100                    chain,
101                }
102            }
103        }
104    }
105}
106
107/// The plain-data, owned, serializable mirror of [`TestError`].
108#[derive(Debug, Clone, PartialEq, Eq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub struct StructuredError {
111    /// The failure category.
112    pub kind: ErrorKind,
113    /// What failed, when stated concisely.
114    pub message: Option<String>,
115    /// Where the failure originated.
116    pub location: SourceLocation,
117    /// The context chain, outermost first.
118    pub context: Vec<StructuredContextFrame>,
119    /// The in-test breadcrumbs active when the error was built, oldest first.
120    /// [`TraceEntry`] is already plain data, so it is its own structured form.
121    pub trace: Vec<TraceEntry>,
122    /// Structured detail, when applicable.
123    pub payload: Option<StructuredPayload>,
124}
125
126impl TestError {
127    /// Converts this error into its structured, owned, serializable form.
128    ///
129    /// This is the boundary between `test-better` and any tooling that consumes
130    /// failures: tooling reads the structured form, never the rendered text.
131    #[must_use]
132    pub fn to_structured(&self) -> StructuredError {
133        StructuredError {
134            kind: self.kind,
135            message: self.message.as_ref().map(ToString::to_string),
136            location: SourceLocation::from_std(self.location),
137            context: self
138                .context
139                .iter()
140                .map(|frame| StructuredContextFrame {
141                    message: frame.message.to_string(),
142                    location: frame.location.map(SourceLocation::from_std),
143                })
144                .collect(),
145            trace: self.trace.clone(),
146            payload: self.payload.as_deref().map(StructuredPayload::from_payload),
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::error::ContextFrame;
155    use crate::{OrFail, TestResult, Trace};
156    use test_better_matchers::{check, eq, is_true};
157
158    fn all_kinds() -> [ErrorKind; 6] {
159        [
160            ErrorKind::Assertion,
161            ErrorKind::Setup,
162            ErrorKind::Timeout,
163            ErrorKind::Snapshot,
164            ErrorKind::Property,
165            ErrorKind::Custom,
166        ]
167    }
168
169    #[test]
170    fn every_kind_round_trips_through_structured() -> TestResult {
171        for kind in all_kinds() {
172            let error = TestError::new(kind).with_message("boom");
173            let structured = error.to_structured();
174            check!(structured.kind).satisfies(eq(kind)).or_fail()?;
175            check!(structured.message.as_deref())
176                .satisfies(eq(Some("boom")))
177                .or_fail()?;
178        }
179        Ok(())
180    }
181
182    #[test]
183    fn structured_captures_location_and_context() -> TestResult {
184        let error =
185            TestError::new(ErrorKind::Assertion).with_context_frame(ContextFrame::new("step one"));
186        let structured = error.to_structured();
187        check!(structured.context.len())
188            .satisfies(eq(1))
189            .or_fail()?;
190        check!(structured.context[0].message.as_str())
191            .satisfies(eq("step one"))
192            .or_fail()?;
193        check!(structured.location.file.ends_with("structured.rs"))
194            .satisfies(is_true())
195            .or_fail()?;
196        check!(structured.location.line > 0)
197            .satisfies(is_true())
198            .or_fail()?;
199        Ok(())
200    }
201
202    #[test]
203    fn structured_carries_the_trace() -> TestResult {
204        let mut trace = Trace::new();
205        trace.step("step one");
206        trace.kv("answer", 42);
207        let error = TestError::new(ErrorKind::Assertion);
208        drop(trace);
209
210        let structured = error.to_structured();
211        check!(structured.trace.len()).satisfies(eq(2)).or_fail()?;
212        check!(structured.trace[0].clone())
213            .satisfies(eq(TraceEntry::Step("step one".into())))
214            .or_fail()?;
215        Ok(())
216    }
217
218    #[test]
219    fn expected_actual_payload_round_trips() -> TestResult {
220        let error = TestError::new(ErrorKind::Assertion).with_payload(Payload::ExpectedActual {
221            expected: "1".to_string(),
222            actual: "2".to_string(),
223            diff: Some("- 1\n+ 2".to_string()),
224        });
225        match error.to_structured().payload {
226            Some(StructuredPayload::ExpectedActual {
227                expected,
228                actual,
229                diff,
230            }) => {
231                check!(expected).satisfies(eq("1".to_string())).or_fail()?;
232                check!(actual).satisfies(eq("2".to_string())).or_fail()?;
233                check!(diff.as_deref())
234                    .satisfies(eq(Some("- 1\n+ 2")))
235                    .or_fail()?;
236            }
237            other => panic!("expected ExpectedActual, got {other:?}"),
238        }
239        Ok(())
240    }
241
242    #[test]
243    fn multiple_payload_round_trips_recursively() -> TestResult {
244        let error = TestError::new(ErrorKind::Assertion).with_payload(Payload::Multiple(vec![
245            TestError::new(ErrorKind::Assertion).with_message("a"),
246            TestError::new(ErrorKind::Setup).with_message("b"),
247        ]));
248        match error.to_structured().payload {
249            Some(StructuredPayload::Multiple(subs)) => {
250                check!(subs.len()).satisfies(eq(2)).or_fail()?;
251                check!(subs[0].message.as_deref())
252                    .satisfies(eq(Some("a")))
253                    .or_fail()?;
254                check!(subs[1].kind)
255                    .satisfies(eq(ErrorKind::Setup))
256                    .or_fail()?;
257            }
258            other => panic!("expected Multiple, got {other:?}"),
259        }
260        Ok(())
261    }
262
263    #[test]
264    fn other_payload_flattens_error_chain() -> TestResult {
265        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
266        let error = TestError::new(ErrorKind::Custom).with_payload(Payload::Other(Box::new(io)));
267        match error.to_structured().payload {
268            Some(StructuredPayload::Other { message, chain }) => {
269                check!(message)
270                    .satisfies(eq("missing".to_string()))
271                    .or_fail()?;
272                check!(chain.is_empty()).satisfies(is_true()).or_fail()?;
273            }
274            other => panic!("expected Other, got {other:?}"),
275        }
276        Ok(())
277    }
278
279    #[cfg(feature = "serde")]
280    #[test]
281    fn structured_error_json_round_trips() -> TestResult {
282        let error = TestError::new(ErrorKind::Property)
283            .with_message("shrunk input failed")
284            .with_context_frame(ContextFrame::new("checking the round-trip property"))
285            .with_payload(Payload::ExpectedActual {
286                expected: "Ok(\"x\")".to_string(),
287                actual: "Err(..)".to_string(),
288                diff: None,
289            });
290        let structured = error.to_structured();
291        let json = serde_json::to_string(&structured).or_fail_with("serialize")?;
292        let back: StructuredError = serde_json::from_str(&json).or_fail_with("deserialize")?;
293        check!(structured).satisfies(eq(back)).or_fail()?;
294        Ok(())
295    }
296}