playbook/outfitter-agents/plugins/outfitter-stack/skills/stack-templates/templates/handler-test.md

5.7 KiB

Handler Test Template

Test handlers directly without transport layer using Bun test runner.

Template

import { describe, test, expect, beforeEach } from "bun:test";
import { createContext, type HandlerContext } from "@outfitter/contracts";
import { myHandler } from "../handlers/my-handler.js";

describe("myHandler", () => {
  let ctx: HandlerContext;

  beforeEach(() => {
    ctx = createContext({});
  });

  // ============================================================================
  // Success Cases
  // ============================================================================

  test("returns success for valid input", async () => {
    const result = await myHandler({ id: "valid-id" }, ctx);

    expect(result.isOk()).toBe(true);
    expect(result.value).toMatchObject({
      id: "valid-id",
      // Add expected properties
    });
  });

  test("returns success with optional parameters", async () => {
    const result = await myHandler(
      { id: "valid-id", includeDeleted: true },
      ctx
    );

    expect(result.isOk()).toBe(true);
    // Assert on optional behavior
  });

  // ============================================================================
  // Error Cases
  // ============================================================================

  test("returns NotFoundError for missing resource", async () => {
    const result = await myHandler({ id: "missing" }, ctx);

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

  test("returns ValidationError for empty id", async () => {
    const result = await myHandler({ id: "" }, ctx);

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

  test("returns ValidationError for missing required field", async () => {
    const result = await myHandler({} as any, ctx);

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

  // ============================================================================
  // Edge Cases
  // ============================================================================

  test("handles special characters in id", async () => {
    const result = await myHandler({ id: "user-123/test" }, ctx);

    // Assert expected behavior
  });

  test("respects cancellation signal", async () => {
    const controller = new AbortController();
    const ctxWithSignal = createContext({ signal: controller.signal });

    controller.abort();
    const result = await myHandler({ id: "valid-id" }, ctxWithSignal);

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

Checklist

  • Test success cases with valid input
  • Test all error types in handler signature
  • Test validation errors for invalid/missing input
  • Test edge cases (special characters, empty arrays, etc.)
  • Test cancellation if handler supports it
  • Use createContext({}) for test context
  • Check result.isOk() / result.isErr() before accessing value/error
  • Use _tag for error type discrimination

Result Assertions

Success Assertions

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

// Access value (type-safe after isOk check)
expect(result.value.id).toBe("expected-id");

// Match object structure
expect(result.value).toMatchObject({
  id: "expected-id",
  name: expect.any(String),
});

// Array assertions
expect(result.value.items).toHaveLength(3);
expect(result.value.items[0]).toMatchObject({ type: "expected" });

Error Assertions

// Check error
expect(result.isErr()).toBe(true);

// Check error type
expect(result.error._tag).toBe("NotFoundError");

// Check error category
expect(result.error.category).toBe("not_found");

// Check error properties
expect(result.error.resourceType).toBe("user");
expect(result.error.resourceId).toBe("123");

// Check error message
expect(result.error.message).toContain("not found");

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

Testing with Mock Logger

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

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

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

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

Testing with Fixtures

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

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

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

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

Testing with Temporary Directories

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

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

    expect(result.isOk()).toBe(true);

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

Running Tests

# All tests
bun test

# Single file
bun test src/__tests__/my-handler.test.ts

# Watch mode
bun test --watch

# With coverage
bun test --coverage

# Filter by name
bun test --filter "returns success"