Testing zcli applications

zcli provides three tiers of testing, each suited to different verification needs. Use them together for comprehensive coverage.

TierWhat it testsSpeedFidelity
UnitCommand logic in isolationFast (in-process)Tests execute() only
IntegrationFull CLI binary via subprocessMediumTests arg parsing, routing, output
E2EInteractive terminal behaviorSlowTests prompts, signals, TTY output

Unit testing

Test a single command’s execute() function without compiling or spawning a binary. This is the fastest feedback loop — use it for command logic, output formatting, and error handling.

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. 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 are std-only and live in separate modules, so importing them doesn’t drag those dependencies 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 output mode" {
    var result = try testing.runCommand(list, .{
        .args = .{},
        .options = .{},
        .plugins = .{ .output = .{ .mode = .json } },
    });
    defer result.deinit();

    // Omit `.plugins` to run against each plugin's ContextData defaults.
    try std.testing.expect(result.success);
}

API reference

testing.runCommand(Command, config)

ParameterTypeDescription
Commandtype (comptime)The command module with Args, Options, and an execute taking a concrete context: *Context
config.argsCommand.ArgsPositional arguments to pass
config.optionsCommand.OptionsOption values to pass
config.pluginsderived from ContextInitial plugin state, e.g. .{ .verbose = .{ .enabled = true } }; defaults to each plugin’s ContextData defaults
config.allocatorstd.mem.AllocatorDefaults to std.testing.allocator

Returns CommandResult:

FieldTypeDescription
.stdout[]const u8Captured standard output (raw, with ANSI codes)
.stderr[]const u8Captured standard error (raw, with ANSI codes)
.successbooltrue if execute() returned without error
.err?anyerrorThe error if execute() failed
.termvterm.VTermVirtual 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:

MethodDescription
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

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 std-only 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.

FunctionDescription
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:

OptionDefaultDescription
.masktrueReplace UUIDs, timestamps, and memory addresses with placeholders
.ansitruePreserve ANSI color codes in snapshots
.updatefalseWrite/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, –output)
  • Testing plugin behavior end-to-end
  • Testing exit codes
  • Verifying output stability with snapshots

Limitations

  • Requires the binary to be built first (zig build before zig 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:

MethodDescription
.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
    .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)
}

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,
        &.{"./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

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