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//!     /// Test's doc comments are supported just fine
22//!     #[test]
23//!     fn one_plus_one() {
24//!         println!("{}", 1 + 1);
25//!     }
26//!     verify |success, output| {
27//!         assert!(success);
28//!         assert_eq!(output, "2\n");
29//!     }
30//!
31//!     #[test]
32//!     fn test_with_result() -> Result<(), ()> {
33//!         print!("{}", 1 + 1);
34//!         Ok(())
35//!     }
36//!     verify |success, output| {
37//!         if success && output == "2" {
38//!             Ok(())
39//!         } else {
40//!             Err(())
41//!         }
42//!     }
43//! }
44//! ```
45//!
46//! # Usage
47//!
48//! ```rust
49//! // Single macro invocation can include multiple test function definitions,
50//! // but not other functions or lang items
51//! subprocess_test::subprocess_test! {
52//!     /// You can specify doc comments for your subprocess test,
53//!     /// but only before `#[test]` attribute
54//!     // Mandatory test marker attribute; parens are needed
55//!     // only if any attribute parameters are specified.
56//!     //
57//!     // Please also note that this attribute must be first,
58//!     // and its optional parameters must maintain order.
59//!     // This is due to limitations of Rust's macro-by-example.
60//!     #[test(     
61//!         // Optionally specify name of environment variable used to mark subprocess mode.
62//!         // Default name is "__TEST_RUN_SUBPROCESS__", so in very improbable case case
63//!         // you're getting name collision here, you can change it.
64//!         env_var_name = "RUN_SUBPROCESS_ENV_VAR",
65//!         // While subprocess is executed using `cargo test -q -- --nocapture`,
66//!         // there's still some output from test harness.
67//!         // To filter it out, test prints two boundary lines, in the beginning
68//!         // and in the end of test's output, regardless if it succeeds or panics.
69//!         // The default boundary line is "========================================",
70//!         // so in rare case you expect conflict with actual test output, you can use
71//!         // this parameter to set custom output boundary.
72//!         output_boundary = "<><><><><><><><>",
73//!     )]
74//!     // Any other attributes are allowed, yet are optional
75//!     #[ignore]
76//!     // Test can have any valid name, same as normal test function
77//!     fn dummy() {
78//!         // This block is intended to generate test output,
79//!         // although it can be used as normal test body
80//!         println!("Foo");
81//!         eprintln!("Bar");
82//!     }
83//!     // `verify` block is optional;
84//!     // if absent, it's substituted with block which just asserts that subprocess succeeded
85//!     // and prints test output in case of failure
86//!     //
87//!     // Parameters can be any names. Their meanings:
88//!     // * `success` - boolean which is `true` if subprocess succeeded
89//!     // * `output` - subprocess output collected into string, both `stdout` and `stderr`
90//!     verify |success, output| {
91//!         // This block is run as normal part of test and in general must succeed
92//!         assert!(success);
93//!         assert_eq!(output, "Foo\nBar\n");
94//!     }
95//!
96//!     #[test]
97//!     // Test writer can use explicit `Result` type, like with normal test functions.
98//!     // In this case, `verify` block is mandatory, and both main test block and `verify`
99//!     // block must return same result type
100//!     fn test_returns_result() -> Result<(), String> {
101//!         Ok(())
102//!     }
103//!     verify |success, output| {
104//!         if success && output.is_empty() {
105//!             Ok(())
106//!         } else {
107//!             Err("Oopsie, test failed!")
108//!         }
109//!     }
110//! }
111//! ```
112//!
113//! # Limitations
114//!
115//! Macro doesn't work well with `#[should_panic]` attribute because there's only one test function
116//! which runs in two modes. If subprocess test panics as expected, subprocess succeeds, and
117//! `verify` block must panic too. Just use `verify` block and do any checks you need there.
118//!
119//! Another minor limitation, as described in [#Usage] section, is that first goes doc comment,
120//! then mandatory `#[test]` attribute with extensions, then any other attributes,
121//! then function body and `verify` block.
122//!
123//! If test writer uses explicit result type and forgets to write `verify` block, he'll get error
124//! like "expected return value `Result<_, _>`, got `()" instead of possibly more comprehensive
125//! "missing `verify` block". Again, this is due to limitations of macro-by-example
126use std::borrow::Cow;
127use std::env::{args_os, var_os};
128use std::fs::File;
129use std::io::{Read, Seek, SeekFrom};
130use std::process::{Command, Stdio};
131
132use defer::defer;
133use tempfile::tempfile;
134/// Implementation of `subprocess_test` macro. See crate-level documentation for details and usage examples
135#[macro_export]
136macro_rules! subprocess_test {
137    (
138        $(
139            $(#[doc = $doc_lit:literal])*
140            #[test $((
141                $(env_var_name = $subp_var_name:literal $(,)?)?
142                $(output_boundary = $subp_output_boundary:literal $(,)?)?
143            ))?]
144            $(#[$attrs:meta])*
145            fn $test_name:ident () $(-> $test_result:ty)? $test_block:block
146            $(verify |$success_param:ident, $stdout_param:ident| $verify_block:block)?
147        )*
148    ) => {
149        $(
150            $(#[doc = $doc_lit])*
151            #[test]
152            $(#[$attrs])*
153            fn $test_name() $(-> $test_result)? {
154                $crate::run_subprocess_test(
155                    concat!(module_path!(), "::", stringify!($test_name)),
156                    $crate::subprocess_test! {
157                        @tokens_or_default { $($(Some($subp_var_name))?)? }
158                        or { None }
159                    },
160                    $crate::subprocess_test! {
161                        @tokens_or_default { $($(Some($subp_output_boundary))?)? }
162                        or { None }
163                    },
164                    || $test_block,
165                    $crate::subprocess_test! {
166                        @tokens_or_default {
167                            $(|$success_param, $stdout_param| $verify_block)?
168                        } or {
169                            // NB: we inject closure here, to make panic report its location
170                            // at macro expansion
171                            |success, output| {
172                                if !success {
173                                    eprintln!("{output}");
174                                    // In case panic location will point to whole macro start,
175                                    // you'll get at least test name
176                                    panic!("Test {} subprocess failed", stringify!($test_name));
177                                }
178                            }
179                        }
180                    },
181                )
182            }
183        )*
184    };
185    (
186        @tokens_or_default { $($tokens:tt)+ } or { $($_:tt)* }
187    ) => {
188        $($tokens)+
189    };
190    (
191        @tokens_or_default { } or { $($tokens:tt)* }
192    ) => {
193        $($tokens)*
194    };
195}
196
197#[doc(hidden)]
198pub fn run_subprocess_test<R>(
199    full_test_name: &str,
200    var_name: Option<&str>,
201    boundary: Option<&str>,
202    test_fn: impl FnOnce() -> R,
203    verify_fn: impl FnOnce(bool, String) -> R,
204) -> R {
205    const DEFAULT_SUBPROCESS_ENV_VAR_NAME: &str = "__TEST_RUN_SUBPROCESS__";
206    const DEFAULT_OUTPUT_BOUNDARY: &str = "\n========================================\n";
207
208    let full_test_name = &full_test_name[full_test_name
209        .find("::")
210        .expect("Full test path is expected to include crate name")
211        + 2..];
212    let var_name = var_name.unwrap_or(DEFAULT_SUBPROCESS_ENV_VAR_NAME);
213    let boundary: Cow<'static, str> = if let Some(boundary) = boundary {
214        format!("\n{boundary}\n").into()
215    } else {
216        DEFAULT_OUTPUT_BOUNDARY.into()
217    };
218    // If test phase is requested, execute it and bail immediately
219    if var_os(var_name).is_some() {
220        print!("{boundary}");
221        // We expect that in case of panic we'll get test harness footer,
222        // but in case of abort we won't get it, so finisher won't be needed
223        defer! { print!("{boundary}") };
224        return test_fn();
225    }
226    // Otherwise, perform main runner phase.
227    // Just run same executable but with different options
228    let (tmpfile, stdout, stderr) = tmpfile_buffer();
229    let exe_path = args_os().next().expect("Test executable path not found");
230
231    let success = Command::new(exe_path)
232        .args([
233            "--include-ignored",
234            "--nocapture",
235            "--quiet",
236            "--exact",
237            "--test",
238        ])
239        .arg(full_test_name)
240        .env(var_name, "")
241        .stdin(Stdio::null())
242        .stdout(stdout)
243        .stderr(stderr)
244        .status()
245        .expect("Failed to execute test as subprocess")
246        .success();
247
248    let mut output = read_file(tmpfile);
249    let boundary_at = output
250        .find(&*boundary)
251        .expect("Subprocess output should always include at least one boundary");
252
253    output.replace_range(..(boundary_at + boundary.len()), "");
254
255    if let Some(boundary_at) = output.find(&*boundary) {
256        output.truncate(boundary_at);
257    }
258
259    verify_fn(success, output)
260}
261
262fn tmpfile_buffer() -> (File, File, File) {
263    let file = tempfile().expect("Failed to create temporary file for subprocess output");
264    let stdout = file
265        .try_clone()
266        .expect("Failed to clone tmpfile descriptor");
267    let stderr = file
268        .try_clone()
269        .expect("Failed to clone tmpfile descriptor");
270
271    (file, stdout, stderr)
272}
273
274fn read_file(mut file: File) -> String {
275    file.seek(SeekFrom::Start(0))
276        .expect("Rewind to start failed");
277
278    let mut buffer = String::new();
279    file.read_to_string(&mut buffer)
280        .expect("Failed to read file into buffer");
281
282    buffer
283}
284
285subprocess_test! {
286    #[test]
287    fn name_collision() {
288        println!("One");
289    }
290    verify |success, output| {
291        assert!(success);
292        assert_eq!(output, "One\n");
293    }
294
295    #[test]
296    fn simple_success() {
297        let value = 1;
298        assert_eq!(value + 1, 2);
299    }
300
301    #[test]
302    fn simple_verify() {
303        println!("Simple verify test");
304    }
305    verify |success, output| {
306        assert!(success);
307        assert_eq!(output, "Simple verify test\n");
308    }
309
310    #[test]
311    fn simple_failure() {
312        panic!("Oopsie!");
313    }
314    verify |success, output| {
315        assert!(!success);
316        // Note that panic output contains stacktrace and other stuff
317        assert!(output.contains("Oopsie!\n"));
318    }
319
320    #[test(
321        env_var_name = "__CUSTOM_SUBPROCESS_VAR__"
322    )]
323    fn custom_var() {
324        assert!(var_os("__CUSTOM_SUBPROCESS_VAR__").is_some());
325    }
326
327    #[test(
328        output_boundary = "!!!!!!!!!!!!!!!!"
329    )]
330    fn custom_boundary() {
331        println!("One");
332        println!("Two");
333        println!("\n!!!!!!!!!!!!!!!!\n");
334        println!("Three");
335    }
336    verify |success, output| {
337        assert!(success);
338        assert_eq!(output, "One\nTwo\n");
339    }
340
341    #[test]
342    #[should_panic]
343    fn should_panic_test() {
344        panic!("Oopsie!");
345    }
346    verify |success, _output| {
347        assert!(!success, "Correct result should cause panic");
348    }
349
350    #[test]
351    fn test_aborts() {
352        println!("Banana");
353        eprintln!("Mango");
354        std::process::abort();
355    }
356    verify |success, output| {
357        assert!(!success);
358        assert_eq!(output, "Banana\nMango\n");
359    }
360
361    /// Checks that positive test with result works as intended
362    #[test]
363    fn positive_test_result() -> Result<(), ()> {
364        print!("Result succeeds");
365        Ok(())
366    }
367    verify |success, output| {
368        if success && output == "Result succeeds" {
369            Ok(())
370        } else {
371            Err(())
372        }
373    }
374
375    /// Checks that negative test with result works as intended
376    #[test]
377    fn negative_test_result() -> Result<(), ()> {
378        print!("Result fails");
379        Err(())
380    }
381    verify |success, output| {
382        if !success && output == "Result fails" {
383            Ok(())
384        } else {
385            Err(())
386        }
387    }
388
389    #[test]
390    fn panicking_test_result() -> Result<(), ()> {
391        panic!("Result panics")
392    }
393    verify |success, output| {
394        if !success && output.contains("Result panics") {
395            Ok(())
396        } else {
397            Err(())
398        }
399    }
400}
401
402#[cfg(test)]
403mod submodule_tests {
404    use std::sync::atomic::{AtomicUsize, Ordering};
405    // Used to check that only single test is run per subprocess
406    static COMMON_PREFIX_COUNTER: AtomicUsize = AtomicUsize::new(0);
407
408    subprocess_test! {
409        #[test]
410        fn submodule_test() {
411            let value = 1;
412            assert_eq!(value + 1, 2);
413        }
414
415        #[test]
416        fn common_prefix() {
417            print!("One");
418            COMMON_PREFIX_COUNTER.fetch_add(1, Ordering::Relaxed);
419            assert_eq!(COMMON_PREFIX_COUNTER.load(Ordering::Relaxed), 1);
420        }
421        verify |success, output| {
422            assert!(success);
423            assert_eq!(output, "One");
424        }
425
426        #[test]
427        fn common_prefix_2() {
428            print!("Two");
429            COMMON_PREFIX_COUNTER.fetch_add(1, Ordering::Relaxed);
430            assert_eq!(COMMON_PREFIX_COUNTER.load(Ordering::Relaxed), 1);
431        }
432        verify |success, output| {
433            assert!(success);
434            assert_eq!(output, "Two");
435        }
436    }
437
438    mod common_prefix {
439        subprocess_test! {
440            #[test]
441            fn inner() {
442                print!("Three");
443                super::COMMON_PREFIX_COUNTER.fetch_add(1, super::Ordering::Relaxed);
444                assert_eq!(super::COMMON_PREFIX_COUNTER.load(super::Ordering::Relaxed), 1);
445            }
446            verify |success, output| {
447                assert!(success);
448                assert_eq!(output, "Three");
449            }
450        }
451    }
452}