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; psrens are needed
41//! // only if some 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 unprobable 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 subprocess_test! {
328 #[test]
329 fn submodule_test() {
330 let value = 1;
331 assert_eq!(value + 1, 2);
332 }
333 }
334}