@bunli/test
Testing utilities for Bunli CLI applications
Installation
bash bun add -d @bunli/test
bash npm install -D @bunli/test
bash pnpm add -D @bunli/test
Features
- ๐งช Test individual commands or entire CLIs
- ๐ญ Mock user prompts and shell commands
- โ Built-in test matchers for CLI output
- ๐ Support for validation and retry scenarios
- ๐ TypeScript support with full type inference
- โก Fast execution with isolated test environments
- ๐จ Color output preserved as tags for easy testing
- ๐ Zero dependencies, works seamlessly with Bun's test runner
Basic Usage
Import testing utilities
import { test, expect } from "bun:test";
import { testCommand, expectCommand } from "@bunli/test";Test a simple command
import { defineCommand } from "@bunli/core";
const greetCommand = defineCommand({
name: "greet",
description: "Greet someone",
handler: async ({ colors }) => {
console.log(colors.green("Hello, world!"));
},
});
test("greet command", async () => {
const result = await testCommand(greetCommand);
expectCommand(result).toHaveSucceeded();
expectCommand(result).toContainInStdout("[green]Hello, world![/green]");
});Verify the output
The test utilities preserve color codes as tags for easy assertion:
colors.green('text')โ[green]text[/green]colors.bold('text')โ[bold]text[/bold]colors.dim('text')โ[dim]text[/dim]- All ANSI escape codes are converted to readable tags
Testing Commands with Options
Test commands that accept flags and arguments:
const deployCommand = defineCommand({
name: "deploy",
options: {
env: option(z.enum(["dev", "staging", "prod"]), { description: "Target environment" }),
force: option(z.coerce.boolean().default(false), { description: "Force deployment" }),
},
handler: async ({ flags }) => {
console.log(`Deploying to ${flags.env}${flags.force ? " (forced)" : ""}`);
},
});
test("deploy with flags", async () => {
const result = await testCommand(deployCommand, {
flags: { env: "prod", force: true },
});
expect(result.stdout).toContain("Deploying to prod (forced)");
expect(result.exitCode).toBe(0);
});
// Test validation errors
test("deploy validates environment", async () => {
const result = await testCommand(deployCommand, {
flags: { env: "invalid" as any },
});
expectCommand(result).toHaveFailed();
expect(result.stderr).toContain("[red]Validation errors:[/red]");
expect(result.stderr).toContain("--env:");
});Mocking User Interactions
Mock Prompts
Test interactive commands by mocking user responses:
import { mockPromptResponses } from "@bunli/test";
const setupCommand = defineCommand({
name: "setup",
handler: async ({ prompt }) => {
const name = await prompt("Project name:");
const useTs = await prompt.confirm("Use TypeScript?");
const db = await prompt.select("Database:", {
options: ["postgres", "mysql", "sqlite"],
});
console.log(`Creating ${name} with ${db}${useTs ? " and TypeScript" : ""}`);
},
});
test("interactive setup", async () => {
const result = await testCommand(
setupCommand,
mockPromptResponses({
"Project name:": "my-app",
"Use TypeScript?": "y",
"Database:": "1", // Select first option (postgres)
}),
);
expect(result.stdout).toContain("Creating my-app with postgres and TypeScript");
});
// Test select menu display
test("shows select options", async () => {
const result = await testCommand(
setupCommand,
mockPromptResponses({
"Project name:": "test",
"Use TypeScript?": "n",
"Database:": "2", // Select second option
}),
);
// Verify menu was displayed
expect(result.stdout).toContain("1. postgres");
expect(result.stdout).toContain("2. mysql");
expect(result.stdout).toContain("3. sqlite");
});Mock Shell Commands
Test commands that execute shell operations:
import { mockShellCommands } from "@bunli/test";
const statusCommand = defineCommand({
name: "status",
handler: async ({ shell }) => {
const branch = await shell`git branch --show-current`.text();
const hasChanges = await shell`git status --porcelain`.text();
console.log(`Branch: ${branch.trim()}`);
console.log(`Status: ${hasChanges ? "Modified" : "Clean"}`);
},
});
test("git status", async () => {
const result = await testCommand(
statusCommand,
mockShellCommands({
"git branch --show-current": "feature/awesome\n",
"git status --porcelain": "M src/index.ts\n",
}),
);
expect(result.stdout).toContain("Branch: feature/awesome");
expect(result.stdout).toContain("Status: Modified");
});Testing Validation
Test commands with input validation and retry logic:
const emailCommand = defineCommand({
name: "register",
handler: async ({ prompt }) => {
const email = await prompt("Enter email:", {
schema: z.string().email(),
});
console.log(`Registered: ${email}`);
},
});
test("email validation with retries", async () => {
const result = await testCommand(
emailCommand,
mockPromptResponses({
// Provide multiple attempts - first two fail, third succeeds
"Enter email:": ["invalid", "still@bad", "valid@email.com"],
}),
);
// Check validation errors appear in order
expect(result.stderr).toContain("[red]Invalid input:[/red]");
expect(result.stderr).toContain("[dim] โข Invalid email[/dim]");
// Verify final success
expect(result.stdout).toContain("Registered: valid@email.com");
expectCommand(result).toHaveSucceeded();
});
// Test password validation
test("password masking and validation", async () => {
const passwordCommand = defineCommand({
handler: async ({ prompt }) => {
const pass = await prompt.password("Enter password:", {
schema: z.string().min(8),
});
console.log("Password accepted");
},
});
const result = await testCommand(
passwordCommand,
mockPromptResponses({
"Enter password:": ["short", "validpassword123"],
}),
);
// Password input is masked with asterisks
expect(result.stdout).toContain("*****"); // 'short' masked
expect(result.stdout).toContain("****************"); // 'validpassword123' masked
expectCommand(result).toHaveSucceeded();
});Testing Complete CLIs
Test entire CLI applications with multiple commands:
import { createCLI } from "@bunli/core";
import { testCLI } from "@bunli/test";
test("CLI help command", async () => {
const result = await testCLI(
(cli) => {
cli.command({
name: "hello",
description: "Say hello",
handler: async () => console.log("Hello!"),
});
cli.command({
name: "goodbye",
description: "Say goodbye",
handler: async () => console.log("Goodbye!"),
});
},
["--help"],
);
expectCommand(result).toContainInStdout("Say hello");
expectCommand(result).toContainInStdout("Say goodbye");
});
test("run specific command", async () => {
const result = await testCLI(
(cli) => {
// ... setup commands
},
["hello"],
{ flags: { verbose: true } },
);
expect(result.stdout).toContain("Hello!");
});Test Matchers
Bunli provides specialized matchers for CLI testing:
Exit Code Matchers
// Check specific exit code
expectCommand(result).toHaveExitCode(0);
expectCommand(result).toHaveExitCode(1);
// Convenience matchers
expectCommand(result).toHaveSucceeded(); // exit code 0
expectCommand(result).toHaveFailed(); // exit code !== 0Output Matchers
// String contains
expectCommand(result).toContainInStdout("success message");
expectCommand(result).toContainInStderr("error message");
// Regex matching
expectCommand(result).toMatchStdout(/deployed to .+ successfully/);
expectCommand(result).toMatchStderr(/failed: .+/);
// Negative assertions
expectCommand(result).not.toContainInStdout("error");Advanced Testing
Combine Multiple Mocks
Use mockInteractive to combine prompt and shell mocks:
import { mockInteractive } from "@bunli/test";
test("complex interaction", async () => {
const result = await testCommand(
myCommand,
mockInteractive(
{
"Project name:": "awesome-cli",
"Initialize git?": "y",
},
{
"git init": "",
"git add .": "",
'git commit -m "Initial commit"': "",
},
),
);
expectCommand(result).toHaveSucceeded();
});Merge Test Options
Combine multiple test configurations:
import { mergeTestOptions } from "@bunli/test";
test("with merged options", async () => {
const result = await testCommand(
myCommand,
mergeTestOptions({ flags: { verbose: true } }, mockPromptResponses({ "Name:": "Test" }), {
env: { NODE_ENV: "test" },
}),
);
});Test Spinner States
Mock and verify spinner operations:
test("spinner states", async () => {
const command = defineCommand({
handler: async ({ spinner }) => {
const spin = spinner("Processing...");
spin.start();
spin.update("Almost done...");
spin.succeed("Complete!");
// Test other states
const spin2 = spinner("Checking...");
spin2.start();
spin2.fail("Failed!");
const spin3 = spinner("Warning test");
spin3.start();
spin3.warn("Warning!");
const spin4 = spinner("Info test");
spin4.start();
spin4.info("Information");
},
});
const result = await testCommand(command);
expect(result.stdout).toContain("โ Processing...");
expect(result.stdout).toContain("โ Almost done...");
expect(result.stdout).toContain("โ
Complete!");
expect(result.stdout).toContain("โ Failed!");
expect(result.stdout).toContain("โ ๏ธ Warning!");
expect(result.stdout).toContain("โน๏ธ Information");
});API Reference
testCommand
Test a single command:
function testCommand(command: Command, options?: TestOptions): Promise<TestResult>;Options:
flags- Command flags to pass (type-safe based on command options)args- Positional argumentsenv- Environment variables to merge with process.envcwd- Working directory (defaults to process.cwd())stdin- Input lines for non-prompt stdin (string or array) - Note: only supported bytestCommand, notcreateTestCLImockPrompts- Map of prompt messages to responses (string or array for retries) - Note: only supported bytestCommand, notcreateTestCLImockShellCommands- Map of shell commands to their outputexitCode- Expected exit code (for testing error scenarios)
Returns:
stdout- Standard outputstderr- Standard errorexitCode- Process exit codeduration- Execution time in mserror- Error if command threw
testCLI
Test a complete CLI:
function testCLI(
setupFn: (cli: CLI) => void,
argv: string[],
options?: TestOptions,
): Promise<TestResult>;Helper Functions
// Mock prompt responses
mockPromptResponses(responses: Record<string, string | string[]>)
// Mock shell command outputs
mockShellCommands(commands: Record<string, string>)
// Combine prompts and shell mocks
mockInteractive(prompts: Record<string, string>, commands?: Record<string, string>)
// Create stdin for validation testing
mockValidationAttempts(attempts: string[])
// Merge multiple test options
mergeTestOptions(...options: Partial<TestOptions>[])Best Practices
Color Output: Test utilities preserve colors as tags (e.g., [green]text[/green]) making it
easy to assert colored output without ANSI codes. This works automatically - no configuration
needed.
Shell Mock Defaults: The test utilities provide sensible defaults for common shell commands: -
git branch --show-current โ main\n - git status โ nothing to commit, working tree clean\n
Override these by providing your own mock values.
-
Test both success and failure cases:
test("handles missing file", async () => { const result = await testCommand(readCommand, { args: ["nonexistent.txt"], }); expectCommand(result).toHaveFailed(); expectCommand(result).toContainInStderr("File not found"); }); -
Use descriptive test names:
test("deploy command deploys to production with --force flag", async () => { // ... }); -
Mock external dependencies:
// Don't actually hit external APIs or modify files mockShellCommands({ "curl https://api.example.com": '{"status": "ok"}', }); -
Test validation scenarios:
// Provide multiple attempts for validation mockPromptResponses({ "Port:": ["abc", "99999", "3000"], // Test invalid โ invalid โ valid }); -
Verify side effects:
// Check that commands were called const result = await testCommand(deployCommand); expect(result.stdout).toContain("git push origin main");
Common Patterns
Testing Schema Errors
test("handles schema validation errors", async () => {
const result = await testCommand(deployCommand, {
flags: { env: "qa" as any }, // Invalid enum value
});
expectCommand(result).toHaveFailed();
expect(result.stderr).toContain("[red]Validation errors:[/red]");
expect(result.stderr).toContain("[yellow] --env:[/yellow]");
expect(result.stderr).toContain("Expected 'dev' | 'staging' | 'prod'");
});Testing Shell JSON Output
test("parses JSON from shell commands", async () => {
const command = defineCommand({
handler: async ({ shell }) => {
const data = await shell`curl https://api.example.com`.json();
console.log(`Users: ${data.users.length}`);
},
});
const result = await testCommand(
command,
mockShellCommands({
"curl https://api.example.com": JSON.stringify({ users: [{}, {}, {}] }),
}),
);
expect(result.stdout).toContain("Users: 3");
});Testing Progress Updates
test("shows progress during long operations", async () => {
const result = await testCommand(buildCommand);
// Verify progress messages appear in order
const output = result.stdout;
const buildIndex = output.indexOf("Building...");
const optimizeIndex = output.indexOf("Optimizing...");
const completeIndex = output.indexOf("โ Build complete");
expect(buildIndex).toBeLessThan(optimizeIndex);
expect(optimizeIndex).toBeLessThan(completeIndex);
});Testing Interactive Flows
test("wizard completes full flow", async () => {
const result = await testCommand(
wizardCommand,
mockInteractive(
{
"Project name:": "my-app",
"Choose template:": "typescript",
"Install dependencies?": "y",
},
{
"npm install": "added 150 packages",
"git init": "Initialized empty Git repository",
},
),
);
expectCommand(result).toHaveSucceeded();
expect(result.stdout).toContain("Project created successfully");
});Testing with Generated Types
When using type generation, you can test the generated types themselves and CLI wrappers that use them:
// test/generated-types.test.ts
import { test, expect } from "bun:test";
import { getCommandApi, listCommands } from "../commands.gen";
test("generated types are valid", () => {
const commands = listCommands();
expect(commands.length).toBeGreaterThan(0);
// Test specific command
const greetApi = getCommandApi("greet");
expect(greetApi.description).toBe("A friendly greeting");
expect(greetApi.options).toHaveProperty("name");
});
test("command discovery works", () => {
const commands = listCommands();
const commandNames = commands.map((c) => c.name);
expect(commandNames).toContain("greet");
});
test("command options are properly typed", () => {
const greetApi = getCommandApi("greet");
// Test option structure
expect(greetApi.options.name.type).toBe("string");
expect(greetApi.options.name.required).toBe(true);
expect(greetApi.options.excited.type).toBe("boolean");
expect(greetApi.options.excited.default).toBe(false);
});Testing CLI Wrappers
Test CLI wrappers that use generated types:
// test/cli-wrapper.test.ts
import { test, expect } from "bun:test";
import { DevToolsCLI } from "../src/cli-wrapper";
import { getCommandApi } from "../commands.gen";
test("CLI wrapper validates commands", async () => {
const cli = new DevToolsCLI();
// Test valid command
await expect(cli.executeCommand("greet", { name: "Alice" })).resolves.not.toThrow();
// Test invalid command
await expect(cli.executeCommand("nonexistent", {})).rejects.toThrow(
"Unknown command: nonexistent",
);
// Test invalid options
await expect(cli.executeCommand("greet", { name: 123 })).rejects.toThrow(
"Option name must be a string",
);
});
test("command discovery works", () => {
const cli = new DevToolsCLI();
const commands = cli.getAvailableCommands();
expect(commands).toHaveLength(2);
expect(commands[0]).toMatchObject({
name: "greet",
description: "A friendly greeting",
options: ["name", "excited"],
});
});Testing generated types ensures your CLI's type generation is working correctly and provides confidence in type-safe CLI wrappers.
Testing Full CLIs with createTestCLI
For full CLI integration tests, use createTestCLI to create a test harness that captures stdout/stderr and intercepts process.exit:
import { createTestCLI } from "@bunli/test";
import { greetCommand } from "../commands/greet";
import { deployCommand } from "../commands/deploy";
test("greet via CLI harness", async () => {
const cli = await createTestCLI({
commands: [greetCommand],
});
const result = await cli.run(["greet", "--name", "Alice"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Hello, Alice!");
});
test("deploy via programmatic execute", async () => {
const cli = await createTestCLI({
commands: [deployCommand],
});
const result = await cli.execute("deploy", ["--env", "prod"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Deploying to prod");
});Limitation: createTestCLI does NOT support stdin or mockPrompts options. For testing
interactive prompts, use testCommand instead.
Configuration
interface TestCLIConfig {
name?: string; // CLI name (default: 'test-cli')
version?: string; // CLI version (default: '1.0.0')
description?: string; // CLI description
commands?: Command[]; // Commands to register (required for run/execute)
plugins?: BunliPlugin[]; // Plugins to load
env?: Record<string, string | undefined>; // Environment overrides
config?: Partial<BunliConfigInput>; // Config overrides
}Result Shape
cli.run() and cli.execute() return:
interface TestCLIRunResult {
stdout: string; // Captured stdout (lines joined by newlines)
stderr: string; // Captured stderr
exitCode: number; // Process exit code (0 = success)
error?: Error; // Error thrown during execution
}Key Differences from testCommand
testCommand | createTestCLI | |
|---|---|---|
| Scope | Single command | Full CLI with plugin lifecycle |
| Commands | Passed per-test | Registered once at setup |
| Plugins | Not supported | Supported |
stdin/mockPrompts | โ Supported | โ Not supported |
cli.run(argv) | โ | Run with argv like CLI invocation |
cli.execute(name, args?) | โ | Execute a named command programmatically |