Code that shells out is miserable to test — unless the subprocess is behind a
seam. In processkit that seam is one trait with one method:
#[async_trait]
pub trait ProcessRunner: Send + Sync {
async fn output(&self, command: &Command) -> Result<ProcessResult<String>>;
}Production code takes a runner (generically or as &dyn ProcessRunner); tests
hand it a double. Four doubles ship with the crate, plus a macro that makes
whole CLI wrappers testable for free.
- The
ProcessRunnerseam - Scripting replies:
ScriptedRunner - Asserting invocations:
RecordingRunner - Expectation-style:
MockRunner - Record/replay cassettes:
RecordReplayRunner - Wrapping a CLI tool:
CliClient
JobRunner is the real implementation (each run in a fresh private group); a
ProcessGroup is also a runner (runs land in that
shared group); and impl ProcessRunner for &R means a borrowed runner
works wherever an owned one does — inject &group or &recording without
giving ownership away.
Every runner — real or double — gets the convenience helpers of
ProcessRunnerExt for free: run (trimmed stdout, success required),
run_unit, exit_code, probe (exit code as a boolean), checked
(success-checked full result). Retry
policies work through the seam too, so
a double exercises your retry handling hermetically.
The seam covers streaming as well as bulk runs: ProcessRunner::start
returns a live RunningProcess, and a ScriptedRunner's start hands back a
scripted handle whose canned lines flow through the same pump machinery a real
child uses — stdout_lines, wait_for_line, and finish_streamed behave
identically, with no subprocess (see
Scripted streaming below). An output-only custom
runner keeps compiling: start is defaulted to Error::Unsupported.
use processkit::{Command, ProcessRunner, ProcessRunnerExt, Result};
// Production code: generic over the runner.
async fn current_branch(runner: &impl ProcessRunner) -> Result<String> {
runner
.run(&Command::new("git").args(["branch", "--show-current"]))
.await
}ScriptedRunner returns canned Replys for matched commands — the
work-horse double:
use processkit::{Command, ProcessRunnerExt, Reply, ScriptedRunner};
#[tokio::test]
async fn detects_the_branch() {
let runner = ScriptedRunner::new()
// Match by argument PREFIX (element-wise, in registration order):
.on(["branch", "--show-current"], Reply::ok("main\n"))
// …or by any predicate over the full Command:
.when(
|cmd| cmd.working_dir().is_some(),
Reply::fail(128, "fatal: not a git repository"),
)
// …with an optional catch-all:
.fallback(Reply::ok(""));
assert_eq!(current_branch(&runner).await.unwrap(), "main");
}The pieces:
Reply::ok(stdout)— exit 0.Reply::fail(code, stderr)— non-zero with stderr.Reply::lines(["a", "b"])— exit 0 with the lines joined (and streamed one by one on a scriptedstart).Reply::timeout()— a timed-out run (the checking helpers raiseError::Timeoutfrom it, carrying the command's own configured deadline)..with_stdout(text)— attach stdout to any of them (e.g. theCONFLICT …text git prints on a failing merge)..with_line_delay(d)— pace a scripted stream's lines.Reply::pending()(cancellationfeature) — parks the call until the command's cancellation token (per-commandcancel_onor the client-leveldefault_cancel_on) fires, then resolves withError::Cancelled— so a test can prove an orchestration actually cancels a blocked call, not just that it formats a canned error. With no token it parks forever, like a hung child.- Rules are tried in registration order; first match wins. Prefix
matching is element-wise —
on(["foo"])matches args["foo", "bar"]but not["foobar"]. - No match and no fallback is a loud error (
Error::Spawn, not-found) — an unexpected invocation can't slip through a test silently. - Bulk runs also replay the canned lines through the command's
on_stdout_line/on_stderr_linehandlers, so a wrapper's progress-reporting path is exercised without a subprocess.
ScriptedRunner::start returns a live RunningProcess backed by the canned
reply instead of an OS child. The canned stdout/stderr feed the same pump
machinery a real child uses, so the whole streaming surface works
hermetically — stdout_lines yields the lines, wait_for_line probes them,
finish_streamed reports the canned outcome and stderr:
use processkit::{Command, Outcome, ProcessRunner, Reply, ScriptedRunner, StreamExt, StreamedFinish};
use std::time::Duration;
#[tokio::test]
async fn server_becomes_ready() {
let runner = ScriptedRunner::new()
.on(["serve"], Reply::lines(["booting", "listening on 8080"]));
let mut run = runner.start(&Command::new("server").arg("serve")).await.unwrap();
run.wait_for_line(|l| l.contains("listening"), Duration::from_secs(5))
.await
.unwrap(); // satisfied by the canned banner — no subprocess
let StreamedFinish { outcome, .. } = run.finish_streamed().await.unwrap();
assert_eq!(outcome, Outcome::Exited(0));
}Reply::lines([...]) scripts the stdout lines; .with_line_delay(d) paces
them (deterministic under #[tokio::test(start_paused = true)]), and the
scripted run "exits" after the last line. The honest boundaries: a scripted
handle has no OS identity (pid() is None, profile reports empty
samples), does not compose into a real Pipeline, and does not model
interactive stdin. Reply::pending() scripts a run that never exits on its
own — cancel or time it out through the command's own knobs.
RecordingRunner wraps another runner and records every Invocation — what
was asked — so a test asserts inputs, not just outputs:
use processkit::{Command, ProcessRunnerExt, RecordingRunner, Reply, ScriptedRunner};
#[tokio::test]
async fn passes_the_right_flags() {
let runner = RecordingRunner::new(
ScriptedRunner::new().fallback(Reply::ok("done")),
);
runner
.run(&Command::new("gh").args(["pr", "create", "--draft"]).current_dir("/repo"))
.await
.unwrap();
let call = runner.only_call(); // panics unless exactly one call
assert_eq!(call.args_str(), ["pr", "create", "--draft"]);
assert!(call.has_flag("--draft"));
assert_eq!(call.cwd.as_deref().map(|c| c.to_str().unwrap()), Some("/repo"));
assert!(!call.has_stdin);
}An Invocation captures the routing knobs — program, args, cwd,
envs (explicit overrides, None = removal), has_stdin — not the
I/O-shaping ones (timeout, encodings, buffer policy); assert those through a
when predicate over the Command itself. calls() returns the full list
when more than one run is expected.
With the mock feature, mockall generates a MockRunner for
expectation-style tests (call counts, argument matchers, ordered
expectations) — the right tool when the interaction is the contract:
use processkit::MockRunner;
let mut mock = MockRunner::new();
mock.expect_output()
.times(1)
.returning(|_cmd| /* build a Result<ProcessResult<String>> */ …);For most tests ScriptedRunner/RecordingRunner read better; reach for the
mock when you need mockall's matching machinery.
With the record feature, RecordReplayRunner closes the loop: record
real runs to a JSON cassette once, then replay them deterministically —
fast, hermetic, byte-stable, no subprocess in CI:
use processkit::{Command, JobRunner, ProcessRunnerExt, RecordReplayRunner};
// Record once against the real tool (an opt-in `--record` test run, say):
let runner = RecordReplayRunner::record("fixtures/git.json", JobRunner::new());
let version = runner.run(&Command::new("git").arg("--version")).await?;
runner.save()?; // the error-surfacing flush
// (best-effort on drop too)
// Replay everywhere else:
let runner = RecordReplayRunner::replay("fixtures/git.json")?;
assert_eq!(runner.run(&Command::new("git").arg("--version")).await?, version);Semantics worth knowing before you commit a cassette:
| Aspect | Behavior |
|---|---|
| Match key | program + args + cwd + has-stdin (lossy UTF-8 on both sides) |
| Environment | values never reach the file — only sorted variable names (a committed fixture can't leak secrets); env is not matched, so env differences can't cause spurious misses |
| Duplicates of one key | replay in capture order, then the last entry repeats — a recorded sequence (git rev-parse HEAD before/after a commit) replays faithfully, while retry/probe loops keep getting a stable final answer |
| Miss | strict Error::Spawn (not-found) — replay never spawns a surprise subprocess; a stale cassette fails loudly |
| Timeouts | a recorded timed-out run replays as one, surfacing Error::Timeout with the replaying command's deadline |
| Format | pretty-printed JSON with a version field; unknown versions / corrupt files are Error::Io(InvalidData), a missing file keeps NotFound |
| Err results | not recorded — only completed runs (non-zero exits and captured timeouts are results and are recorded) |
A neat trick: in tests, record against a ScriptedRunner instead of
JobRunner — the whole record→save→replay round trip is then itself
hermetic.
CliClient is the foundation for typed wrappers around external tools
(git, jj, gh, kubectl, …): it owns the program name, per-client
defaults, and the runner; your wrapper contributes only commands and parsers.
The cli_client! macro generates the boilerplate:
use processkit::{cli_client, Error, ProcessRunner, Result};
use std::path::Path;
use std::time::Duration;
cli_client!(
/// A typed `git` client.
pub struct Git => "git"
);
impl<R: ProcessRunner> Git<R> {
/// HEAD's commit id.
pub async fn head(&self, repo: &Path) -> Result<String> {
self.core.run(self.core.command_in(repo, ["rev-parse", "HEAD"])).await
}
/// Is the work tree clean? (exit code IS the answer)
pub async fn is_clean(&self, repo: &Path) -> Result<bool> {
self.core.probe(self.core.command_in(repo, ["diff", "--quiet"])).await
}
/// Branch list, parsed — the parser is fallible and returns the crate's
/// `Result`, typically an `Error::Parse` naming the program.
pub async fn branches(&self, repo: &Path) -> Result<Vec<String>> {
self.core
.try_parse(
self.core.command_in(repo, ["branch", "--format=%(refname:short)"]),
|out| {
let list: Vec<String> = out.lines().map(str::to_owned).collect();
if list.is_empty() {
Err(Error::Parse {
program: "git".into(),
message: "no branches".into(),
})
} else {
Ok(list)
}
},
)
.await
}
}
// Production: the real runner, with per-client defaults.
let git = Git::new().default_timeout(Duration::from_secs(30));
let head = git.head(Path::new(".")).await?;The generated type is Git<R: ProcessRunner = JobRunner> with Git::new(),
Git::with_runner(runner), default_timeout / default_env /
default_env_remove builders, and a public core: CliClient<R> whose helpers
speak the crate-wide verb vocabulary: run (trimmed stdout), output (full
result), run_unit (success only), exit_code, probe, plus parse
(infallible) and try_parse (fallible → Error::Parse).
And the payoff — the wrapper tests hermetically with any double:
#[tokio::test]
async fn head_is_trimmed() {
let git = Git::with_runner(
ScriptedRunner::new().on(["rev-parse", "HEAD"], Reply::ok("abc123\n")),
);
assert_eq!(git.head(Path::new("/repo")).await.unwrap(), "abc123");
}…or with a cassette recorded against the real tool once.
Next: Platform support · Supervision · Running commands