Skip to content

Instantly share code, notes, and snippets.

@target-san
Last active April 19, 2025 18:34
Show Gist options
  • Save target-san/72676113b6d4b1c4f62be19e9f3b84d1 to your computer and use it in GitHub Desktop.
Save target-san/72676113b6d4b1c4f62be19e9f3b84d1 to your computer and use it in GitHub Desktop.
Implement Rust test which launches itself as a separate subprocess
//! ```cargo
//! [dependencies]
//! tempfile = "3.19"
//! ```
use std::env::var_os;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::process::{Command, Stdio};
use tempfile::tempfile;
/// Launches piece of test code as separate subprocess, collects all its output
/// and then runs validation code against it
///
/// Used when one needs to either run some test in isolation or validate test output
/// regardless of its proper completion, i.e. even if it aborts
///
/// # Usage
/// ```rust,ignore
/// subprocess_test! {
/// #[test] // Mandatory test attribute
/// #[ignore] // Any other attributes are allowed, yet are optional
/// fn dummy() { // Test can have any valid name
/// // This block is intended to generate test output,
/// // although it can be used as normal test body
/// println!("Foo");
/// eprintln!("Bar");
/// }
/// // `verify` block is optional;
/// // if absent, it's substituted with block which just asserts that exit code was 0
/// verify |code, output| { // Parameter names can be any valid identifiers
/// // This block is run as normal part of test and in general must succeed
/// assert_eq!(code, 0);
/// assert_eq!(output, "Foo\nBar\n");
/// }
/// }
/// ```
///
#[macro_export]
macro_rules! subprocess_test {
(
#[test]
$(#[$attrs:meta])*
fn $test_name:ident () $test_block:block
verify |$status_param:ident, $stdout_param:ident| $verify_block:block
) => {
#[test]
$(#[$attrs])*
fn $test_name() {
run_bin_test(
env!("CARGO_PKG_NAME"),
concat!(module_path!(), "::", stringify!($test_name)),
|| $test_block,
|$status_param, $stdout_param| $verify_block,
);
}
};
(
#[test]
$(#[$attrs:meta])*
fn $test_name:ident() $test_block:block
) => {
$crate::subprocess_test! {
#[test]
$(#[$attrs])*
fn $test_name() $test_block
verify |exit_code, output| {
if exit_code != 0 {
eprintln!("{output}");
panic!("Test process failed with {exit_code}");
}
}
}
};
}
fn run_bin_test(
package_name: &str,
full_test_name: &str,
test_fn: impl FnOnce(),
verify_fn: impl FnOnce(i32, String),
) {
const RUN_TEST_PHASE_ENV_VAR: &str = "__RUN_TEST_PHASE__";
const TEST_OUTPUT_BOUNDARY: &str = "\n========================================\n";
let full_test_name = &full_test_name[full_test_name
.find("::")
.expect("Full test path is expected to include crate name")
+ 2..];
let cargo = var_os("CARGO").unwrap_or("cargo".into());
// If test phase is requested, execute it and bail immediately
if var_os(RUN_TEST_PHASE_ENV_VAR).is_some() {
print!("{TEST_OUTPUT_BOUNDARY}");
// We expect that in case of panic we'll get test harness footer,
// but in case of abort we won't get it, so finisher won't be needed
let _finisher = defer(|| print!("{TEST_OUTPUT_BOUNDARY}"));
test_fn();
return;
}
// Otherwise, perform main runner phase
// Note that we don't perform separate compilation phase,
// as we always run this code as test
let (tmpfile, stdout, stderr) = tmpfile_buffer();
let code = Command::new(&cargo)
.args(["test", "-q", "-p"])
.arg(package_name)
.args(["--", "--include-ignored", "--nocapture", "--test"])
.arg(full_test_name)
.env(RUN_TEST_PHASE_ENV_VAR, "")
.stdin(Stdio::null())
.stdout(stdout)
.stderr(stderr)
.status()
.expect("Failed to execute test in binary output mode")
.code()
.expect("Test subprocess should've completed and got its status code");
let mut output = read_file(tmpfile);
let boundary_at = output
.find(TEST_OUTPUT_BOUNDARY)
.expect("Test mode output should always include at least one boundary");
output.replace_range(..(boundary_at + TEST_OUTPUT_BOUNDARY.len()), "");
if let Some(boundary_at) = output.find(TEST_OUTPUT_BOUNDARY) {
output.truncate(boundary_at);
}
verify_fn(code, output);
}
/// Copy of `defer` from `defer` crate, to not introduce dependency
fn defer<F: FnOnce()>(f: F) -> impl Drop {
use std::mem::ManuallyDrop;
struct Defer<F: FnOnce()>(ManuallyDrop<F>);
impl<F: FnOnce()> Drop for Defer<F> {
fn drop(&mut self) {
let f: F = unsafe { ManuallyDrop::take(&mut self.0) };
f();
}
}
Defer(ManuallyDrop::new(f))
}
fn tmpfile_buffer() -> (File, File, File) {
let file = tempfile().expect("Failed to create temporary file for subprocess output");
let stdout = file
.try_clone()
.expect("Failed to clone tmpfile descriptor");
let stderr = file
.try_clone()
.expect("Failed to clone tmpfile descriptor");
(file, stdout, stderr)
}
fn read_file(mut file: File) -> String {
file.seek(SeekFrom::Start(0))
.expect("Rewind to start failed");
let mut buffer = String::new();
file.read_to_string(&mut buffer)
.expect("Failed to read file into buffer");
buffer
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment