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 --coverageThis 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
- Test User Scenarios: Focus on how users interact with your CLI
- Test Error Cases: Ensure good error messages
- Mock External Dependencies: Don't make real API calls
- Test Cross-Platform: Consider Windows/Unix differences
- Keep Tests Fast: Mock slow operations
- Test Output Format: Users depend on consistent output
- Test Plugin Integration: Ensure plugins work with your commands
- 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
- Type Generation Guide - Learn about code generation
- Distribution - Package and distribute your CLI
- Examples - See tests in real projects