Questions on CLI application (unit testing and design)

I’m working on a CLI app without using external dependencies and have a rough sketch in mind.

The CLI is going to work like:

zig run src/main.zig -- --create --name "foo" --due "today" --priority 8
zig run src/main.zig -- --delete --name "foo"

and its outline looks like:

const Action = enum {
    default,
    help,
    create,
    delete,
};

const ActionMap: StaticStringMap(Action) = .initComptime(.{
    .{ "--help", .help },
    .{ "--list", .list },
    .{ "--create", .create },
    .{ "--delete", .delete },
});

const CreateTodoOption = enum {
    name,
    due,
    priority,
};

const CreateTodoOptions: StaticStringMap(CreateTodoOption) = .initComptime(.{
    .{ "--name", .name },
    .{ "--due", .due },
    .{ "--priority", .priority },
});

const Args = struct {
	action: Action   // Action is an enum
	create: CreateArgs  // CreateArgs is a struct 
	delete: DeleteArgs  // DeleteArgs is a struct

	pub fn init() Args {}

	pub fn parse(self: *Args, gpa: Allocator, out: Io.Writer, iter: *ArgIterator) !Args {
		// create output buffer
		// skip program name
		// iterate over the arguments
		// // use static string map to get enum from string
		// // switch based on enum by calling a different parse method in the relevant struct (CreateArgs or DeleteArgs)
		// return the modified Args object 
	}
}}

const CreateArgs = struct {
	name: ?[]const u8,
	due: ?[]const u8,
	priority: u16,

	pub fn parse(self: *CreateArgs, gpa: Allocator, out: Io.Writer, iter: *ArgIterator, arg: []const u8) !CreateArgs {
		// Using parameter `arg`, get enum value from `CreateTodoOptions` 
		// switch based on value by (check for errors)
		// return the modified Args object 
	}
}

// ...
// Delete related things will be similar to Create
// ...

Questions:

How can I unit test the Args struct? Haven’t found an example on testing std.process.ArgIterator.
The idea is for the help menu text to have usage information and for unit testing to cover them and document behavior like no argument provided, duplicate argument, etc.

Is there a better way to design the app?

( No change was made to build.zig after zig init )

Make an alias for ArgIterator that you then refer to instead of directly accessing std.process.ArgIterator.

You can then change that to a mock implementation when built for testing that you can inject with whatever args you want for your tests.

Just make sure it has the same API, you’ll get a compiler error if it doesn’t anyway.

Is there an outline you can share of just the function signature ?

Can you elaborate, I’m not sure what you are asking.

On a meta-level, I would recommend first getting at least a single end-to-end use-case of the entire app fully working (the simplest possible use-case, but end-to-end), and then thinking about testing strategy later, before expanding that to cover more complex use-cases.

The description so far feels to me a bit over engineered, it feels a bit like you are trying to solve the first problem that comes to mind, rather than the most salient problem you identified in practice. Not saying this is necessarily wrong, but I personally usually approach problems in the more incremental way initially, even if there’s going to be a bit of the waterfall second implementation, once I am convinced I understand what are he actual hard parts of the problem (see also Setting goals).

That being said, I usually don’t unit-test CLI, and instead write integration tests for it (so, spawning an entire process), for several reasons:

  • (for software that is released to the users) CLI is a part of public stable interface, so I am going to need some end-to-end stability tests there anyway, just in case.
  • There’s a lot of fiddly details with getting arguments from OS and emitting errors, and, while they can be abstracted, there’s a relatively poor return on investment there.
  • There’s usually not that much to test there, so a few full-process tests do the trick.

Consider also writing a general purpose CLI-parsing library, testing that, and then rely on the glue code being simple enough to not be buggy. That’s what we do at TigerBeetle:

1 Like

Conditionally define ArgIterator as different types depending on the build mode:

pub const ArgIterator = if (@import("builtin").is_test)
    std.process.ArgIteratorGeneral
else
    std.process.ArgIterator;

pub fn parse(self: *Args, gpa: Allocator, out: Io.Writer, iter: *ArgIterator) !Args;

Or, use anytype duck typing:

pub fn parse(
    self: *Args,
    gpa: Allocator,
    out: Io.Writer,
    /// Should be `*std.process.ArgIterator` or something with an equivalent API.
    iter: anytype,
) !Args;

Either approach will let you write unit tests that mock OS functionality like argv and stderr. I would recommend the latter anytype approach because it provides the greatest flexibility for API consumers and lets them e.g. use ArgIteratorGeneral for parsing response files (files with command-line arguments, to get around command-line length limitations).

For testing against the real OS, this post has details on how how to write integration tests that test what is written to stdout/stderr:

(Note that it’s from January 2024, so the exact APIs might have changed a bit.)

1 Like