As food for thoughts, here's how I'm using assert_cmd
.
The guiding idea is that I build a struct describing programs to run/delay/kill, a set of input files, and a set of post-run checks that operate on created files (including the program's stdout/stderr). I can run()
on that struct once it is set up, and it takes care of setting up the TempDir, scheduling program start, and running the checks.
/// Main struct described above. It's got builder-pattern methods,
/// and a `run` method that will do all the work and `assert` upon failure.
pub struct Exec {
cmds: Vec<Cmd>,
timeout: Duration,
files: Vec<FileIn>,
checks: Vec<FilePredicate>,
}
pub struct Cmd {
/// Program to run.
bin: Command,
/// Command name for `after()`, std/err filname prefix, and logs.
name: String,
/// Expected exit status. Some(0)=success, Some(n)=failure, None=timeout.
exit: Option<i32>,
/// Fail if the cmd exits too early.
mintime: Duration,
/// Fail if the cmd run for too long.
maxtime: Duration,
/// Current state.
state: CmdState,
/// List of signals to send to the process after startup.
signals: Vec<(CmdCond, c_int)>,
}
enum CmdState {
Wait(CmdCond),
Started(Child, Instant),
Done,
}
pub enum CmdCond {
/// Immediately true
None,
/// Duration elapsed
Delay(Duration),
/// Other Cmd exited
Cmd(String),
/// File-based predicate
Predicate(FilePredicate),
}
pub struct FilePredicate {
/// Desciption for assert-logging purpose.
desc: String,
/// Which file to operate on.
file: String,
/// Closure that tests the content of the file.
pred: Box<dyn Fn(&str) -> bool>,
}
pub enum FileIn {
FromFs(&'static str, &'static str),
Bin(&'static str, Vec<u8>),
}
With that in place, I have a pretty powerful way to write a unittest, using my crate's main
binary and a few ancillary processes:
// As basic as it gets.
exec().cmd(Cmd::any("echo", "echo", "-n a")).check("echo a", "echo.out", |s| s == "a").run();
// File from hardcoded data.
exec().inbytes("a", "a")
.cmd(Cmd::any("cat", "cat", "a"))
.check("cat a", "cat.out", |s| s == "a")
.run();
// File from file in test/ directory.
exec().infile("j", "input.basic.json")
.cmd(Cmd::any("cat", "cat", "j"))
.check("cat j", "cat.out", |s| s.starts_with("{"))
.run();
// run sequentially
let start = Instant::now();
exec().cmd(Cmd::any("s1", "sleep", "0.3"))
.cmd(Cmd::any("s2", "sleep", "0.3").after("s1"))
.cmd(Cmd::any("s3", "sleep", "0.3").after("s2"))
.cmd(Cmd::any("cat", "cat", "s1.out s2.out s3.out").after("s3"))
.run();
assert!(Instant::now() - start > Duration::from_millis(900));
// delayed start
exec().cmd(Cmd::any("0", "cat", "1.out 2.out").after(20))
.cmd(Cmd::any("1", "echo", "a"))
.cmd(Cmd::any("2", "echo", "b"))
.check("cat", "0.out", |s| s == "a\nb\n")
.run();
// timeout
exec().cmd(Cmd::any("s", "sleep", "1").maxtime(100).exit(None)).run();
exec().cmd(Cmd::any("s", "sleep", "0.05").mintime(50).maxtime(70)).run();
// Multiple signals, ordered by Instant.
exec().cmd(Cmd::any("s", "sleep", "1").signal(SIGINT, 70) // Added first but triggers last
.signal(SIGCONT, 10) // Triggers first but doesn't terminate
.signal(SIGTERM, 30) // Actual terminator
.exit(SIGTERM))
.run();
// Signal after another cmd exit.
exec().cmd(Cmd::any("s1", "sleep", "1").signal(SIGINT, "s2").maxtime(50).exit(SIGINT))
.cmd(Cmd::any("s2", "sleep", "0.01"))
.run();
// Signal after file content matches
exec().cmd(Cmd::any("s1", "sleep", "1").signal(SIGINT, ("s2.out", "4"))
.maxtime(100)
.exit(SIGINT))
.cmd(Cmd::bash("s2", "for i in $(seq 10);do echo $i;sleep 0.01;done"))
.timeout(1000)
.run();
// Main binary
exec().cmd(Cmd::main("m", "-V")).check("progname", "0.out", |s| s.starts_with("mybin"));
// Set/unset env
exec().cmd(Cmd::any("env", "env", "").env("foo", "bar")
.env("PATH", &format!("/ut:{}", env::var("PATH").unwrap()))
.env_remove("HOME"))
.check("added_foo", "env.out", |s| s.lines().any(|l| l == "foo=bar"))
.check("changed_path", "env.out", |s| s.lines().any(|l| l.starts_with("PATH=/ut:")))
.check("removed_home", "env.out", |s| !s.lines().any(|l| l.starts_with("HOME=")))
.run();
If there's enough interest, I could try to get this cleared for upstreaming. Currently there are a few extensions and API decisions that are specific to my project, and the code is a bit ad-hoc in places.
Originally posted by @vincentdephily in #74 (comment)