Bunli
Guides

Testing

Write comprehensive tests for your CLI applications

Setup

Bunli includes @bunli/test for testing CLI commands:

// package.json
{
  "scripts": {
    "test": "bunli test",
    "test:watch": "bunli test --watch",
    "test:coverage": "bunli test --coverage"
  }
}

Basic Testing

Testing a Simple Command

// src/commands/greet.ts
import { defineCommand, option } from "@bunli/core";
import { z } from "zod";

export default defineCommand({
  name: "greet",
  options: {
    name: option(z.string().default("World")),
  },
  handler: async ({ flags, colors }) => {
    console.log(colors.green(`Hello, ${flags.name}!`));
  },
});
// src/commands/greet.test.ts
import { test, expect } from "bun:test";
import { testCommand } from "@bunli/test";
import greet from "./greet";

test("greet command says hello", async () => {
  const result = await testCommand(greet, {
    flags: { name: "World" },
  });

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Hello, World!");
});

test("greet command with custom name", async () => {
  const result = await testCommand(greet, {
    flags: { name: "Alice" },
  });

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Hello, Alice!");
});

Testing with createTestCLI

For full CLI integration testing, use createTestCLI:

import { test, expect } from "bun:test";
import { createTestCLI } from "@bunli/test";
import greet from "./greet";

test("greet command via CLI", async () => {
  const cli = await createTestCLI({
    commands: [greet],
  });

  const result = await cli.run(["greet"]);

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Hello, World!");
});

test("greet with arguments via execute", async () => {
  const cli = await createTestCLI({
    commands: [greet],
  });

  const result = await cli.execute("greet", ["--name", "Alice"]);

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Hello, Alice!");
});

Testing Command Options

Testing Validation

// src/commands/serve.ts
import { defineCommand, option } from "@bunli/core";
import { z } from "zod";

export default defineCommand({
  name: "serve",
  options: {
    port: option(z.coerce.number().int().min(1).max(65535), { description: "Port number" }),
    host: option(z.string().default("localhost")),
  },
  handler: async ({ flags }) => {
    console.log(`Server running on ${flags.host}:${flags.port}`);
  },
});
// src/commands/serve.test.ts
import { test, expect, describe } from "bun:test";
import { testCommand } from "@bunli/test";
import serve from "./serve";

describe("serve command", () => {
  test("valid port number", async () => {
    const result = await testCommand(serve, {
      flags: { port: 3000 },
    });

    expect(result.exitCode).toBe(0);
    expect(result.stdout).toContain("Server running on localhost:3000");
  });

  test("invalid port number", async () => {
    const result = await testCommand(serve, {
      flags: { port: 70000 },
    });

    expect(result.exitCode).toBe(1);
    expect(result.stderr).toContain("less than or equal to 65535");
  });

  test("non-numeric port", async () => {
    const result = await testCommand(serve, {
      flags: { port: "abc" as any },
    });

    expect(result.exitCode).toBe(1);
    expect(result.stderr).toContain("Expected number");
  });
});

Testing Interactive Commands

Mocking Prompts

Use the mockPromptResponses helper with testCommand:

// src/commands/init.ts
export default defineCommand({
  name: "init",
  handler: async ({ prompt, colors }) => {
    const name = await prompt.text("Enter project name:", {
      default: "my-project",
    });

    const useTypeScript = await prompt.confirm("Use TypeScript?", {
      default: true,
    });

    const template = await prompt.select("Choose a template:", {
      options: [
        { value: "basic", label: "Basic" },
        { value: "full", label: "Full-featured" },
      ],
    });

    console.log(colors.green("✓"), `Created ${name} with ${template} template`);
    if (useTypeScript) {
      console.log(colors.dim("  TypeScript enabled"));
    }
  },
});
// src/commands/init.test.ts
import { test, expect } from "bun:test";
import { testCommand, mockPromptResponses } from "@bunli/test";
import init from "./init";

test("init command with prompts", async () => {
  const result = await testCommand(init, {
    ...mockPromptResponses({
      "Enter project name:": "awesome-cli",
      "Use TypeScript?": "n",
      "Choose a template:": "full",
    }),
  });

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Created awesome-cli with full template");
  expect(result.stdout).not.toContain("TypeScript enabled");
});

test("init command with default values", async () => {
  const result = await testCommand(init, {
    ...mockPromptResponses({
      "Enter project name:": "my-project",
      "Use TypeScript?": "",
      "Choose a template:": "basic",
    }),
  });

  expect(result.stdout).toContain("Created my-project with basic template");
  expect(result.stdout).toContain("TypeScript enabled");
});

test("init command with stdin array", async () => {
  const result = await testCommand(init, {
    stdin: ["my-app", "y", "basic"],
  });

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Created my-app with basic template");
});

Testing Command Output

Capturing Output

test("command output formatting", async () => {
  const cli = await createTestCLI({
    commands: [listCommand],
  });

  const result = await cli.run(["list"]);

  // Test stdout
  expect(result.stdout).toContain("Items:");
  expect(result.stdout.split("\n")).toHaveLength(5);

  // Test stderr
  expect(result.stderr).toBe("");

  // Test combined output
  expect(result.stdout + result.stderr).toContain("Items:");
});

Testing Colored Output

import { stripAnsi } from "@bunli/utils";

test("colored output", async () => {
  const cli = await createTestCLI({
    commands: [statusCommand],
    env: { FORCE_COLOR: "1" },
  });

  const result = await cli.run(["status"]);

  // Test for ANSI color codes
  expect(result.stdout).toContain("\x1b[32m"); // Green
  expect(result.stdout).toContain("\x1b[31m"); // Red

  // Or strip colors for easier testing
  const stripped = stripAnsi(result.stdout);
  expect(stripped).toContain("✓ Success");
});

Testing Error Handling

Exit Codes

test("command failure", async () => {
  const cli = await createTestCLI({
    commands: [buildCommand],
  });

  const result = await cli.run(["build", "--invalid-flag"]);

  expect(result.exitCode).toBe(1);
  expect(result.stderr).toContain("Unknown flag");
});

test("command throws error", async () => {
  const result = await testCommand(
    defineCommand({
      name: "fail",
      handler: async () => {
        throw new Error("Something went wrong");
      },
    }),
  );

  expect(result.exitCode).toBe(1);
  expect(result.stderr).toContain("Something went wrong");
});

Validation Errors

test("validation error messages", async () => {
  const cli = await createTestCLI({
    commands: [deployCommand],
  });

  const result = await cli.run(["deploy", "--env", "invalid", "--port", "-1"]);

  expect(result.exitCode).toBe(1);
  expect(result.stderr).toContain("Validation errors:");
  expect(result.stderr).toContain("--env: Invalid enum value");
  expect(result.stderr).toContain("--port: Number must be greater than or equal to 1");
});

Testing Async Operations

Testing Spinners and Progress

test("long running command", async () => {
  const result = await testCommand(installCommand, {
    flags: {},
    // timeout is handled by test runner
  });

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Installing dependencies...");
  expect(result.stdout).toContain("✓ Installation complete");
});

Testing Environment Variables

test("command uses environment variables", async () => {
  const result = await testCommand(apiCommand, {
    env: {
      API_KEY: "test-key-123",
      DEBUG: "true",
    },
  });

  expect(result.stdout).toContain("Using API key: test-***-123");
  expect(result.stdout).toContain("Debug mode enabled");
});

Testing Shell Commands

Use mockShellCommands to mock shell command outputs:

import { testCommand, mockShellCommands } from "@bunli/test";

test("command executes shell commands", async () => {
  const result = await testCommand(syncCommand, {
    ...mockShellCommands({
      "git status": "On branch main\nnothing to commit",
      "git pull": "Already up to date.",
    }),
  });

  expect(result.stdout).toContain("Already up to date");
});

test("shell command returns JSON", async () => {
  const result = await testCommand(infoCommand, {
    ...mockShellCommands({
      "npm list --json": JSON.stringify({ dependencies: {} }),
    }),
  });

  expect(result.stdout).toContain("dependencies");
});

Integration Testing

Testing Multiple Commands

test("command workflow", async () => {
  const cli = await createTestCLI({
    commands: [initCommand, addCommand, buildCommand],
  });

  // Initialize project
  let result = await cli.run(["init", "--name", "test-app"]);
  expect(result.exitCode).toBe(0);

  // Add a component
  result = await cli.run(["add", "component", "Button"]);
  expect(result.exitCode).toBe(0);

  // Build project
  result = await cli.run(["build"]);
  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Build successful");
});

Test Utilities

Custom Matchers

@bunli/test provides custom matchers via expectCommand:

import { expectCommand } from "@bunli/test";

// Use with testCommand
test("command succeeds", async () => {
  const result = await testCommand(myCommand);
  expectCommand(result).toHaveSucceeded();
  expectCommand(result).toContainInStdout("success");
});

test("command fails", async () => {
  const result = await testCommand(failCommand);
  expectCommand(result).toHaveFailed();
  expectCommand(result).toContainInStderr("error");
});

Test Helpers

// test-helpers.ts
import { createTestCLI } from "@bunli/test";
import { loginCommand, logoutCommand } from "./commands";

export function createAuthenticatedCLI() {
  return createTestCLI({
    env: {
      AUTH_TOKEN: "test-token",
    },
    commands: [loginCommand, logoutCommand],
  });
}

// Use in tests
test("authenticated command", async () => {
  const cli = await createAuthenticatedCLI();
  const result = await cli.run(["profile"]);
  expect(result.stdout).toContain("Logged in as: test-user");
});

Coverage Reports

Run tests with coverage:

bunli test --coverage

This generates a coverage report showing:

  • Line coverage
  • Branch coverage
  • Function coverage
  • Statement coverage

Testing Plugins

Testing Plugin Integration

import { test, expect } from "bun:test";
import { testCommand } from "@bunli/test";
import { createPlugin } from "@bunli/core/plugin";
import type { BunliPlugin } from "@bunli/core/plugin";

const analyticsPlugin = createPlugin({
  name: "analytics",
  store: {
    commandCount: 0,
    commands: [] as string[],
  },
  beforeCommand({ store, command }) {
    store.commandCount++;
    store.commands.push(command);
  },
});

test("analytics plugin tracks commands", async () => {
  const result = await testCommand(
    defineCommand({
      name: "test",
      plugins: [analyticsPlugin as BunliPlugin<typeof analyticsPlugin>],
      handler: async ({ context }) => {
        // Access plugin store in command
        console.log(`Count: ${context?.store.commandCount}`);
      },
    }),
  );

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain("Count: 1");
});

Testing Plugin Hooks

test("plugin lifecycle hooks", async () => {
  const events: string[] = [];

  const testPlugin = createPlugin({
    name: "test-plugin",
    setup() {
      events.push("setup");
    },
    configResolved() {
      events.push("configResolved");
    },
    beforeCommand() {
      events.push("beforeCommand");
    },
    afterCommand() {
      events.push("afterCommand");
    },
  });

  const result = await testCommand(
    defineCommand({
      name: "test",
      plugins: [testPlugin as BunliPlugin<typeof testPlugin>],
      handler: async () => {},
    }),
  );

  expect(events).toEqual(["setup", "configResolved", "beforeCommand", "afterCommand"]);
});

Testing Plugin Store Types

interface TimerStore {
  startTime: number | null;
  endTime: number | null;
}

const timerPlugin = createPlugin({
  name: "timer",
  store: {
    startTime: null,
    endTime: null,
  },
  beforeCommand({ store }) {
    store.startTime = Date.now();
  },
  afterCommand({ store }) {
    store.endTime = Date.now();
  },
});

test("plugin store type safety", async () => {
  const result = await testCommand(
    defineCommand({
      name: "timed",
      plugins: [timerPlugin as BunliPlugin<typeof timerPlugin>],
      handler: async ({ context }) => {
        if (context?.store.startTime) {
          console.log(`Started at: ${context.store.startTime}`);
        }
      },
    }),
  );
  expect(result.stdout).toMatch(/Started at: \d+/);
});

Testing Multiple Plugins

test("multiple plugins interaction", async () => {
  const pluginA = createPlugin({
    name: "plugin-a",
    store: { valueA: "A" },
  });

  const pluginB = createPlugin({
    name: "plugin-b",
    store: { valueB: "B" },
  });

  const result = await testCommand(
    defineCommand({
      name: "test",
      plugins: [pluginA, pluginB] as any,
      handler: async ({ context }) => {
        console.log(context?.store.valueA);
        console.log(context?.store.valueB);
      },
    }),
  );

  expect(result.stdout).toContain("A");
  expect(result.stdout).toContain("B");
});

Testing with Generated Types

When using type generation, you can test the generated types themselves:

// test/generated-types.test.ts
import { test, expect } from "bun:test";
import { listCommands, getCommandApi } 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 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);
});

Best Practices

  1. Test User Scenarios: Focus on how users interact with your CLI
  2. Test Error Cases: Ensure good error messages
  3. Mock External Dependencies: Don't make real API calls
  4. Test Cross-Platform: Consider Windows/Unix differences
  5. Keep Tests Fast: Mock slow operations
  6. Test Output Format: Users depend on consistent output
  7. Test Plugin Integration: Ensure plugins work with your commands
  8. Test Generated Types: Verify type generation works correctly

Debugging Tests

test("debugging example", async () => {
  const cli = await createTestCLI({
    commands: [complexCommand],
  });

  const result = await cli.run(["complex-command"]);

  // Log output for debugging
  console.log("STDOUT:", result.stdout);
  console.log("STDERR:", result.stderr);

  // Detailed assertion messages
  expect(result.exitCode).toBe(0, `Command failed with: ${result.stderr}`);
});

Next Steps

On this page