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.expect(&2, eq(2));
43///         s.expect(&"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 `expect`/`check` 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.
107    #[track_caller]
108    pub fn expect<T, M>(&mut self, actual: &T, matcher: M)
109    where
110        T: ?Sized,
111        M: Matcher<T>,
112    {
113        if let Some(mismatch) = matcher.check(actual).failure {
114            self.record(TestError::new(ErrorKind::Assertion).with_payload(
115                Payload::ExpectedActual {
116                    expected: mismatch.expected.to_string(),
117                    actual: mismatch.actual,
118                    diff: mismatch.diff,
119                },
120            ));
121        }
122    }
123
124    /// Records the result of an arbitrary fallible step. An `Err` is collected
125    /// with its original location and context intact; an `Ok` is ignored.
126    #[track_caller]
127    pub fn check(&mut self, result: TestResult) {
128        if let Err(error) = result {
129            self.record(error);
130        }
131    }
132
133    /// Opens a context sub-scope. Failures recorded through the returned
134    /// [`SoftScope`] carry `message` as a context frame; the frame is removed
135    /// when the `SoftScope` is dropped. Sub-scopes nest: a `SoftScope` can open
136    /// further sub-scopes, and their frames stack outermost-first.
137    #[track_caller]
138    pub fn context(&mut self, message: impl Into<Cow<'static, str>>) -> SoftScope<'_> {
139        self.context.push(ContextFrame::new(message));
140        SoftScope { asserter: self }
141    }
142
143    /// Collects a failure, wrapping it in the context frames currently in
144    /// effect. The scope frames are the *outer* circumstance, so they precede
145    /// the error's own frames, which `TestError` already orders outermost-first.
146    fn record(&mut self, mut error: TestError) {
147        if !self.context.is_empty() {
148            let mut frames = self.context.clone();
149            frames.append(&mut error.context);
150            error.context = frames;
151        }
152        self.errors.push(error);
153    }
154
155    /// Consumes the recorder, folding the collected failures into one result:
156    /// `Ok(())` when nothing was recorded, otherwise a single [`TestError`]
157    /// whose [`Payload::Multiple`] holds every failure.
158    #[track_caller]
159    fn into_result(self) -> TestResult {
160        if self.errors.is_empty() {
161            return Ok(());
162        }
163        let count = self.errors.len();
164        let noun = if count == 1 {
165            "soft assertion"
166        } else {
167            "soft assertions"
168        };
169        Err(TestError::new(ErrorKind::Assertion)
170            .with_message(format!("{count} {noun} failed"))
171            .with_payload(Payload::Multiple(self.errors)))
172    }
173}
174
175/// A context sub-scope of a [`SoftAsserter`], returned by
176/// [`SoftAsserter::context`].
177///
178/// Recording through a `SoftScope` behaves exactly like recording through the
179/// underlying [`SoftAsserter`], except every recorded failure also carries the
180/// scope's context frame (and the frames of any enclosing scopes). Dropping the
181/// `SoftScope` removes its frame, so the context applies only to failures
182/// recorded *while the scope is alive*.
183pub struct SoftScope<'a> {
184    asserter: &'a mut SoftAsserter,
185}
186
187impl SoftScope<'_> {
188    /// Records whether `actual` satisfies `matcher`, attaching this scope's
189    /// context to a miss. See [`SoftAsserter::expect`].
190    #[track_caller]
191    pub fn expect<T, M>(&mut self, actual: &T, matcher: M)
192    where
193        T: ?Sized,
194        M: Matcher<T>,
195    {
196        self.asserter.expect(actual, matcher);
197    }
198
199    /// Records the result of a fallible step, attaching this scope's context to
200    /// an `Err`. See [`SoftAsserter::check`].
201    #[track_caller]
202    pub fn check(&mut self, result: TestResult) {
203        self.asserter.check(result);
204    }
205
206    /// Opens a nested context sub-scope. Its frame stacks *under* this scope's,
207    /// so failures recorded through it carry both. See
208    /// [`SoftAsserter::context`].
209    #[track_caller]
210    pub fn context(&mut self, message: impl Into<Cow<'static, str>>) -> SoftScope<'_> {
211        self.asserter.context(message)
212    }
213}
214
215impl Drop for SoftScope<'_> {
216    fn drop(&mut self) {
217        self.asserter.context.pop();
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use test_better_core::{Payload, TestError, TestResult};
224
225    use super::*;
226    use crate::{contains_str, eq, expect, is_true};
227
228    #[test]
229    fn soft_with_no_failures_returns_ok() -> TestResult {
230        let result = soft(|s| {
231            s.expect(&2, eq(2));
232            s.check(Ok(()));
233        });
234        expect!(result.is_ok()).to(is_true())?;
235        Ok(())
236    }
237
238    #[test]
239    fn soft_collects_every_failure_each_with_its_own_location() -> TestResult {
240        let result = soft(|s| {
241            s.expect(&1, eq(2));
242            s.expect(&3, eq(4));
243            s.expect(&5, eq(6));
244        });
245        let error = result.expect_err("three soft assertions failed");
246
247        let rendered = error.to_string();
248        expect!(rendered.contains("3 soft assertions failed")).to(is_true())?;
249        expect!(rendered.contains("3 failures")).to(is_true())?;
250
251        match error.payload.as_deref() {
252            Some(Payload::Multiple(errors)) => {
253                expect!(errors.len()).to(eq(3))?;
254                // The three `expect` calls are on consecutive lines, so the
255                // captured locations are all distinct.
256                let lines: Vec<u32> = errors.iter().map(|e| e.location.line()).collect();
257                expect!(lines[0] != lines[1] && lines[1] != lines[2] && lines[0] != lines[2])
258                    .to(is_true())?;
259            }
260            _ => return Err(TestError::assertion("expected a Multiple payload")),
261        }
262        Ok(())
263    }
264
265    #[test]
266    fn soft_check_records_an_err_and_ignores_ok() -> TestResult {
267        let result = soft(|s| {
268            s.check(Ok(()));
269            s.check(Err(TestError::assertion("a recorded failure")));
270        });
271        let error = result.expect_err("one recorded check failed");
272        expect!(error.to_string().contains("a recorded failure")).to(is_true())?;
273        Ok(())
274    }
275
276    #[test]
277    fn soft_check_preserves_the_recorded_error_location() -> TestResult {
278        let recorded = TestError::assertion("from elsewhere");
279        let recorded_line = recorded.location.line();
280        let result = soft(|s| s.check(Err(recorded)));
281        let error = result.expect_err("one recorded check failed");
282        match error.payload.as_deref() {
283            Some(Payload::Multiple(errors)) => {
284                expect!(errors[0].location.line()).to(eq(recorded_line))?;
285            }
286            _ => return Err(TestError::assertion("expected a Multiple payload")),
287        }
288        Ok(())
289    }
290
291    #[test]
292    fn context_scope_attaches_a_frame_to_recorded_failures() -> TestResult {
293        let result = soft(|s| {
294            let mut scope = s.context("while validating the user");
295            scope.expect(&1, eq(2));
296        });
297        let error = result.expect_err("one soft assertion failed");
298        match error.payload.as_deref() {
299            Some(Payload::Multiple(errors)) => {
300                let frames: Vec<&str> = errors[0]
301                    .context
302                    .iter()
303                    .map(|frame| frame.message.as_ref())
304                    .collect();
305                expect!(frames).to(eq(vec!["while validating the user"]))?;
306            }
307            _ => return Err(TestError::assertion("expected a Multiple payload")),
308        }
309        Ok(())
310    }
311
312    #[test]
313    fn context_scope_ends_when_the_scope_is_dropped() -> TestResult {
314        let result = soft(|s| {
315            {
316                let mut scope = s.context("inside the scope");
317                scope.expect(&1, eq(2));
318            }
319            // The scope has been dropped; this failure carries no context.
320            s.expect(&3, eq(4));
321        });
322        let error = result.expect_err("two soft assertions failed");
323        match error.payload.as_deref() {
324            Some(Payload::Multiple(errors)) => {
325                expect!(errors[0].context.len()).to(eq(1usize))?;
326                expect!(errors[1].context.len()).to(eq(0usize))?;
327            }
328            _ => return Err(TestError::assertion("expected a Multiple payload")),
329        }
330        Ok(())
331    }
332
333    #[test]
334    fn nested_context_scopes_stack_outermost_first() -> TestResult {
335        let result = soft(|s| {
336            let mut outer = s.context("while validating the user");
337            outer.expect(&1, eq(2));
338            let mut inner = outer.context("while checking the email");
339            inner.expect(&"bad", contains_str("@"));
340        });
341        let error = result.expect_err("two soft assertions failed");
342        let rendered = error.to_string();
343        expect!(rendered.contains("while validating the user")).to(is_true())?;
344        expect!(rendered.contains("while checking the email")).to(is_true())?;
345
346        match error.payload.as_deref() {
347            Some(Payload::Multiple(errors)) => {
348                let outer_frames: Vec<&str> = errors[0]
349                    .context
350                    .iter()
351                    .map(|frame| frame.message.as_ref())
352                    .collect();
353                let inner_frames: Vec<&str> = errors[1]
354                    .context
355                    .iter()
356                    .map(|frame| frame.message.as_ref())
357                    .collect();
358                expect!(outer_frames).to(eq(vec!["while validating the user"]))?;
359                expect!(inner_frames).to(eq(vec![
360                    "while validating the user",
361                    "while checking the email",
362                ]))?;
363            }
364            _ => return Err(TestError::assertion("expected a Multiple payload")),
365        }
366        Ok(())
367    }
368}