cmdtest v0.2.0

This blog post highlight my experience and stuff that I have learned while writing cmdtest.

cmdtest is a Zig package that you can use to test your CLI app.

Here is how you can install and use the cmdtest:

  1. Fetch the latest release:

    Shell
    zig fetch --save=cmdtest https://github.com/pyk/cmdtest/archive/v0.2.0.tar.gz

    This updates build.zig.zon.

  2. Write your test file. Example: test/mycli.zig.

    Zig
    const std = @import("std");
    const cmdtest = @import("cmdtest");
    const testing = std.testing;
    
    test "via exe name" {
        const argv = &[_][]const u8{"mycli"};
        var result = try cmdtest.run(.{ .argv = argv });
        defer result.deinit();
    
        try testing.expectEqualStrings("project-exe", result.stderr);
    }
    
    test "via path" {
        const argv = &[_][]const u8{"./zig-out/bin/mycli"};
        var result = try cmdtest.run(.{ .argv = argv });
        defer result.deinit();
    
        try testing.expectEqualStrings("project-exe", result.stderr);
    }
  3. Register the test in build.zig:

    Zig
     const std = @import("std");
     const cmdtest = @import("cmdtest");
    
     pub fn build(b: *std.Build) void {
         const target = b.standardTargetOptions(.{});
    
         // Your CLI
         const cli = b.addExecutable(.{
             .name = "mycli",
             .root_module = b.createModule(.{
                 .root_source_file = b.path("src/main.zig"),
                 .target = target,
             }),
         });
         b.installArtifact(cli);
    
         // Register new test
         const cli_test = cmdtest.add(b, .{
             .name = "mycli",
             .test_file = b.path("test/mycli.zig"),
         });
    
         const test_step = b.step("test", "Run tests");
         test_step.dependOn(&cli_test.step);
     }
  4. Run the tests:

    Shell
    zig build test --summary all

See minimal Zig project cmdtest-example.

Learning about I/O

The first thing that I have learned while publishing the package is about the I/O.

Zig 0.15.2 introduces std.Io.Writer which if you are dealing with the stdin, stdout and stderr, the std.Io.Writer is your friend.

Here is how to write to stdout using std.Io.Writer:

Zig
var buf: [1024]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const io = &writer.interface;
try io.writeAll("Hello\n");
try io.flush();

The writer.interface is caught me offguard. I thought I can simply do this:

Zig
var writer = std.fs.File.stdout().writer(&buf);
try writer.writeAll("Hello");
try writer.flush();

however its not the case.

I also learned that the buf is not the output, its like staging area before bytes got flushed to the target.

Comptime vs Runtime Values

The second thing that I have learned is about comptime value vs runtime value.

In cmdtest I have use case where I want to write syntax sugar for array of string literal for argv.

Here is how to write array of string literal in zig:

Zig
const argv = &[_][]const u8{"echo", "hello"};

I thought, can I simply write cmd("echo", "hello") and automatically in zig?

The answer is Zig does not support variadic arguments. So it does not work.

Then lets try to use tuple: cmd(.{"echo", "hello"}).

Well it looks better, but can we implement it?

Zig
pub fn cmd(comptime argv: anytype) []const []const u8 {
    const len = comptime {
        // Get length at compile time for tuple here
    }

    var arr: [len][]const u8 = undefined;
    comptime var i: usize = 0;
    while (i < len) : (i += 1) {
        arr[i] = argv[i];
    }
    return arr[0..len];
}

This trick work if the tuple have only one element, however it will not work if the tuple have more than one element. It’s segfault. I believe this caused by the fact that arr lifetime is tied to the cmd function.

build.zig Import Behavior

Third thing that I have learned is about the import behaviour in build.zig.

I thought when I write the @import("cmdtest") in build.zig it will automatically uses the src/root.zig of my package.

Turns out I was wrong. If you import package from your build.zig it will get the build.zig of the package.

So previously I defined the add function inside src/root.zig, now the add function is in build.zig

build.zig
pub const AddOptions = struct {
    /// Name of the test target
    name: []const u8,
    /// Path to the test source file
    test_file: Build.LazyPath,
    /// The `cmdtest` build module to import into the test
    cmdtest_mod: ?*Build.Module = null,
};

/// Register new test
pub fn add(b: *Build, options: AddOptions) *Build.Step.Run {
    const cmdtest_mod = if (options.cmdtest_mod) |mod|
        mod
    else
        b.dependency("cmdtest", .{
            .target = b.graph.host,
        }).module("cmdtest");

    // Create the test module that imports the runtime module
    const test_mod = b.createModule(.{
        .root_source_file = options.test_file,
        .target = b.graph.host,
        .imports = &.{
            .{
                .name = "cmdtest",
                .module = cmdtest_mod,
            },
        },
    });

    // Create the test executable compilation step
    const test_exe = b.addTest(.{
        .name = options.name,
        .root_module = test_mod,
    });
    const run_test_exe = b.addRunArtifact(test_exe);

    // IMPORTANT: Make sure all exe are installed first
    run_test_exe.step.dependOn(b.getInstallStep());

    const original_path = b.graph.env_map.get("PATH") orelse "";
    const path = b.fmt("{s}{c}{s}", .{
        b.exe_dir,
        std.fs.path.delimiter,
        original_path,
    });

    run_test_exe.setEnvironmentVariable("PATH", path);

    return run_test_exe;
}

And that’s it. I love zig because its very explicit about the memory allocation and how they provide std.testing.allocator to catch memory management mistake early in the development.