subprocess_test/
lib.rs

1//! This crate exposes single utility macro `subprocess_test`
2//!
3//! Macro generates test function code in such a way that first test code block
4//! is executed in separate subprocess by re-invoking current test executable.
5//! Its output is captured, filtered a bit and then fed to verification function.
6//! Test decides whether it's in normal or subprocess mode through marker environment variable
7//!
8//! Used when one needs to either run some test in isolation or validate test output
9//! regardless of its proper completion, i.e. even if it aborts
10//!
11//! # Small examples
12//!
13//! ```rust
14//! subprocess_test::subprocess_test! {
15//!     #[test]
16//!     fn just_success() {
17//!         let value = 1;
18//!         assert_eq!(value + 1, 2);
19//!     }
20//! }
21//! ```
22//!
23//! ```rust
24//! subprocess_test::subprocess_test! {
25//!     #[test]
26//!     fn one_plus_one() {
27//!         println!("{}", 1 + 1);
28//!     }
29//!     verify |success, output| {
30//!         assert!(success);
31//!         assert_eq!(output, "2\n");
32//!     }
33//! }
34//! ```
35//!
36//! # Usage
37//!
38//! ```rust
39//! subprocess_test::subprocess_test! {
40//!     // Mandatory test marker attribute; parens are needed
41//!     // only if any attribute parameters are specified.
42//!     //
43//!     // Please also note that this attribute must be first,
44//!     // and its optional parameters must maintain order.
45//!     // This is due to limitations of Rust's macro-by-example.
46//!     #[test(     
47//!         // Optionally specify name of environment variable used to mark subprocess mode.
48//!         // Default name is "__TEST_RUN_SUBPROCESS__", so in very improbable case case
49//!         // you're getting name collision here, you can change it.
50//!         env_var_name = "RUN_SUBPROCESS_ENV_VAR",
51//!         // While subprocess is executed using `cargo test -q -- --nocapture`,
52//!         // there's still some output from test harness.
53//!         // To filter it out, test prints two boundary lines, in the beginning
54//!         // and in the end of test's output, regardless if it succeeds or panics.
55//!         // The default boundary line is "========================================",
56//!         // so in rare case you expect conflict with actual test output, you can use
57//!         // this parameter to set custom output boundary.
58//!         output_boundary = "<><><><><><><><>",
59//!     )]
60//!     // Any other attributes are allowed, yet are optional
61//!     #[ignore]
62//!     // Test can have any valid name, same as normal test function
63//!     fn dummy() {
64//!         // This block is intended to generate test output,
65//!         // although it can be used as normal test body
66//!         println!("Foo");
67//!         eprintln!("Bar");
68//!     }
69//!     // `verify` block is optional;
70//!     // if absent, it's substituted with block which just asserts that subprocess succeeded
71//!     // and prints test output in case of failure
72//!     //
73//!     // Parameters can be any names. Their meanings:
74//!     // * `success` - boolean which is `true` if subprocess succeeded
75//!     // * `output` - subprocess output collected into string, both `stdout` and `stderr`
76//!     verify |success, output| {
77//!         // This block is run as normal part of test and in general must succeed
78//!         assert!(success);
79//!         assert_eq!(output, "Foo\nBar\n");
80//!     }
81//! }
82//! ```
83//!
84//! # Limitations
85//!
86//! Macro doesn't work well with `#[should_panic]` attribute because there's only one test function
87//! which runs in two modes. If subprocess test panics as expected, subprocess succeeds, and
88//! `verify` block must panic too. Just use `verify` block and do any checks you need there.
89use std::borrow::Cow;
90use std::env::{args_os, var_os};
91use std::fs::File;
92use std::io::{Read, Seek, SeekFrom};
93use std::process::{Command, Stdio};
94
95use defer::defer;
96use tempfile::tempfile;
97/// Implementation of `subprocess_test` macro. See crate-level documentation for details and usage examples
98#[macro_export]
99macro_rules! subprocess_test {
100    (
101        $(
102            #[test $((
103                $(env_var_name = $subp_var_name:literal $(,)?)?
104                $(output_boundary = $subp_output_boundary:literal $(,)?)?
105            ))?]
106            $(#[$attrs:meta])*
107            fn $test_name:ident () $test_block:block
108            $(verify |$success_param:ident, $stdout_param:ident| $verify_block:block)?
109        )*
110    ) => {
111        $(
112            #[test]
113            $(#[$attrs])*
114            fn $test_name() {
115                // NB: adjust full path to runner function whenever this code is moved to other module
116                $crate::run_subprocess_test(
117                    concat!(module_path!(), "::", stringify!($test_name)),
118                    $crate::subprocess_test! {
119                        @tokens_or_default { $($(Some($subp_var_name))?)? }
120                        or { None }
121                    },
122                    $crate::subprocess_test! {
123                        @tokens_or_default { $($(Some($subp_output_boundary))?)? }
124                        or { None }
125                    },
126                    || $test_block,
127                    $crate::subprocess_test! {
128                        @tokens_or_default {
129                            $(|$success_param, $stdout_param| $verify_block)?
130                        } or {
131                            // NB: we inject closure here, to make panic report its location
132                            // at macro expansion
133                            |success, output| {
134                                if !success {
135                                    eprintln!("{output}");
136                                    // In case panic location will point to whole macro start,
137                                    // you'll get at least test name
138                                    panic!("Test {} subprocess failed", stringify!($test_name));
139                                }
140                            }
141                        }
142                    },
143                );
144            }
145        )*
146    };
147    (
148        @tokens_or_default { $($tokens:tt)+ } or { $($_:tt)* }
149    ) => {
150        $($tokens)+
151    };
152    (
153        @tokens_or_default { } or { $($tokens:tt)* }
154    ) => {
155        $($tokens)*
156    };
157}
158
159#[doc(hidden)]
160pub fn run_subprocess_test(
161    full_test_name: &str,
162    var_name: Option<&str>,
163    boundary: Option<&str>,
164    test_fn: impl FnOnce(),
165    verify_fn: impl FnOnce(bool, String),
166) {
167    const DEFAULT_SUBPROCESS_ENV_VAR_NAME: &str = "__TEST_RUN_SUBPROCESS__";
168    const DEFAULT_OUTPUT_BOUNDARY: &str = "\n========================================\n";
169
170    let full_test_name = &full_test_name[full_test_name
171        .find("::")
172        .expect("Full test path is expected to include crate name")
173        + 2..];
174    let var_name = var_name.unwrap_or(DEFAULT_SUBPROCESS_ENV_VAR_NAME);
175    let boundary: Cow<'static, str> = if let Some(boundary) = boundary {
176        format!("\n{boundary}\n").into()
177    } else {
178        DEFAULT_OUTPUT_BOUNDARY.into()
179    };
180    // If test phase is requested, execute it and bail immediately
181    if var_os(var_name).is_some() {
182        print!("{boundary}");
183        // We expect that in case of panic we'll get test harness footer,
184        // but in case of abort we won't get it, so finisher won't be needed
185        defer! { print!("{boundary}") };
186        test_fn();
187        return;
188    }
189    // Otherwise, perform main runner phase.
190    // Just run same executable but with different options
191    let (tmpfile, stdout, stderr) = tmpfile_buffer();
192    let exe_path = args_os().next().expect("Test executable path not found");
193
194    let success = Command::new(exe_path)
195        .args([
196            "--include-ignored",
197            "--nocapture",
198            "--quiet",
199            "--exact",
200            "--test",
201        ])
202        .arg(full_test_name)
203        .env(var_name, "")
204        .stdin(Stdio::null())
205        .stdout(stdout)
206        .stderr(stderr)
207        .status()
208        .expect("Failed to execute test as subprocess")
209        .success();
210
211    let mut output = read_file(tmpfile);
212    let boundary_at = output
213        .find(&*boundary)
214        .expect("Subprocess output should always include at least one boundary");
215
216    output.replace_range(..(boundary_at + boundary.len()), "");
217
218    if let Some(boundary_at) = output.find(&*boundary) {
219        output.truncate(boundary_at);
220    }
221
222    verify_fn(success, output);
223}
224
225fn tmpfile_buffer() -> (File, File, File) {
226    let file = tempfile().expect("Failed to create temporary file for subprocess output");
227    let stdout = file
228        .try_clone()
229        .expect("Failed to clone tmpfile descriptor");
230    let stderr = file
231        .try_clone()
232        .expect("Failed to clone tmpfile descriptor");
233
234    (file, stdout, stderr)
235}
236
237fn read_file(mut file: File) -> String {
238    file.seek(SeekFrom::Start(0))
239        .expect("Rewind to start failed");
240
241    let mut buffer = String::new();
242    file.read_to_string(&mut buffer)
243        .expect("Failed to read file into buffer");
244
245    buffer
246}
247
248subprocess_test! {
249    #[test]
250    fn name_collision() {
251        println!("One");
252    }
253    verify |success, output| {
254        assert!(success);
255        assert_eq!(output, "One\n");
256    }
257
258    #[test]
259    fn simple_success() {
260        let value = 1;
261        assert_eq!(value + 1, 2);
262    }
263
264    #[test]
265    fn simple_verify() {
266        println!("Simple verify test");
267    }
268    verify |success, output| {
269        assert!(success);
270        assert_eq!(output, "Simple verify test\n");
271    }
272
273    #[test]
274    fn simple_failure() {
275        panic!("Oopsie!");
276    }
277    verify |success, output| {
278        assert!(!success);
279        // Note that panic output contains stacktrace and other stuff
280        assert!(output.contains("Oopsie!\n"));
281    }
282
283    #[test(
284        env_var_name = "__CUSTOM_SUBPROCESS_VAR__"
285    )]
286    fn custom_var() {
287        assert!(var_os("__CUSTOM_SUBPROCESS_VAR__").is_some());
288    }
289
290    #[test(
291        output_boundary = "!!!!!!!!!!!!!!!!"
292    )]
293    fn custom_boundary() {
294        println!("One");
295        println!("Two");
296        println!("\n!!!!!!!!!!!!!!!!\n");
297        println!("Three");
298    }
299    verify |success, output| {
300        assert!(success);
301        assert_eq!(output, "One\nTwo\n");
302    }
303
304    #[test]
305    #[should_panic]
306    fn should_panic_test() {
307        panic!("Oopsie!");
308    }
309    verify |success, _output| {
310        assert!(!success, "Correct result should cause panic");
311    }
312
313    #[test]
314    fn test_aborts() {
315        println!("Banana");
316        eprintln!("Mango");
317        std::process::abort();
318    }
319    verify |success, output| {
320        assert!(!success);
321        assert_eq!(output, "Banana\nMango\n");
322    }
323}
324
325#[cfg(test)]
326mod submodule_tests {
327    use std::sync::atomic::{AtomicUsize, Ordering};
328    // Used to check that only single test is run per subprocess
329    static COMMON_PREFIX_COUNTER: AtomicUsize = AtomicUsize::new(0);
330
331    subprocess_test! {
332        #[test]
333        fn submodule_test() {
334            let value = 1;
335            assert_eq!(value + 1, 2);
336        }
337
338        #[test]
339        fn common_prefix() {
340            print!("One");
341            COMMON_PREFIX_COUNTER.fetch_add(1, Ordering::Relaxed);
342            assert_eq!(COMMON_PREFIX_COUNTER.load(Ordering::Relaxed), 1);
343        }
344        verify |success, output| {
345            assert!(success);
346            assert_eq!(output, "One");
347        }
348
349        #[test]
350        fn common_prefix_2() {
351            print!("Two");
352            COMMON_PREFIX_COUNTER.fetch_add(1, Ordering::Relaxed);
353            assert_eq!(COMMON_PREFIX_COUNTER.load(Ordering::Relaxed), 1);
354        }
355        verify |success, output| {
356            assert!(success);
357            assert_eq!(output, "Two");
358        }
359    }
360
361    mod common_prefix {
362        subprocess_test! {
363            #[test]
364            fn inner() {
365                print!("Three");
366                super::COMMON_PREFIX_COUNTER.fetch_add(1, super::Ordering::Relaxed);
367                assert_eq!(super::COMMON_PREFIX_COUNTER.load(super::Ordering::Relaxed), 1);
368            }
369            verify |success, output| {
370                assert!(success);
371                assert_eq!(output, "Three");
372            }
373        }
374    }
375}