test_better_matchers/
soft.rs1use std::borrow::Cow;
20
21use test_better_core::{ContextFrame, ErrorKind, Payload, TestError, TestResult};
22
23use crate::matcher::Matcher;
24
25#[track_caller]
49pub fn soft<F>(f: F) -> TestResult
50where
51 F: FnOnce(&mut SoftAsserter),
52{
53 let mut asserter = SoftAsserter::new();
54
55 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 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#[derive(Default)]
86pub struct SoftAsserter {
87 errors: Vec<TestError>,
88 context: Vec<ContextFrame>,
92}
93
94impl SoftAsserter {
95 #[must_use]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 #[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 #[track_caller]
128 pub fn record(&mut self, result: TestResult) {
129 if let Err(error) = result {
130 self.collect(error);
131 }
132 }
133
134 #[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 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 #[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
176pub struct SoftScope<'a> {
185 asserter: &'a mut SoftAsserter,
186}
187
188impl SoftScope<'_> {
189 #[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 #[track_caller]
203 pub fn record(&mut self, result: TestResult) {
204 self.asserter.record(result);
205 }
206
207 #[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 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 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}