Skip to main content

test_better_matchers/
soft.rs

1//! Soft assertions: [`soft`], [`SoftAsserter`], and [`SoftScope`].
2//!
3//! A normal assertion returns its `TestError` through `?`, so the first failure
4//! ends the test. [`soft`] opens a scope in which assertions are *recorded*
5//! rather than propagated; when the scope closes, every recorded failure is
6//! reported together under a single [`Payload::Multiple`], each sub-failure
7//! keeping its own location.
8//!
9//! [`SoftAsserter::context`] opens a sub-scope: failures recorded through the
10//! returned [`SoftScope`] carry an extra context frame, and nested sub-scopes
11//! stack their frames outermost-first.
12//!
13//! A panic inside the [`soft`] closure does not mask the failures recorded
14//! before it: [`soft`] runs the closure under [`catch_unwind`], reports the
15//! collected failures, and re-raises the panic afterward.
16//!
17//! [`catch_unwind`]: std::panic::catch_unwind
18
19use std::borrow::Cow;
20
21use test_better_core::{ContextFrame, ErrorKind, Payload, TestError, TestResult};
22
23use crate::matcher::Matcher;
24
25/// Runs `f` in a soft-assertion scope.
26///
27/// Inside `f`, failures recorded on the [`SoftAsserter`] do not end the
28/// closure. When `f` returns, `soft` returns `Ok(())` if nothing was recorded,
29/// or a single [`TestError`] collecting every recorded failure.
30///
31/// If `f` *panics*, the panic does not swallow what was already recorded:
32/// `soft` runs `f` under [`catch_unwind`](std::panic::catch_unwind), prints the
33/// collected soft failures to standard error, and then re-raises the panic so
34/// the test still fails on it.
35///
36/// ```
37/// use test_better_core::TestResult;
38/// use test_better_matchers::{eq, soft};
39///
40/// fn main() -> TestResult {
41///     soft(|s| {
42///         s.check(&2, eq(2));
43///         s.check(&"alice", eq("alice"));
44///     })?;
45///     Ok(())
46/// }
47/// ```
48#[track_caller]
49pub fn soft<F>(f: F) -> TestResult
50where
51    F: FnOnce(&mut SoftAsserter),
52{
53    let mut asserter = SoftAsserter::new();
54
55    // `f` captures `&mut asserter`, and `&mut T` is not `UnwindSafe`. Asserting
56    // unwind-safety is sound here: the only state `f` mutates is
57    // `asserter.errors` and `asserter.context`, both plain `Vec`s. A panic can
58    // leave them partially populated, but a partially-filled `Vec` is still a
59    // valid, fully-readable value — there is no torn invariant for the code
60    // after the `catch_unwind` to observe.
61    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut asserter)));
62
63    let result = asserter.into_result();
64
65    match outcome {
66        Ok(()) => result,
67        Err(panic) => {
68            // A panic cut the closure short. Surface the failures recorded
69            // before it — otherwise the panic would mask them — then re-raise
70            // so the test still fails on the panic itself.
71            if let Err(ref soft_failures) = result {
72                eprintln!("soft assertions recorded before the panic:\n{soft_failures}");
73            }
74            std::panic::resume_unwind(panic);
75        }
76    }
77}
78
79/// The recorder passed to a [`soft`] closure.
80///
81/// Every `check`/`record` that fails is pushed onto an internal list instead of
82/// returning early; [`soft`] turns that list into one [`TestError`] on scope
83/// exit. Callers rarely construct or name this type directly: it arrives as the
84/// argument of the [`soft`] closure.
85#[derive(Default)]
86pub struct SoftAsserter {
87    errors: Vec<TestError>,
88    /// The context frames currently in effect, outermost first. Pushed by
89    /// [`SoftAsserter::context`] and popped when the returned [`SoftScope`] is
90    /// dropped.
91    context: Vec<ContextFrame>,
92}
93
94impl SoftAsserter {
95    /// Creates an empty recorder. Most callers use [`soft`] rather than
96    /// constructing this directly.
97    #[must_use]
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Records whether `actual` satisfies `matcher`. A miss is collected, not
103    /// propagated, so the closure keeps running.
104    ///
105    /// The recorded failure captures *this* call site, so each soft failure
106    /// reports the line it came from. The soft counterpart of
107    /// [`Subject::satisfies`](crate::Subject::satisfies).
108    #[track_caller]
109    pub fn check<T, M>(&mut self, actual: &T, matcher: M)
110    where
111        T: ?Sized,
112        M: Matcher<T>,
113    {
114        if let Some(mismatch) = matcher.check(actual).failure {
115            self.collect(TestError::new(ErrorKind::Assertion).with_payload(
116                Payload::ExpectedActual {
117                    expected: mismatch.expected.to_string(),
118                    actual: mismatch.actual,
119                    diff: mismatch.diff,
120                },
121            ));
122        }
123    }
124
125    /// Records the result of an arbitrary fallible step. An `Err` is collected
126    /// with its original location and context intact; an `Ok` is ignored.
127    #[track_caller]
128    pub fn record(&mut self, result: TestResult) {
129        if let Err(error) = result {
130            self.collect(error);
131        }
132    }
133
134    /// Opens a context sub-scope. Failures recorded through the returned
135    /// [`SoftScope`] carry `message` as a context frame; the frame is removed
136    /// when the `SoftScope` is dropped. Sub-scopes nest: a `SoftScope` can open
137    /// further sub-scopes, and their frames stack outermost-first.
138    #[track_caller]
139    pub fn context(&mut self, message: impl Into<Cow<'static, str>>) -> SoftScope<'_> {
140        self.context.push(ContextFrame::new(message));
141        SoftScope { asserter: self }
142    }
143
144    /// Collects a failure, wrapping it in the context frames currently in
145    /// effect. The scope frames are the *outer* circumstance, so they precede
146    /// the error's own frames, which `TestError` already orders outermost-first.
147    fn collect(&mut self, mut error: TestError) {
148        if !self.context.is_empty() {
149            let mut frames = self.context.clone();
150            frames.append(&mut error.context);
151            error.context = frames;
152        }
153        self.errors.push(error);
154    }
155
156    /// Consumes the recorder, folding the collected failures into one result:
157    /// `Ok(())` when nothing was recorded, otherwise a single [`TestError`]
158    /// whose [`Payload::Multiple`] holds every failure.
159    #[track_caller]
160    fn into_result(self) -> TestResult {
161        if self.errors.is_empty() {
162            return Ok(());
163        }
164        let count = self.errors.len();
165        let noun = if count == 1 {
166            "soft assertion"
167        } else {
168            "soft assertions"
169        };
170        Err(TestError::new(ErrorKind::Assertion)
171            .with_message(format!("{count} {noun} failed"))
172            .with_payload(Payload::Multiple(self.errors)))
173    }
174}
175
176/// A context sub-scope of a [`SoftAsserter`], returned by
177/// [`SoftAsserter::context`].
178///
179/// Recording through a `SoftScope` behaves exactly like recording through the
180/// underlying [`SoftAsserter`], except every recorded failure also carries the
181/// scope's context frame (and the frames of any enclosing scopes). Dropping the
182/// `SoftScope` removes its frame, so the context applies only to failures
183/// recorded *while the scope is alive*.
184pub struct SoftScope<'a> {
185    asserter: &'a mut SoftAsserter,
186}
187
188impl SoftScope<'_> {
189    /// Records whether `actual` satisfies `matcher`, attaching this scope's
190    /// context to a miss. See [`SoftAsserter::check`].
191    #[track_caller]
192    pub fn check<T, M>(&mut self, actual: &T, matcher: M)
193    where
194        T: ?Sized,
195        M: Matcher<T>,
196    {
197        self.asserter.check(actual, matcher);
198    }
199
200    /// Records the result of a fallible step, attaching this scope's context to
201    /// an `Err`. See [`SoftAsserter::record`].
202    #[track_caller]
203    pub fn record(&mut self, result: TestResult) {
204        self.asserter.record(result);
205    }
206
207    /// Opens a nested context sub-scope. Its frame stacks *under* this scope's,
208    /// so failures recorded through it carry both. See
209    /// [`SoftAsserter::context`].
210    #[track_caller]
211    pub fn context(&mut self, message: impl Into<Cow<'static, str>>) -> SoftScope<'_> {
212        self.asserter.context(message)
213    }
214}
215
216impl Drop for SoftScope<'_> {
217    fn drop(&mut self) {
218        self.asserter.context.pop();
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use test_better_core::{Payload, TestError, TestResult};
225
226    use super::*;
227    use crate::{check, contains_str, eq, is_true};
228
229    #[test]
230    fn soft_with_no_failures_returns_ok() -> TestResult {
231        let result = soft(|s| {
232            s.check(&2, eq(2));
233            s.record(Ok(()));
234        });
235        check!(result.is_ok()).satisfies(is_true())?;
236        Ok(())
237    }
238
239    #[test]
240    fn soft_collects_every_failure_each_with_its_own_location() -> TestResult {
241        let result = soft(|s| {
242            s.check(&1, eq(2));
243            s.check(&3, eq(4));
244            s.check(&5, eq(6));
245        });
246        let error = result.expect_err("three soft assertions failed");
247
248        let rendered = error.to_string();
249        check!(rendered.contains("3 soft assertions failed")).satisfies(is_true())?;
250        check!(rendered.contains("3 failures")).satisfies(is_true())?;
251
252        match error.payload.as_deref() {
253            Some(Payload::Multiple(errors)) => {
254                check!(errors.len()).satisfies(eq(3))?;
255                // The three `check` calls are on consecutive lines, so the
256                // captured locations are all distinct.
257                let lines: Vec<u32> = errors.iter().map(|e| e.location.line()).collect();
258                check!(lines[0] != lines[1] && lines[1] != lines[2] && lines[0] != lines[2])
259                    .satisfies(is_true())?;
260            }
261            _ => return Err(TestError::assertion("expected a Multiple payload")),
262        }
263        Ok(())
264    }
265
266    #[test]
267    fn soft_check_records_an_err_and_ignores_ok() -> TestResult {
268        let result = soft(|s| {
269            s.record(Ok(()));
270            s.record(Err(TestError::assertion("a recorded failure")));
271        });
272        let error = result.expect_err("one recorded check failed");
273        check!(error.to_string().contains("a recorded failure")).satisfies(is_true())?;
274        Ok(())
275    }
276
277    #[test]
278    fn soft_check_preserves_the_recorded_error_location() -> TestResult {
279        let recorded = TestError::assertion("from elsewhere");
280        let recorded_line = recorded.location.line();
281        let result = soft(|s| s.record(Err(recorded)));
282        let error = result.expect_err("one recorded check failed");
283        match error.payload.as_deref() {
284            Some(Payload::Multiple(errors)) => {
285                check!(errors[0].location.line()).satisfies(eq(recorded_line))?;
286            }
287            _ => return Err(TestError::assertion("expected a Multiple payload")),
288        }
289        Ok(())
290    }
291
292    #[test]
293    fn context_scope_attaches_a_frame_to_recorded_failures() -> TestResult {
294        let result = soft(|s| {
295            let mut scope = s.context("while validating the user");
296            scope.check(&1, eq(2));
297        });
298        let error = result.expect_err("one soft assertion failed");
299        match error.payload.as_deref() {
300            Some(Payload::Multiple(errors)) => {
301                let frames: Vec<&str> = errors[0]
302                    .context
303                    .iter()
304                    .map(|frame| frame.message.as_ref())
305                    .collect();
306                check!(frames).satisfies(eq(vec!["while validating the user"]))?;
307            }
308            _ => return Err(TestError::assertion("expected a Multiple payload")),
309        }
310        Ok(())
311    }
312
313    #[test]
314    fn context_scope_ends_when_the_scope_is_dropped() -> TestResult {
315        let result = soft(|s| {
316            {
317                let mut scope = s.context("inside the scope");
318                scope.check(&1, eq(2));
319            }
320            // The scope has been dropped; this failure carries no context.
321            s.check(&3, eq(4));
322        });
323        let error = result.expect_err("two soft assertions failed");
324        match error.payload.as_deref() {
325            Some(Payload::Multiple(errors)) => {
326                check!(errors[0].context.len()).satisfies(eq(1usize))?;
327                check!(errors[1].context.len()).satisfies(eq(0usize))?;
328            }
329            _ => return Err(TestError::assertion("expected a Multiple payload")),
330        }
331        Ok(())
332    }
333
334    #[test]
335    fn nested_context_scopes_stack_outermost_first() -> TestResult {
336        let result = soft(|s| {
337            let mut outer = s.context("while validating the user");
338            outer.check(&1, eq(2));
339            let mut inner = outer.context("while checking the email");
340            inner.check(&"bad", contains_str("@"));
341        });
342        let error = result.expect_err("two soft assertions failed");
343        let rendered = error.to_string();
344        check!(rendered.contains("while validating the user")).satisfies(is_true())?;
345        check!(rendered.contains("while checking the email")).satisfies(is_true())?;
346
347        match error.payload.as_deref() {
348            Some(Payload::Multiple(errors)) => {
349                let outer_frames: Vec<&str> = errors[0]
350                    .context
351                    .iter()
352                    .map(|frame| frame.message.as_ref())
353                    .collect();
354                let inner_frames: Vec<&str> = errors[1]
355                    .context
356                    .iter()
357                    .map(|frame| frame.message.as_ref())
358                    .collect();
359                check!(outer_frames).satisfies(eq(vec!["while validating the user"]))?;
360                check!(inner_frames).satisfies(eq(vec![
361                    "while validating the user",
362                    "while checking the email",
363                ]))?;
364            }
365            _ => return Err(TestError::assertion("expected a Multiple payload")),
366        }
367        Ok(())
368    }
369}