playbook/outfitter-agents/plugins/outfitter-stack/skills/stack-patterns/references/testing.md

5.7 KiB

Stack Testing

Test patterns for @outfitter/* packages.

Test Structure

src/
├── handlers/
│   └── get-user.ts
└── __tests__/
    ├── get-user.test.ts
    └── __snapshots__/
        └── get-user.test.ts.snap

Handler Testing

Test handlers directly without transport layer:

import { describe, test, expect } from "bun:test";
import { createContext } from "@outfitter/contracts";
import { getUser } from "../handlers/get-user.js";

describe("getUser", () => {
  test("returns user when found", async () => {
    const ctx = createContext({});
    const result = await getUser({ id: "user-1" }, ctx);

    expect(result.isOk()).toBe(true);
    expect(result.value).toEqual({
      id: "user-1",
      name: "Alice",
    });
  });

  test("returns NotFoundError when user missing", async () => {
    const ctx = createContext({});
    const result = await getUser({ id: "missing" }, ctx);

    expect(result.isErr()).toBe(true);
    expect(result.error._tag).toBe("NotFoundError");
    expect(result.error.resourceId).toBe("missing");
  });

  test("returns ValidationError for invalid input", async () => {
    const ctx = createContext({});
    const result = await getUser({ id: "" }, ctx);

    expect(result.isErr()).toBe(true);
    expect(result.error._tag).toBe("ValidationError");
  });
});

Test Fixtures

Use createFixture for deep-merged test data:

import { createFixture } from "@outfitter/testing";

interface User {
  id: string;
  name: string;
  email: string;
  settings: { theme: string; notifications: boolean };
}

const createUser = createFixture<User>({
  id: "user-1",
  name: "Test User",
  email: "test@example.com",
  settings: { theme: "light", notifications: true },
});

test("user with custom settings", async () => {
  const user = createUser({ settings: { theme: "dark" } });
  // user.settings.notifications is still true (deep merge)
});

Temporary Directories

Use withTempDir for isolated file operations:

import { withTempDir } from "@outfitter/testing";

test("writes config file", async () => {
  await withTempDir(async (dir) => {
    const result = await writeConfig({ dir, data: { key: "value" } }, ctx);

    expect(result.isOk()).toBe(true);
    const content = await Bun.file(`${dir}/config.json`).json();
    expect(content).toEqual({ key: "value" });
  });
});

Environment Mocking

Use withEnv for environment variable testing:

import { withEnv } from "@outfitter/testing";

test("uses custom log level", async () => {
  await withEnv({ LOG_LEVEL: "debug" }, async () => {
    const config = loadConfig();
    expect(config.logLevel).toBe("debug");
  });
});

CLI Testing

Use createCliHarness for CLI command testing:

import { createCliHarness } from "@outfitter/testing";
import { listCommand } from "../commands/list.js";

const harness = createCliHarness(listCommand);

test("lists items in JSON mode", async () => {
  const result = await harness.run(["--json"]);

  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain('"items"');
});

test("exits with error for invalid flag", async () => {
  const result = await harness.run(["--invalid"]);

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

MCP Testing

Use createMcpHarness for MCP tool testing:

import { createMcpHarness } from "@outfitter/testing";
import { searchTool } from "../tools/search.js";

const harness = createMcpHarness(searchTool);

test("returns search results", async () => {
  const result = await harness.invoke({
    query: "test",
    limit: 10,
  });

  expect(result.isOk()).toBe(true);
  expect(result.value.results).toHaveLength(3);
});

test("validates input schema", async () => {
  const result = await harness.invoke({
    query: "",  // Invalid: min length 1
  });

  expect(result.isErr()).toBe(true);
  expect(result.error._tag).toBe("ValidationError");
});

Context Mocking

Create mock context with logger spy:

import { createContext } from "@outfitter/contracts";
import { createMockLogger } from "@outfitter/testing";

test("logs debug messages", async () => {
  const mockLogger = createMockLogger();
  const ctx = createContext({ logger: mockLogger });

  await myHandler({ id: "1" }, ctx);

  expect(mockLogger.calls.debug).toContainEqual([
    "Processing",
    { id: "1" },
  ]);
});

Snapshot Testing

Use Bun's snapshot testing:

import { expect, test } from "bun:test";

test("output matches snapshot", async () => {
  const result = await formatOutput(data);
  expect(result).toMatchSnapshot();
});

Snapshots stored in __snapshots__/*.snap.

Result Assertions

Custom matchers for Result types:

// Check success
expect(result.isOk()).toBe(true);
expect(result.value).toEqual(expected);

// Check failure
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("NotFoundError");
expect(result.error.category).toBe("not_found");

// Error details
expect(result.error.details).toMatchObject({
  field: "email",
});

Running Tests

# All tests
bun test

# Single file
bun test src/__tests__/get-user.test.ts

# Watch mode
bun test --watch

# With coverage
bun test --coverage

# Update snapshots
bun test --update-snapshots

Best Practices

  1. Test handlers directly - Skip transport layer for unit tests
  2. Use fixtures - Create reusable test data with createFixture
  3. Isolate side effects - Use withTempDir and withEnv
  4. Mock context - Inject mock logger to verify logging
  5. Test error paths - Verify correct error types and categories
  6. Snapshot outputs - Use snapshots for complex output verification