Testing zcli applications
zcli provides three tiers of testing, each suited to different verification needs. Use them together for comprehensive coverage.
| Tier | What it tests | Speed | Fidelity |
|---|---|---|---|
| Unit | Command and shared-module logic in isolation | Fast (in-process) | Tests execute() and the helpers it calls, no binary |
| Integration | Full CLI binary via subprocess | Medium | Tests arg parsing, routing, output |
| E2E | Interactive terminal behavior | Slow | Tests prompts, signals, TTY output |
Alongside the tiers there is one test double: HttpFixture, a scripted loopback HTTP server for the code that talks to an API.
Unit testing
Test a single command’s execute() function — or a shared module it delegates to — without compiling or spawning a binary. This is the fastest feedback loop: use it for command logic, output formatting, error handling, and the plain helper functions underneath.
Setup
The unit-testing tier ships with the zcli dependency itself — no separate dependency entry. Projects scaffolded by zcli init are already wired: zcli.addCommandTests(...) in build.zig compiles each command file as its own test root with zcli-testing importable, and does the same for every module in its shared_modules list — so zig build test runs your command tests and your shared helpers’ own tests together. For hand-rolled wiring, import the module from your existing zcli dependency:
// build.zig
test_module.addImport("zcli-testing", zcli_dep.module("zcli_testing_unit"));
The unit tier lives in its own module (zcli_testing_unit) because runCommand runs in-process and so needs zcli + vterm; the subprocess and PTY tiers below live in a separate module that stays free of zcli itself (the PTY tier does use vterm to render child output for frame assertions), so importing them doesn’t drag the framework into your test build.
Writing tests
const std = @import("std");
const testing = @import("zcli-testing");
// Import the command you want to test
const add = @import("commands/add.zig");
test "add command prints confirmation" {
var result = try testing.runCommand(add, .{
.args = .{ .name = "widget" },
.options = .{ .verbose = false },
});
defer result.deinit();
try std.testing.expectEqualStrings("Added widget\n", result.stdout);
try std.testing.expect(result.stderr.len == 0);
try std.testing.expect(result.success);
}
test "add command fails on empty name" {
var result = try testing.runCommand(add, .{
.args = .{ .name = "" },
.options = .{},
});
defer result.deinit();
try std.testing.expect(!result.success);
try std.testing.expectEqual(error.InvalidName, result.err.?);
}
Testing with plugins
A command takes a concrete context: *Context (the type zcli add command scaffolds), and runCommand derives that Context from the command — so your project’s plugins are already in scope. If your command reads plugin data through context.plugins, set that state directly with .plugins:
test "command respects verbose mode" {
var result = try testing.runCommand(list, .{
.args = .{},
.options = .{},
// Keyed by each plugin's `plugin_id`.
.plugins = .{ .verbose = .{ .enabled = true } },
});
defer result.deinit();
// Omit `.plugins` to run against each plugin's ContextData defaults.
try std.testing.expect(result.success);
}
Feeding stdin
.stdin hands the command a deterministic input stream, reachable through context.stdin() and context.prompts(). The injected stream is an in-memory, non-TTY byte stream that reaches EOF once your bytes run out — so a command that asks questions can be driven end to end without a subprocess:
test "setup asks for a name" {
// One line per answer; an empty line takes the prompt's default.
var result = try testing.runCommand(setup, .{ .stdin = "Ada\n\n" });
defer result.deinit();
try std.testing.expect(result.success);
try std.testing.expect(std.mem.indexOf(u8, result.stdout, "hello Ada") != null);
}
Prompts made through context.prompts() take their line-based branch here: they print the question, read one line, and return it. That is the right tier for testing what a command does with an answer, and for the piped/CI path your CLI has to keep working (echo Ada | myapp setup).
The switch is decided by the streams the command was handed, not by the process’s descriptors — context.prompts() reports isInteractive() == false whenever runCommand’s captured stdout or injected stdin is in play. So the test behaves identically under zig build test and when you run the test binary straight from a terminal; a prompt cannot quietly enter raw mode and read your real keyboard. (A command that guards with try p.requireInteractive() therefore fails with error.NotInteractive in a runCommand test — that is the guard working, and such a command belongs in the E2E tier.)
It is deliberately not a terminal session. Raw-mode keystrokes — arrow keys through a select, backspace editing, hidden password input, Ctrl-C — are not modeled by a byte stream and stay with the E2E tier, which drives the binary through a real PTY.
Omit .stdin and the command sees the process’s own stdin, exactly as before. Prompts still take the line branch (stdout is captured either way) and still cannot enter raw mode — but the line they read comes from the real stdin, which at a terminal means the test blocks until someone types. Give any command that prompts an explicit .stdin.
Setting app metadata
A real run fills context.app_name / context.app_version / context.app_description from the registry’s Config; a runCommand context defaults them to "app" / "unknown" / "". Set them when the command — or a plugin it relies on — puts them in its output:
test "version command prints the app version" {
var result = try testing.runCommand(version, .{
.app_name = "myapp",
.app_version = "1.2.3",
});
defer result.deinit();
try std.testing.expectEqualStrings("myapp 1.2.3\n", result.stdout);
}
The values are in place before plugin initContextData hooks run, so a plugin that captures app metadata into its ContextData sees them too — the bundled secrets plugin captures app_name there, which is what namespaces your stored secrets. Omit a field and it keeps its default.
API reference
testing.runCommand(Command, config)
| Parameter | Type | Description |
|---|---|---|
Command | type (comptime) | The command module with Args, Options, and an execute taking a concrete context: *Context |
config.args | Command.Args | Positional arguments to pass |
config.options | Command.Options | Option values to pass |
config.plugins | derived from Context | Initial plugin state, e.g. .{ .verbose = .{ .enabled = true } }; defaults to each plugin’s ContextData defaults |
config.environ | ?*const std.process.Environ.Map | Environment variables the command sees via context.environ; defaults to empty |
config.stdin | ?[]const u8 | Input bytes the command reads via context.stdin() / context.prompts(), as an in-memory stream that ends at EOF; also puts prompts on their line-based branch. Omitted, the command sees the process’s stdin |
config.app_name | ?[]const u8 | App name on the context (context.app_name), set before plugin initContextData runs; defaults to "app" |
config.app_version | ?[]const u8 | App version (context.app_version), set before plugin initContextData runs; defaults to "unknown" |
config.app_description | ?[]const u8 | App description (context.app_description), set before plugin initContextData runs; defaults to "" |
config.allocator | std.mem.Allocator | Defaults to std.testing.allocator |
Returns CommandResult:
| Field | Type | Description |
|---|---|---|
.stdout | []const u8 | Captured standard output (raw, with ANSI codes) |
.stderr | []const u8 | Captured standard error (raw, with ANSI codes) |
.success | bool | true if execute() returned without error |
.err | ?anyerror | The error if execute() failed |
.term | vterm.VTerm | Virtual terminal with stdout rendered — for testing colors, formatting, and positioning |
Always call result.deinit() when done (use defer).
Testing terminal output with VTerm
The result.term field is a virtual terminal that has processed all ANSI escape sequences from stdout. Use it to verify colors, bold/italic formatting, cursor positioning, and rendered text — things you can’t check from raw string output.
test "status shows green checkmark" {
var result = try testing.runCommand(StatusCommand, .{});
defer result.deinit();
// Check rendered text (ANSI codes stripped)
try std.testing.expect(result.term.containsText("All checks passed"));
// Check text is bold
try std.testing.expect(result.term.hasAttribute(0, 0, .bold));
// Check text color is green
const color = result.term.getTextColor(0, 0);
try std.testing.expect(color == .green);
}
Available VTerm assertions:
| Method | Description |
|---|---|
term.containsText("text") | Text appears anywhere on screen |
term.containsTextIgnoreCase("text") | Case-insensitive search |
term.containsPattern("he*o") | Wildcard pattern matching |
term.hasAttribute(x, y, .bold) | Cell has text attribute (bold, italic, underline) |
term.getTextColor(x, y) | Get foreground color at position |
term.getBackgroundColor(x, y) | Get background color at position |
term.cursorAt(x, y) | Cursor is at position |
term.getLine(allocator, y) | Get rendered text of a line |
term.getAllText(allocator) | Get all rendered text |
term.containsTextInRegion("text", x, y, w, h) | Text in specific region |
term.expectRegionEquals(x, y, w, h, "expected") | Region matches exactly |
When to use unit tests
- Testing command output formatting
- Testing error handling and validation
- Testing conditional logic based on args/options
- Testing plugin data interactions
- Testing colors and ANSI formatting with VTerm
- Fast iteration during development
Limitations
- Does not test argument parsing (args are passed directly as typed structs)
- Does not test command routing or discovery
- Does not test global option handling
- Commands that call
std.process.exit()will exit the test runner .stdincovers line input only — raw-mode keystrokes (arrows, backspace, hidden input, Ctrl-C) need the PTY-backed E2E tier
Integration testing
Test your compiled CLI binary as a subprocess. This validates the full stack — argument parsing, command routing, plugin hooks, and output generation.
Setup
The integration tier lives in the framework-free zcli_testing module (separate from the unit tier’s zcli_testing_unit) — it ships with the zcli dependency, so the wiring is one line:
// build.zig
test_module.addImport("zcli-testing", zcli_dep.module("zcli_testing"));
Writing tests
const std = @import("std");
const testing = @import("zcli-testing");
test "help flag shows usage" {
var result = try testing.runSubprocess(
std.testing.allocator,
std.testing.io,
"./zig-out/bin/myapp",
&.{"--help"},
.{},
);
defer result.deinit();
try testing.expectExitCode(result, 0);
try testing.expectContains(result.stdout, "USAGE:");
try testing.expectContains(result.stdout, "COMMANDS:");
}
test "version flag" {
var result = try testing.runSubprocess(
std.testing.allocator,
std.testing.io,
"./zig-out/bin/myapp",
&.{"--version"},
.{},
);
defer result.deinit();
try testing.expectExitCode(result, 0);
try testing.expectContains(result.stdout, "myapp v");
}
test "unknown command shows suggestions" {
var result = try testing.runSubprocess(
std.testing.allocator,
std.testing.io,
"./zig-out/bin/myapp",
&.{"hlep"},
.{},
);
defer result.deinit();
try testing.expectContains(result.stderr, "Unknown command");
try testing.expectContains(result.stderr, "Did you mean");
}
Assertions
All assertion functions take a Result or output string and return !void.
| Function | Description |
|---|---|
expectExitCode(result, code) | Exit code matches exactly |
expectExitCodeNot(result, code) | Exit code does not match |
expectContains(output, needle) | Output contains substring |
expectNotContains(output, needle) | Output does not contain substring |
expectEqualStrings(expected, actual) | Exact string match |
expectValidJson(allocator, output) | Output is valid JSON |
expectStdoutEmpty(result) | stdout has no output |
expectStderrEmpty(result) | stderr has no output |
Snapshot testing
Compare command output against saved golden files. Useful for verifying help text, formatted output, or any output that should remain stable.
test "help output matches snapshot" {
var result = try testing.runSubprocess(
std.testing.allocator,
std.testing.io,
"./zig-out/bin/myapp",
&.{"--help"},
.{},
);
defer result.deinit();
try testing.expectSnapshot(
std.testing.allocator,
std.testing.io,
std.Io.Dir.cwd(),
result.stdout,
@src(),
"help_output",
.{},
);
}
Snapshots are stored in tests/snapshots/{test_file}/{snapshot_name}.txt, resolved against the directory you pass (usually std.Io.Dir.cwd() — the package root when run via zig build test).
Creating and updating snapshots:
Pass .update = true to write snapshots instead of comparing. Thread it from explicit configuration — the idiomatic setup is a build option:
// build.zig
const update_snapshots = b.option(bool, "update-snapshots", "Rewrite snapshot files") orelse false;
const test_options = b.addOptions();
test_options.addOption(bool, "update_snapshots", update_snapshots);
tests.root_module.addOptions("build_options", test_options);
// in the test
try testing.expectSnapshot(allocator, io, std.Io.Dir.cwd(), result.stdout, @src(), "help_output", .{
.update = @import("build_options").update_snapshots,
});
zig build test -Dupdate-snapshots
Snapshot options:
| Option | Default | Description |
|---|---|---|
.mask | true | Replace UUIDs, timestamps, and memory addresses with placeholders |
.ansi | true | Preserve ANSI color codes in snapshots |
.update | false | Write/overwrite the snapshot instead of comparing |
Masking prevents snapshots from breaking due to dynamic content like timestamps or UUIDs.
When to use integration tests
- Testing argument parsing and validation
- Testing command routing (correct command is dispatched)
- Testing global options (–help, –version)
- Testing plugin behavior end-to-end
- Testing exit codes
- Verifying output stability with snapshots
Limitations
- Requires the binary to be built first (
zig buildbeforezig build test) - Slower than unit tests (subprocess overhead)
- Cannot inspect internal state (only stdout, stderr, exit code)
- No TTY — output is piped, so TTY-aware formatting won’t activate
E2E testing
Test interactive terminal behavior with a real pseudo-terminal (PTY). Use this for commands that prompt for input, handle signals, or adapt to terminal size.
Setup
E2E testing is included in the testing package (same dependency as integration testing). Access it via testing.e2e.
Writing tests
const std = @import("std");
const testing = @import("zcli-testing");
test "login prompts for credentials" {
const allocator = std.testing.allocator;
var script = testing.e2e.InteractiveScript.init(allocator);
_ = script
.expect("Username:")
.send("alice")
.expect("Password:")
.sendHidden("secret123")
.expect("Login successful")
.withTimeout(5000);
var result = try testing.e2e.runInteractive(
allocator,
std.testing.io,
&.{"./zig-out/bin/myapp", "login"},
script,
.{ .allocate_pty = true },
);
try std.testing.expect(result.success);
}
test "ctrl-c triggers graceful shutdown" {
const allocator = std.testing.allocator;
var script = testing.e2e.InteractiveScript.init(allocator);
_ = script
.expect("Running...")
.sendSignal(.SIGINT)
.expect("Shutting down gracefully");
var result = try testing.e2e.runInteractive(
allocator,
std.testing.io,
&.{"./zig-out/bin/myapp", "serve"},
script,
.{ .forward_signals = true },
);
try std.testing.expect(result.success);
}
Script builder
The InteractiveScript uses a fluent API to describe a sequence of expected outputs and inputs:
| Method | Description |
|---|---|
.expect(text) | Wait for text to appear in output |
.expectExact(text) | Wait for exact text match |
.send(text) | Send text input |
.sendHidden(text) | Send input without echo (passwords) |
.sendControl(seq) | Send control sequence (.enter, .ctrl_c, .tab, .escape, arrow keys) |
.sendSignal(sig) | Send a signal (.SIGINT, .SIGTERM, .SIGTSTP, .SIGWINCH, etc.) |
.sendRaw(bytes) | Send raw bytes |
.delay(ms) | Wait before next step |
.withTimeout(ms) | Set timeout for current step |
.optional() | Don’t fail if this step doesn’t match |
Configuration
testing.e2e.InteractiveConfig{
.allocate_pty = true, // Use real PTY (vs pipes)
.total_timeout_ms = 30000, // Global timeout
.terminal_mode = .cooked, // .raw, .cooked, or .inherit
.terminal_size = .{ .rows = 24, .cols = 80 },
.disable_echo = false, // Disable echo for password testing
.forward_signals = false, // Forward signals to child process
.save_transcript = false, // Save full interaction log
.echo_input = false, // Debug: echo sent input to stderr
}
Result
testing.e2e.InteractiveResult{
.exit_code: u8,
.output: []const u8, // Captured output
.input: []const u8, // Input sent during interaction (for debugging)
.success: bool, // All script steps matched
.steps_executed: usize, // How many steps ran
.duration_ms: u64, // Total time
.transcript: ?[]const u8, // Full log (if save_transcript=true)
.final_termios: ?posix.termios, // PTY termios after exit (POSIX+PTY only; null on pipes/Windows)
}
Dual-mode testing
Test that your CLI works correctly in both TTY and piped modes:
test "output works in both modes" {
var script = testing.e2e.InteractiveScript.init(allocator);
_ = script.expect("Results:");
const results = try testing.e2e.runInteractiveDualMode(
allocator,
std.testing.io,
&.{"./zig-out/bin/myapp", "list"},
script,
.{},
);
try std.testing.expect(results.tty_result.success);
try std.testing.expect(results.pipe_result.success);
}
When to use E2E tests
- Testing password prompts and masked input
- Testing signal handling (Ctrl+C cleanup, SIGTERM shutdown)
- Testing TTY-aware output (colors, progress bars, column width)
- Testing interactive wizards and menus
- Verifying behavior differs correctly between TTY and pipe modes
Limitations
- Slowest tier (PTY allocation, process spawning, timeouts)
- Platform-dependent (PTY support varies across OS)
- Flaky if timeouts are too tight
- Requires the binary to be built first
Testing HTTP adapters
Commands that talk to an API have a layer none of the three tiers reaches directly: the adapter that builds the request and turns the response into your domain types. HttpFixture is that layer’s test double — a real HTTP server on an ephemeral 127.0.0.1 port that serves the responses you queue and records the requests it received.
Because it is a real socket, the whole client path runs for real — URL building, headers, bodies, status handling — with no network access and nothing to stub out.
Setup
It lives in the same framework-free zcli_testing module as the integration tier:
// build.zig
test_module.addImport("zcli-testing", zcli_dep.module("zcli_testing"));
Writing an adapter test
The code below is copied from packages/testing/examples/http_fixture_example.zig (minus its third test), byte-identical as of this writing. That file is compiled and run by zig build test, so the API used here is real and current; the copy on this page is kept in sync by hand.
const std = @import("std");
const zcli = @import("zcli");
const HttpFixture = @import("zcli-testing").HttpFixture;
// ---------------------------------------------------------------------------
// The code under test: a small adapter over an HTTP API.
// ---------------------------------------------------------------------------
/// The domain type the rest of the CLI works with. It owns its strings, so it
/// outlives the HTTP response they were parsed out of.
const Widget = struct {
id: u32,
name: []u8,
fn deinit(self: *Widget, allocator: std.mem.Allocator) void {
allocator.free(self.name);
self.* = undefined;
}
};
/// Fetch one widget. Exactly the shape of adapter a CLI command would call:
/// it owns the URL layout and the auth header, and it hands back a domain type.
fn fetchWidget(
allocator: std.mem.Allocator,
io: std.Io,
base_url: []const u8,
token: []const u8,
id: u32,
) !Widget {
var client: zcli.http.Client = .init(allocator, io, .{});
defer client.deinit();
const url = try std.fmt.allocPrint(allocator, "{s}/widgets/{d}", .{ base_url, id });
defer allocator.free(url);
const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token});
defer allocator.free(authorization);
var response = try client.request(.GET, url, .{
.headers = &.{.{ .name = "authorization", .value = authorization }},
});
defer response.deinit();
if (response.status != .ok) return error.WidgetFetchFailed;
// Parsed strings can point straight into `response.body`, so copy anything
// the caller keeps before the response (and its body) goes away.
var parsed = try response.json(struct { id: u32, name: []const u8 }, allocator);
defer parsed.deinit();
return .{ .id = parsed.value.id, .name = try allocator.dupe(u8, parsed.value.name) };
}
// ---------------------------------------------------------------------------
// 1. The happy path: script a response, assert on the request that produced it.
// ---------------------------------------------------------------------------
test "fetchWidget sends the token and parses the payload" {
const allocator = std.testing.allocator;
// `init` binds an ephemeral loopback port and starts serving immediately.
// `deinit` stops the serving tasks, closes the socket, and frees every byte
// the fixture handed out — including the URLs from `url()`.
var fixture = try HttpFixture.init(allocator, std.testing.io, .{});
defer fixture.deinit();
try fixture.respondWith(.{
.status = .ok,
.headers = &.{.{ .name = "content-type", .value = "application/json" }},
.body = "{\"id\":7,\"name\":\"sprocket\"}",
});
var widget = try fetchWidget(allocator, std.testing.io, fixture.baseUrl(), "secret-token", 7);
defer widget.deinit(allocator);
try std.testing.expectEqual(@as(u32, 7), widget.id);
try std.testing.expectEqualStrings("sprocket", widget.name);
// Now assert on what the adapter actually put on the wire.
const sent = try fixture.requests();
try std.testing.expectEqual(@as(usize, 1), sent.len);
try std.testing.expectEqual(std.http.Method.GET, sent[0].method);
try std.testing.expectEqualStrings("/widgets/7", sent[0].target);
try std.testing.expectEqualStrings("Bearer secret-token", sent[0].header("authorization").?);
}
// ---------------------------------------------------------------------------
// 2. The failure path: script an error status and check the adapter's mapping.
// ---------------------------------------------------------------------------
test "fetchWidget turns a non-200 into a domain error" {
const allocator = std.testing.allocator;
var fixture = try HttpFixture.init(allocator, std.testing.io, .{});
defer fixture.deinit();
try fixture.respondWith(.{ .status = .not_found, .body = "{\"error\":\"no such widget\"}" });
try std.testing.expectError(
error.WidgetFetchFailed,
fetchWidget(allocator, std.testing.io, fixture.baseUrl(), "secret-token", 404),
);
}
API reference
| Call | Purpose |
|---|---|
HttpFixture.init(allocator, io, options) | Bind an ephemeral loopback port and start serving. Returns a *HttpFixture. |
fixture.deinit() | Stop serving, close the socket, free everything the fixture allocated — including itself. |
fixture.respondWith(.{ .status, .headers, .body }) | Queue one response. All bytes are copied. |
fixture.baseUrl() | http://127.0.0.1:<port>, no trailing slash. Requesting it directly targets /. |
fixture.url("/path") | baseUrl() joined with path. Fixture-owned; freed by deinit. |
try fixture.requests() | A snapshot of every request served so far, oldest first. Fixture-owned; the previous call’s slice is invalidated by the next one. |
Each recorded request carries method, target (the request line’s path and query), headers, body, body_truncated, and a header(name) lookup that is case-insensitive.
Options has two knobs:
concurrency(default4) — how many connections the fixture serves at once, so overlapping requests don’t serialize behind one another.max_request_body_bytes(default64 KiB) — how much of a request body is recorded. Anything past the bound is read and discarded, andbody_truncatedis set, so the code under test can’t turn the fixture into an unbounded buffer.
Ordering and exhaustion
Queued responses are served in the order they were queued, one per request, so a client issuing requests sequentially sees exactly the scripted sequence. Once the queue runs dry, every further request gets HttpFixture.unscripted_status (500) with HttpFixture.unscripted_body — a loud, assertable signal that the test scripted too few responses, rather than a hang.
Limitations
- Plain HTTP only. TLS is out of scope; point the adapter at the fixture’s
http://URL. - The fixture answers every request from the same queue — it does not route on method or path. Assert on
requests()instead. requests()returns a snapshot taken at the moment of the call: requests served afterwards don’t appear in it, and calling it again invalidates the slice the previous call returned. Holding a snapshot across further requests is safe — the fixture’s own recording growing behind it can’t move it.- The allocator must be usable from more than one thread (
std.testing.allocatoris), because the serving tasks run concurrently with your test.
Recommended testing strategy
For most commands
Start with unit tests. They’re fast and cover the majority of logic:
// tests/commands/add_test.zig
test "add creates resource" { ... }
test "add validates name" { ... }
test "add rejects duplicates" { ... }
For the CLI as a whole
Add integration tests for flags, routing, and output:
// tests/integration_test.zig
test "help flag" { ... }
test "version flag" { ... }
test "unknown command" { ... }
test "subcommand routing" { ... }
For interactive features
Add E2E tests only for commands that interact with the terminal:
// tests/e2e_test.zig
test "init wizard" { ... }
test "login flow" { ... }
Snapshot tests for output stability
Use snapshots for any output that users depend on (help text, structured output):
// tests/snapshot_test.zig
test "help output" { ... }
test "json output format" { ... }
Project structure
myapp/
├── src/
│ ├── main.zig
│ └── commands/
│ ├── add.zig
│ └── list.zig
├── tests/
│ ├── unit/
│ │ ├── add_test.zig # Unit tests for add command
│ │ └── list_test.zig # Unit tests for list command
│ ├── integration_test.zig # Subprocess tests
│ ├── e2e_test.zig # Interactive tests
│ └── snapshots/ # Auto-generated snapshot files
│ └── integration_test/
│ └── help_output.txt
├── build.zig
└── build.zig.zon