Skip to main content

test_better_core/
trace.rs

1//! [`Trace`]: in-test breadcrumbs.
2//!
3//! A `Trace` records a chronological list of steps and key/value pairs while a
4//! test runs. The entries live in a thread-local for the trace's lifetime, so
5//! every [`TestError`](crate::TestError) built while the trace is in scope
6//! snapshots them automatically. A failure then renders the breadcrumbs that
7//! led up to it, in the order they happened, with no need to thread the trace
8//! value through the code under test.
9//!
10//! `cargo test` runs each test on its own thread, so a thread-local is per-test
11//! in practice. The one caveat is async: if a runtime moves a task across
12//! threads, a `TestError` constructed after the move snapshots the wrong
13//! thread's trace (usually an empty one). Keep a `Trace` within a single
14//! synchronous span, or within one async task that is not migrated.
15
16use std::borrow::Cow;
17use std::cell::RefCell;
18use std::fmt;
19
20/// One breadcrumb recorded on a [`Trace`].
21#[derive(Debug, Clone, PartialEq, Eq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[non_exhaustive]
24pub enum TraceEntry {
25    /// A narrative step, recorded by [`Trace::step`].
26    Step(Cow<'static, str>),
27    /// A key/value pair, recorded by [`Trace::kv`].
28    Kv {
29        /// The key.
30        key: Cow<'static, str>,
31        /// The value, rendered to text when the breadcrumb was recorded.
32        value: String,
33    },
34}
35
36thread_local! {
37    /// The active trace's entries for the current thread, or `None` when no
38    /// `Trace` is in scope.
39    static ACTIVE: RefCell<Option<Vec<TraceEntry>>> = const { RefCell::new(None) };
40}
41
42/// A scoped collector of in-test breadcrumbs.
43///
44/// Construct one at the top of a test; every [`TestError`](crate::TestError)
45/// built before it is dropped carries a snapshot of the breadcrumbs recorded so
46/// far, and renders them in the failure output.
47///
48/// ```
49/// use test_better_core::Trace;
50///
51/// let mut trace = Trace::new();
52/// trace.step("connecting to db");
53/// trace.kv("db_url", "postgres://localhost/test");
54/// trace.step("running the query");
55/// // If an assertion fails here, these three breadcrumbs are attached to the
56/// // resulting `TestError` and shown, in order, in the rendered failure.
57/// ```
58///
59/// Dropping the `Trace` ends the scope. Nested traces compose: an inner
60/// `Trace::new()` displaces the outer trace's entries and restores them on
61/// drop, so the outer trace resumes intact.
62pub struct Trace {
63    /// The thread-local entries displaced by this `Trace`, restored on drop.
64    /// `None` is the common case: no outer trace was in scope.
65    previous: Option<Vec<TraceEntry>>,
66}
67
68impl Trace {
69    /// Starts a trace, collecting breadcrumbs until it is dropped.
70    #[must_use]
71    pub fn new() -> Self {
72        let previous = ACTIVE.with(|cell| cell.borrow_mut().replace(Vec::new()));
73        Self { previous }
74    }
75
76    /// Records a narrative step.
77    pub fn step(&mut self, message: impl Into<Cow<'static, str>>) {
78        let entry = TraceEntry::Step(message.into());
79        ACTIVE.with(|cell| {
80            if let Some(entries) = cell.borrow_mut().as_mut() {
81                entries.push(entry);
82            }
83        });
84    }
85
86    /// Records a key/value breadcrumb, rendering `value` with [`Display`] now,
87    /// so the breadcrumb is not tied to the value's lifetime.
88    ///
89    /// [`Display`]: std::fmt::Display
90    pub fn kv(&mut self, key: impl Into<Cow<'static, str>>, value: impl fmt::Display) {
91        let entry = TraceEntry::Kv {
92            key: key.into(),
93            value: value.to_string(),
94        };
95        ACTIVE.with(|cell| {
96            if let Some(entries) = cell.borrow_mut().as_mut() {
97                entries.push(entry);
98            }
99        });
100    }
101
102    /// The breadcrumbs recorded in the active trace so far, oldest first.
103    #[must_use]
104    pub fn entries(&self) -> Vec<TraceEntry> {
105        snapshot()
106    }
107}
108
109impl Default for Trace {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl Drop for Trace {
116    fn drop(&mut self) {
117        ACTIVE.with(|cell| *cell.borrow_mut() = self.previous.take());
118    }
119}
120
121/// Snapshots the active thread's trace entries, for [`TestError`] construction.
122/// Empty when no `Trace` is in scope.
123///
124/// [`TestError`]: crate::TestError
125pub(crate) fn snapshot() -> Vec<TraceEntry> {
126    ACTIVE.with(|cell| cell.borrow().clone().unwrap_or_default())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::{ErrorKind, OrFail, TestError, TestResult};
133    use test_better_matchers::{check, eq, is_true};
134
135    #[test]
136    fn steps_and_kv_are_recorded_in_order() -> TestResult {
137        let mut trace = Trace::new();
138        trace.step("first");
139        trace.kv("key", 42);
140        trace.step("second");
141        let entries = trace.entries();
142        check!(entries.len()).satisfies(eq(3)).or_fail()?;
143        check!(entries[0].clone())
144            .satisfies(eq(TraceEntry::Step("first".into())))
145            .or_fail()?;
146        check!(entries[1].clone())
147            .satisfies(eq(TraceEntry::Kv {
148                key: "key".into(),
149                value: "42".to_string(),
150            }))
151            .or_fail()?;
152        check!(entries[2].clone())
153            .satisfies(eq(TraceEntry::Step("second".into())))
154            .or_fail()?;
155        Ok(())
156    }
157
158    #[test]
159    fn an_error_built_within_a_trace_snapshots_it() -> TestResult {
160        let mut trace = Trace::new();
161        trace.step("doing the thing");
162        let error = TestError::new(ErrorKind::Assertion);
163        check!(error.trace.len()).satisfies(eq(1)).or_fail()?;
164        check!(error.trace[0].clone())
165            .satisfies(eq(TraceEntry::Step("doing the thing".into())))
166            .or_fail()?;
167        Ok(())
168    }
169
170    #[test]
171    fn an_error_built_with_no_trace_in_scope_has_an_empty_trace() -> TestResult {
172        let error = TestError::new(ErrorKind::Assertion);
173        check!(error.trace.is_empty())
174            .satisfies(is_true())
175            .or_fail()?;
176        Ok(())
177    }
178
179    #[test]
180    fn dropping_a_trace_ends_the_scope() -> TestResult {
181        {
182            let mut trace = Trace::new();
183            trace.step("inside the scope");
184        }
185        // The trace is dropped; a later error captures nothing.
186        let error = TestError::new(ErrorKind::Assertion);
187        check!(error.trace.is_empty())
188            .satisfies(is_true())
189            .or_fail()?;
190        Ok(())
191    }
192
193    #[test]
194    fn nested_traces_compose_and_restore() -> TestResult {
195        let mut outer = Trace::new();
196        outer.step("outer step");
197        {
198            let mut inner = Trace::new();
199            inner.step("inner step");
200            check!(inner.entries().len()).satisfies(eq(1)).or_fail()?;
201        }
202        // The inner trace is gone; the outer trace's entry is back.
203        let entries = outer.entries();
204        check!(entries.len()).satisfies(eq(1)).or_fail()?;
205        check!(entries[0].clone())
206            .satisfies(eq(TraceEntry::Step("outer step".into())))
207            .or_fail()?;
208        Ok(())
209    }
210}