[go: up one dir, main page]

tryfn/
lib.rs

1//! [`Harness`] for discovering test inputs and asserting against snapshot files
2//!
3//! This is a custom test harness and should be put in its own test binary with
4//! [`test.harness = false`](https://doc.rust-lang.org/stable/cargo/reference/cargo-targets.html#the-harness-field).
5//!
6//! # Examples
7//!
8//! ```rust,no_run
9//! fn some_func(num: usize) -> usize {
10//!     // ...
11//! #    10
12//! }
13//!
14//! tryfn::Harness::new(
15//!     "tests/fixtures/invalid",
16//!     setup,
17//!     test,
18//! )
19//! .select(["tests/cases/*.in"])
20//! .test();
21//!
22//! fn setup(input_path: std::path::PathBuf) -> tryfn::Case {
23//!     let name = input_path.file_name().unwrap().to_str().unwrap().to_owned();
24//!     let expected = tryfn::Data::read_from(&input_path.with_extension("out"), None);
25//!     tryfn::Case {
26//!         name,
27//!         fixture: input_path,
28//!         expected,
29//!     }
30//! }
31//!
32//! fn test(input_path: &std::path::Path) -> Result<usize, Box<dyn std::error::Error>> {
33//!     let raw = std::fs::read_to_string(input_path)?;
34//!     let num = raw.parse::<usize>()?;
35//!
36//!     let actual = some_func(num);
37//!
38//!     Ok(actual)
39//! }
40//! ```
41
42use libtest_mimic::Trial;
43
44pub use snapbox::data::DataFormat;
45pub use snapbox::Data;
46
47/// [`Harness`] for discovering test inputs and asserting against snapshot files
48pub struct Harness<S, T, I, E> {
49    root: std::path::PathBuf,
50    overrides: Option<ignore::overrides::Override>,
51    setup: S,
52    test: T,
53    config: snapbox::Assert,
54    test_output: std::marker::PhantomData<I>,
55    test_error: std::marker::PhantomData<E>,
56}
57
58impl<S, T, I, E> Harness<S, T, I, E>
59where
60    S: Setup + Send + Sync + 'static,
61    T: Test<I, E> + Clone + Send + Sync + 'static,
62    I: std::fmt::Display,
63    E: std::fmt::Display,
64{
65    /// Specify where the test scenarios
66    ///
67    /// - `input_root`: where to find the files.  See [`Self::select`] for restricting what files
68    ///   are considered
69    /// - `setup`: Given a path, choose the test name and the output location
70    /// - `test`: Given a path, return the actual output value
71    ///
72    /// By default filters are applied, including:
73    /// - `...` is a line-wildcard when on a line by itself
74    /// - `[..]` is a character-wildcard when inside a line
75    /// - `[EXE]` matches `.exe` on Windows
76    /// - `"{...}"` is a JSON value wildcard
77    /// - `"...": "{...}"` is a JSON key-value wildcard
78    /// - `\` to `/`
79    /// - Newlines
80    ///
81    /// To limit this to newline normalization for text, have [`Setup`] call [`Data::raw`][snapbox::Data::raw] on `expected`.
82    pub fn new(input_root: impl Into<std::path::PathBuf>, setup: S, test: T) -> Self {
83        Self {
84            root: input_root.into(),
85            overrides: None,
86            setup,
87            test,
88            config: snapbox::Assert::new().action_env(snapbox::assert::DEFAULT_ACTION_ENV),
89            test_output: Default::default(),
90            test_error: Default::default(),
91        }
92    }
93
94    /// Path patterns for selecting input files
95    ///
96    /// This uses gitignore syntax
97    pub fn select<'p>(mut self, patterns: impl IntoIterator<Item = &'p str>) -> Self {
98        let mut overrides = ignore::overrides::OverrideBuilder::new(&self.root);
99        for line in patterns {
100            overrides.add(line).unwrap();
101        }
102        self.overrides = Some(overrides.build().unwrap());
103        self
104    }
105
106    /// Customize the assertion behavior
107    ///
108    /// Includes
109    /// - Configuring redactions
110    /// - Override updating environment vaeiable
111    pub fn with_assert(mut self, config: snapbox::Assert) -> Self {
112        self.config = config;
113        self
114    }
115
116    /// Run tests
117    pub fn test(self) -> ! {
118        let mut walk = ignore::WalkBuilder::new(&self.root);
119        walk.standard_filters(false);
120        let tests = walk.build().filter_map(|entry| {
121            let entry = entry.unwrap();
122            let is_dir = entry.file_type().map(|f| f.is_dir()).unwrap_or(false);
123            let path = entry.into_path();
124            if let Some(overrides) = &self.overrides {
125                overrides
126                    .matched(&path, is_dir)
127                    .is_whitelist()
128                    .then_some(path)
129            } else {
130                Some(path)
131            }
132        });
133
134        let shared_config = std::sync::Arc::new(self.config);
135        let tests: Vec<_> = tests
136            .into_iter()
137            .map(|path| {
138                let case = self.setup.setup(path);
139                assert!(
140                    case.expected.source().map(|s| s.is_path()).unwrap_or(false),
141                    "`Case::expected` must be from a file"
142                );
143                let test = self.test.clone();
144                let config = shared_config.clone();
145                Trial::test(case.name.clone(), move || {
146                    let actual = test.run(&case.fixture)?;
147                    let actual = actual.to_string();
148                    let actual = snapbox::Data::text(actual);
149                    config.try_eq(Some(&case.name), actual, case.expected.clone())?;
150                    Ok(())
151                })
152                .with_ignored_flag(
153                    shared_config.selected_action() == snapbox::assert::Action::Ignore,
154                )
155            })
156            .collect();
157
158        let args = libtest_mimic::Arguments::from_args();
159        libtest_mimic::run(&args, tests).exit()
160    }
161}
162
163/// Function signature for generating a test [`Case`] from a path fixture
164pub trait Setup {
165    fn setup(&self, fixture: std::path::PathBuf) -> Case;
166}
167
168impl<F> Setup for F
169where
170    F: Fn(std::path::PathBuf) -> Case,
171{
172    fn setup(&self, fixture: std::path::PathBuf) -> Case {
173        (self)(fixture)
174    }
175}
176
177/// Function signature for running a test [`Case`]
178pub trait Test<S, E>
179where
180    S: std::fmt::Display,
181    E: std::fmt::Display,
182{
183    fn run(&self, fixture: &std::path::Path) -> Result<S, E>;
184}
185
186impl<F, S, E> Test<S, E> for F
187where
188    F: Fn(&std::path::Path) -> Result<S, E>,
189    S: std::fmt::Display,
190    E: std::fmt::Display,
191{
192    fn run(&self, fixture: &std::path::Path) -> Result<S, E> {
193        (self)(fixture)
194    }
195}
196
197/// A test case enumerated by the [`Harness`] with data from the [`Setup`] function
198pub struct Case {
199    /// Display name
200    pub name: String,
201    /// Input for the test
202    pub fixture: std::path::PathBuf,
203    /// What the actual output should be compared against or updated
204    ///
205    /// Generally derived from `fixture` and loaded with [`Data::read_from`]
206    pub expected: Data,
207}