Skip to content
Select theme

Testing a CLI without a network

This step finishes: the part of hit nothing has covered so far — proof that it keeps working. Ten steps in, the tool parses arguments, sends requests, reads ~/.hit.toml, replays saved requests and runs batches concurrently, and every change has been checked by hand against live URLs like httpbin.org. Hand-checking stops scaling the moment two changes interact. This step gives the tool a test suite that needs no network: assert_cmd drives the real binary, wiremock answers in place of a server, and cargo test runs all five tests in a fraction of a second — offline, on your machine and on a CI runner.

You need the project as the previous article left it. If you are starting from scratch, that state is tagged cli-ch10 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch10

hit is a binary crate, and the behaviour worth testing is its process surface: exit codes, stdout, stderr. That surface crosses a process boundary, so the tests live in tests/cli.rs — cargo compiles every file in tests/ as its own crate, and before running them it builds the binary and hands each test crate the path to it. Unit tests inside main.rs would only reach Rust functions; integration tests reach hit the way a user does.

Terminal window
cargo add --dev assert_cmd
cargo add --dev predicates
cargo add --dev tempfile
cargo add --dev wiremock
Cargo.toml
[dev-dependencies]
assert_cmd = "2.2.2"
predicates = "3.1.4"
tempfile = "3.27.0"
wiremock = "0.6.5"

[dev-dependencies] is the section that matters here. Cargo compiles these crates for tests, examples and benchmarks only — cargo build --release never includes them, so people who install the binary don’t pay for the test stack. Declaring assert_cmd under [dependencies] instead compiles fine and quietly bloats every release build.

What each crate is for:

  • assert_cmd runs a binary as a child process and asserts on the result: exit status, stdout, stderr.
  • predicates supplies the matchers those assertions compare against — assert_cmd doesn’t re-export it, so both crates go in Cargo.toml.
  • wiremock is an HTTP server that exists only inside the test process and answers only what you told it to answer.
  • tempfile hands every test a fresh directory, so a real ~/.hit.toml on the machine running the tests can never leak into a result.

Before wiring any of this into hit, watch wiremock run on its own. Make a scratch project — cargo new mock-demo — and give it every crate this article touches:

mock-demo/Cargo.toml
[dependencies]
assert_cmd = "2.2.2"
predicates = "3.1.4"
reqwest = "0.13.5"
tempfile = "3.27.0"
tokio = { version = "1.53.3", features = ["macros", "rt-multi-thread"] }
wiremock = "0.6.5"

A scratch project has no tests to guard, so the test crates sit under plain [dependencies] here. tempfile stays unused until the real tests; it is listed so any block below compiles in the scratch project as-is. Paste this into src/main.rs:

use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::main]
async fn main() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/hello"))
.respond_with(ResponseTemplate::new(200).set_body_string("hello from the mock"))
.mount(&server)
.await;
let body = reqwest::get(format!("{}/hello", server.uri()))
.await
.unwrap()
.text()
.await
.unwrap();
println!("mock at {}", server.uri());
println!("body: {body}");
}
Terminal window
cargo run
mock at http://127.0.0.1:58400
body: hello from the mock

The port changes every run — MockServer::start() binds 127.0.0.1 on whatever free port the OS hands it, and server.uri() reports where it landed. Mock::given(...) describes the requests to answer: GET, path /hello. respond_with is the canned reply, and mount installs the rule. Everything a real server does except be real: it answers until the value is dropped.

assert_cmd needs no project of its own to show its shape — it can drive any program on PATH:

use assert_cmd::Command;
use predicates::prelude::predicate;
fn main() {
Command::new("git")
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains("git version"));
println!("assertions on a real process, no test harness needed");
}
assertions on a real process, no test harness needed

.assert() runs the child and returns an Assert whose methods — .success(), .stdout(...), .stderr(...) — each check one thing and panic with a readable diff when one fails. The git --version line lands on your machine’s version of git; the assertion only asks that the output contains git version.

Now the real file, tests/cli.rs:

tests/cli.rs
use assert_cmd::Command;
use predicates::prelude::predicate;
use tempfile::tempdir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Every test runs hit with HOME pointed at an empty temp directory, so a
/// real ~/.hit.toml or a real saved request can never leak into a test.
fn hit(home: &std::path::Path) -> Command {
let mut command = Command::cargo_bin("hit").unwrap();
command.env("HOME", home);
command
}
#[test]
fn no_subcommand_prints_usage() {
let home = tempdir().unwrap();
hit(home.path())
.assert()
.failure()
.stderr(predicate::str::contains("Usage:"));
}
#[test]
fn a_broken_header_fails_before_the_network() {
let home = tempdir().unwrap();
hit(home.path())
.args(["get", "http://127.0.0.1:9/nowhere", "-H", "no colon here"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid --header"));
}
#[tokio::test]
async fn get_reports_the_status_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_string("hello from the mock"))
.mount(&server)
.await;
let home = tempdir().unwrap();
hit(home.path())
.args(["get", &server.uri()])
.assert()
.success()
.stdout(predicate::str::contains("200 OK"))
.stdout(predicate::str::contains("hello from the mock"));
}
#[tokio::test]
async fn run_replays_a_saved_request() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/weather"))
.respond_with(ResponseTemplate::new(200).set_body_string("18C, overcast"))
.mount(&server)
.await;
let home = tempdir().unwrap();
let url = format!("{}/weather", server.uri());
hit(home.path())
.args(["save", "weather", &url])
.assert()
.success()
.stdout(predicate::str::contains("saved weather"));
hit(home.path())
.args(["run", "weather"])
.assert()
.success()
.stdout(predicate::str::contains("18C, overcast"));
}
#[tokio::test]
async fn a_batch_reports_every_request() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/feed"))
.respond_with(ResponseTemplate::new(200).set_body_string("fresh"))
.mount(&server)
.await;
let home = tempdir().unwrap();
let url = format!("{}/feed", server.uri());
hit(home.path())
.args(["save", "feed-a", &url])
.assert()
.success();
hit(home.path())
.args(["save", "feed-b", &url])
.assert()
.success();
hit(home.path())
.args(["run", "feed-a", "feed-b"])
.assert()
.success()
.stdout(predicate::str::contains("feed-a"))
.stdout(predicate::str::contains("feed-b"))
.stdout(predicate::str::contains("200 OK"));
}

The hit() helper does two jobs. Command::cargo_bin("hit") resolves the binary cargo just built — cargo sets CARGO_BIN_EXE_hit when it compiles the test crate, so the path is always current and no test depends on where the binary was installed. The env("HOME", home) line gives every child process a scratch home directory, because hit reads ~/.hit.toml on startup and writes saved requests under the data dir; without the override, a test could pass on your machine and fail on one with a config file.

One trap sits inside that helper. The chain reads like it should return a Command:

use assert_cmd::Command;
fn hit(home: &std::path::Path) -> Command {
Command::cargo_bin("hit").unwrap().env("HOME", home)
}
fn main() {}
error[E0308]: mismatched types

env() returns &mut Command — the builder methods mutate in place so they can be chained. The helper binds let mut command, calls command.env(...), and returns the owned Command.

The first two tests need no server at all. Bare hit makes clap print its usage to stderr and exit with code 2 — exactly what the test asks for with .failure() and a stderr check:

A tiny command-line HTTP client
Usage: hit [OPTIONS] <COMMAND>
Commands:
get Send a GET request
post Send a POST request with a body
save Save a GET request to reuse later
run Send saved requests; more than one runs concurrently
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Print what we are about to do
-h, --help Print help
-V, --version Print version

A malformed -H is rejected in build_request() before any connection attempt, so the test can point the URL at a dead port — 127.0.0.1:9 — and know it is never contacted:

hit: invalid --header "no colon here": expected "Name: value"

The exit code is 1, from the error path the error-handling step built.

The three async tests follow the same recipe: start a mock, teach it one response, then assert on what hit prints. #[tokio::test] wraps each test in its own runtime, and plain is enough — wiremock runs its HTTP server on its own OS thread with its own runtime, so the child process blocking the test’s thread cannot starve it. get_reports_the_status_line checks the two things a user reads first: the status line and the body. run_replays_a_saved_request saves under the scratch HOME and replays it, which exercises the JSON round-trip from the save-and-reuse step against a server that cannot flake. a_batch_reports_every_request saves two names pointing at the same mock and runs them together, asserting the batch prints one line per name.

Terminal window
cargo test
Compiling hit v0.1.0 (/Users/rango/www/hackingwithrust/code/cli-tool)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.54s
Running unittests src/main.rs (target/debug/deps/hit-2b2f00e706502a97)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/cli.rs (target/debug/deps/cli-b1163ef42bdf40bb)
running 5 tests
test no_subcommand_prints_usage ... ok
test a_broken_header_fails_before_the_network ... ok
test get_reports_the_status_line ... ok
test run_replays_a_saved_request ... ok
test a_batch_reports_every_request ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s

The first run compiles the four test crates too, so it takes longer than this; after that, the whole suite runs in well under a second. The running 0 tests section is the binary crate itself — hit has no unit tests, all of its coverage is the integration file.

Pull the network cable and the suite still passes: the only address any test touches is 127.0.0.1. That is the whole point of wiremock — a test that talks to httpbin.org fails when the site rate-limits a CI runner, and it fails differently on every retry. A test that talks to a mock fails only when the code is wrong.

One failure mode to recognise early: wiremock answers 404 Not Found to any request no Mock matches. If an assertion on 200 OK suddenly fails, read the diff before suspecting hit — most of the time the mock’s path() or method() matcher stopped matching a URL the code changed.

Because the suite is offline, CI needs nothing beyond a Rust toolchain. This is .github/workflows/ci.yml, committed with the code:

.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo test

No database, no service containers, no retry logic. If you want reproducible toolchains, swap @stable for a pinned version or commit a rust-toolchain.toml; the tests don’t care either way.

This step’s end state is tagged cli-ch11 in the examples repository — the project exactly as this article leaves it:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch11

If your terminal shows something different from what this article describes, checking out the tag and diffing it against your own tree shows which line drifted.

The tool works and now it is guarded. Next the track ships it: release profiles, stripping debug symbols, and the paths from a local binary to something other people can install.

← Back to the track overview