Skip to main content

test_better_core/
context.rs

1//! [`ContextExt`]: attach "while doing X" context to a fallible value.
2//!
3//! `ContextExt` is what makes `?` carry a story. A bare `?` propagates a
4//! failure as-is; `.context("loading the fixture")?` propagates the same
5//! failure with a frame explaining what the test was attempting.
6//!
7//! When the error path already holds a [`TestError`], the context frame is
8//! pushed onto it directly: the original kind, location, and payload are kept,
9//! and the error is *not* re-wrapped as a [`Payload::Other`].
10
11use std::borrow::Cow;
12use std::error::Error;
13
14use crate::error::{ContextFrame, ErrorKind, Payload, TestError};
15use crate::result::TestResult;
16
17/// Attaches context to the failure path of a [`Result`] or the [`None`] of an
18/// [`Option`].
19pub trait ContextExt<T> {
20    /// Adds a context frame describing the operation that was being attempted.
21    ///
22    /// On the success path the value is returned unchanged.
23    fn context(self, message: impl Into<Cow<'static, str>>) -> TestResult<T>;
24
25    /// Like [`context`](ContextExt::context), but the message is computed by
26    /// `f`, which runs only on the failure path.
27    fn with_context<F, S>(self, f: F) -> TestResult<T>
28    where
29        F: FnOnce() -> S,
30        S: Into<Cow<'static, str>>;
31}
32
33/// Coerces an arbitrary error into a [`TestError`].
34///
35/// If `error` already *is* a `TestError` it is returned untouched (no
36/// double-wrapping); otherwise it becomes the [`Payload::Other`] of a fresh
37/// [`ErrorKind::Custom`] error, so its source chain stays walkable.
38#[track_caller]
39pub(crate) fn coerce<E>(error: E) -> TestError
40where
41    E: Error + Send + Sync + 'static,
42{
43    let boxed: Box<dyn Error + Send + Sync> = Box::new(error);
44    match boxed.downcast::<TestError>() {
45        Ok(test_error) => *test_error,
46        Err(other) => TestError::new(ErrorKind::Custom).with_payload(Payload::Other(other)),
47    }
48}
49
50/// The error produced when context is attached to a [`None`].
51#[track_caller]
52fn none_error() -> TestError {
53    TestError::new(ErrorKind::Custom).with_message("value was None")
54}
55
56impl<T, E> ContextExt<T> for Result<T, E>
57where
58    E: Error + Send + Sync + 'static,
59{
60    #[track_caller]
61    fn context(self, message: impl Into<Cow<'static, str>>) -> TestResult<T> {
62        match self {
63            Ok(value) => Ok(value),
64            Err(error) => Err(coerce(error).with_context_frame(ContextFrame::new(message))),
65        }
66    }
67
68    #[track_caller]
69    fn with_context<F, S>(self, f: F) -> TestResult<T>
70    where
71        F: FnOnce() -> S,
72        S: Into<Cow<'static, str>>,
73    {
74        match self {
75            Ok(value) => Ok(value),
76            Err(error) => Err(coerce(error).with_context_frame(ContextFrame::new(f()))),
77        }
78    }
79}
80
81impl<T> ContextExt<T> for Option<T> {
82    #[track_caller]
83    fn context(self, message: impl Into<Cow<'static, str>>) -> TestResult<T> {
84        match self {
85            Some(value) => Ok(value),
86            None => Err(none_error().with_context_frame(ContextFrame::new(message))),
87        }
88    }
89
90    #[track_caller]
91    fn with_context<F, S>(self, f: F) -> TestResult<T>
92    where
93        F: FnOnce() -> S,
94        S: Into<Cow<'static, str>>,
95    {
96        match self {
97            Some(value) => Ok(value),
98            None => Err(none_error().with_context_frame(ContextFrame::new(f()))),
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::{OrFail, TestResult};
107    use std::cell::Cell;
108    use test_better_matchers::{eq, check, is_true};
109
110    fn io_error() -> std::io::Error {
111        std::io::Error::new(std::io::ErrorKind::NotFound, "missing file")
112    }
113
114    #[test]
115    fn context_passes_through_ok() -> TestResult {
116        let value: TestResult<i32> = Ok::<i32, std::io::Error>(7).context("unused");
117        check!(value?).satisfies(eq(7)).or_fail()?;
118        Ok(())
119    }
120
121    #[test]
122    fn context_passes_through_some() -> TestResult {
123        let value: TestResult<i32> = Some(7).context("unused");
124        check!(value?).satisfies(eq(7)).or_fail()?;
125        Ok(())
126    }
127
128    #[test]
129    fn context_wraps_foreign_error_as_other_payload() -> TestResult {
130        let failing: Result<(), std::io::Error> = Err(io_error());
131        let line = line!() + 1;
132        let result = failing.context("reading the fixture");
133        let error = result.expect_err("err path");
134        check!(error.kind).satisfies(eq(ErrorKind::Custom)).or_fail()?;
135        check!(error.location.line()).satisfies(eq(line)).or_fail()?;
136        check!(matches!(error.payload.as_deref(), Some(Payload::Other(_))))
137            .satisfies(is_true())
138            .or_fail()?;
139        check!(error.context.len()).satisfies(eq(1)).or_fail()?;
140        check!(error.context[0].message.as_ref())
141            .satisfies(eq("reading the fixture"))
142            .or_fail()?;
143        Ok(())
144    }
145
146    #[test]
147    fn context_does_not_double_wrap_a_test_error() -> TestResult {
148        let original = TestError::assertion("values differ");
149        let original_line = original.location.line();
150        let error = Err::<(), _>(original)
151            .context("comparing the results")
152            .expect_err("err path");
153        // Kind, location, and the (absent) payload of the original are kept.
154        check!(error.kind).satisfies(eq(ErrorKind::Assertion)).or_fail()?;
155        check!(error.location.line())
156            .satisfies(eq(original_line))
157            .or_fail()?;
158        check!(error.payload.is_none()).satisfies(is_true()).or_fail()?;
159        check!(error.message.as_deref())
160            .satisfies(eq(Some("values differ")))
161            .or_fail()?;
162        check!(error.context.len()).satisfies(eq(1)).or_fail()?;
163        check!(error.context[0].message.as_ref())
164            .satisfies(eq("comparing the results"))
165            .or_fail()?;
166        Ok(())
167    }
168
169    #[test]
170    fn context_frames_accumulate_in_order() -> TestResult {
171        let error = Err::<(), _>(io_error())
172            .context("inner step")
173            .context("outer step")
174            .expect_err("err path");
175        let messages: Vec<_> = error.context.iter().map(|f| f.message.as_ref()).collect();
176        check!(messages)
177            .satisfies(eq(vec!["inner step", "outer step"]))
178            .or_fail()?;
179        Ok(())
180    }
181
182    #[test]
183    fn none_gains_context_and_caller_location() -> TestResult {
184        let missing: Option<i32> = None;
185        let line = line!() + 1;
186        let result = missing.context("looking up the user");
187        let error = result.expect_err("err path");
188        check!(error.kind).satisfies(eq(ErrorKind::Custom)).or_fail()?;
189        check!(error.location.line()).satisfies(eq(line)).or_fail()?;
190        check!(error.context[0].message.as_ref())
191            .satisfies(eq("looking up the user"))
192            .or_fail()?;
193        Ok(())
194    }
195
196    #[test]
197    fn with_context_runs_the_closure_only_on_failure() -> TestResult {
198        let calls = Cell::new(0);
199        let ok: TestResult<i32> = Ok::<i32, std::io::Error>(1).with_context(|| {
200            calls.set(calls.get() + 1);
201            "unused"
202        });
203        check!(ok?).satisfies(eq(1)).or_fail()?;
204        check!(calls.get()).satisfies(eq(0)).or_fail()?;
205
206        let err = Err::<(), _>(io_error())
207            .with_context(|| {
208                calls.set(calls.get() + 1);
209                "computed context"
210            })
211            .expect_err("err path");
212        check!(calls.get()).satisfies(eq(1)).or_fail()?;
213        check!(err.context[0].message.as_ref())
214            .satisfies(eq("computed context"))
215            .or_fail()?;
216        Ok(())
217    }
218}