Bunli
Packages

@bunli/test

Testing utilities for Bunli CLI applications

Installation

bash bun 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 !== 0

Output 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 arguments
  • env - Environment variables to merge with process.env
  • cwd - Working directory (defaults to process.cwd())
  • stdin - Input lines for non-prompt stdin (string or array) - Note: only supported by testCommand, not createTestCLI
  • mockPrompts - Map of prompt messages to responses (string or array for retries) - Note: only supported by testCommand, not createTestCLI
  • mockShellCommands - Map of shell commands to their output
  • exitCode - Expected exit code (for testing error scenarios)

Returns:

  • stdout - Standard output
  • stderr - Standard error
  • exitCode - Process exit code
  • duration - Execution time in ms
  • error - 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.

  1. 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");
    });
  2. Use descriptive test names:

    test("deploy command deploys to production with --force flag", async () => {
      // ...
    });
  3. Mock external dependencies:

    // Don't actually hit external APIs or modify files
    mockShellCommands({
      "curl https://api.example.com": '{"status": "ok"}',
    });
  4. Test validation scenarios:

    // Provide multiple attempts for validation
    mockPromptResponses({
      "Port:": ["abc", "99999", "3000"], // Test invalid โ†’ invalid โ†’ valid
    });
  5. 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

testCommandcreateTestCLI
ScopeSingle commandFull CLI with plugin lifecycle
CommandsPassed per-testRegistered once at setup
PluginsNot supportedSupported
stdin/mockPromptsโœ… SupportedโŒ Not supported
cli.run(argv)โ€”Run with argv like CLI invocation
cli.execute(name, args?)โ€”Execute a named command programmatically

On this page