Skip to content

Test a feature from business rule to HTTP

Test the Todos feature at three boundaries: its use cases, its HTTP routes, and its database repository. Verify successful operations, invalid input, and access by a second account while keeping each failure easy to locate.

Before you start

Use the Next.js and SQLite app from the quickstart, with its checked-in migrations. Keep the tests the starter already generated. Add the files below to extend them, and run commands from the app root with Bun.

QuestionTest boundary
Can another account change this Todo?Use case with the real policy and a memory repository
Does invalid JSON input receive the expected HTTP response?Route with the real contract and context blueprint
Does the adapter persist the right fields?Repository with a temporary SQLite database

These tests import the application modules they exercise. Recreating a handler inside a test would only prove that the duplicate implementation works.

Share a small feature fixture

The starter's createTestTodoRepository supplies isolated Todo data. Keep the production gate so tests exercise the real ownership policy. Bind the same repository inside the test transaction because mutation use cases use ctx.ports.uow.transaction(...):

typescript
// features/todos/tests/feature-fixture.ts
import { createUseCaseTester } from "@beignet/core/application";
import {
  createAnonymousActor,
  createStaticAuth,
  createUserActor,
} from "@beignet/core/ports";
import {
  createTestContextFactory,
  createTestPorts,
} from "@beignet/core/testing";
import { createInMemoryDevtools } from "@beignet/devtools";
import type { AppContext } from "@/app-context";
import { initialPorts } from "@/infra/port-wiring";
import type { AppTransactionPorts } from "@/ports";
import type { AuthRequest, AuthSessionMetadata, AuthUser } from "@/ports/auth";
import type { TodoRepository } from "../ports";
import { createTestTodoRepository } from "./helpers";

export function createFeatureFixture(
  userId: string | null,
  todos: TodoRepository = createTestTodoRepository(),
) {
  const auth = userId
    ? {
        user: { id: userId, email: `${userId}@example.com`, name: "Test User" },
        session: { id: `session-${userId}` },
      }
    : null;
  const fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
    base: initialPorts,
    overrides: {
      gate: initialPorts.gate,
      auth: createStaticAuth<AuthUser, AuthSessionMetadata, AuthRequest>(auth),
      todos,
      devtools: createInMemoryDevtools(),
    },
    transaction: { ports: (ports) => ({ ...ports, todos }) },
  });
  const context = createTestContextFactory<AppContext, AppContext["ports"]>({
    ports: fixture.ports,
    actor: userId ? createUserActor(userId) : createAnonymousActor(),
    auth,
  });
  return {
    ports: fixture.ports,
    todos,
    tester: createUseCaseTester<AppContext>(context),
  };
}

The transaction fixture makes the same test repository available to the use case. It does not prove database rollback or locking; test those behaviors against the database when your feature depends on them.

Test validation and ownership directly

Use-case tests need no HTTP server or login flow. Supply identities explicitly and assert both the error and the state left behind:

typescript
// features/todos/tests/feature-behavior.test.ts
import { expect, test } from "@/lib/beignet-test";
import { createTodoUseCase, updateTodoUseCase } from "../use-cases";
import { createFeatureFixture } from "./feature-fixture";

test("validates input and keeps another user's Todo unchanged", async () => {
  const owner = createFeatureFixture("owner");
  const other = createFeatureFixture("other", owner.todos);
  await expect(
    owner.tester.run(createTodoUseCase, {
      title: "",
    }),
  ).rejects.toMatchObject({ name: "UseCaseValidationError", phase: "input" });

  const todo = await owner.tester.run(createTodoUseCase, {
    title: "Ship the feature",
  });
  expect(todo.userId).toBe("owner");
  await expect(
    other.tester.run(updateTodoUseCase, {
      id: todo.id,
      completed: true,
    }),
  ).rejects.toMatchObject({ code: "FORBIDDEN" });
  expect((await owner.todos.findById(todo.id))?.completed).toBe(false);

  const updated = await owner.tester.run(updateTodoUseCase, {
    id: todo.id,
    completed: true,
  });
  expect(updated.completed).toBe(true);
  await expect(
    owner.tester.run(updateTodoUseCase, {
      id: crypto.randomUUID(),
      completed: true,
    }),
  ).rejects.toMatchObject({ code: "TODO_NOT_FOUND" });
});

This checks the real use case and policy. A mocked gate that always grants access would miss an authorization regression.

Test the HTTP boundary

Reuse server/context.ts, the feature route group, and the idempotency hook configured by the starter. The auth port controls which account the request context sees. The test app dispatches requests in process, without opening a port:

typescript
// features/todos/tests/feature-http.test.ts
import { createIdempotencyHooks, defineRoutes } from "@beignet/core/server";
import { createTestApp } from "@beignet/web/testing";
import type { AppContext } from "@/app-context";
import { expect, test } from "@/lib/beignet-test";
import { appContext } from "@/server/context";
import { createTodo, listTodos, updateTodo } from "../contracts";
import { todoRoutes } from "../routes";
import { createFeatureFixture } from "./feature-fixture";

test("returns HTTP errors and replays a successful create", async () => {
  const owner = createFeatureFixture("owner");
  const openApp = (userId: string | null) =>
    createTestApp({
      ports: createFeatureFixture(userId, owner.todos).ports,
      context: appContext,
      hooks: [createIdempotencyHooks<AppContext>()],
      routes: defineRoutes<AppContext>([todoRoutes]),
    });
  const app = await openApp("owner");
  const stranger = await openApp("other");
  const anonymous = await openApp(null);
  try {
    const unauthorized = await anonymous.safeRequest(listTodos, { query: {} });
    expect(unauthorized.ok).toBe(false);
    if (unauthorized.ok) throw new Error("Expected an authentication error");
    expect(unauthorized.status).toBe(401);

    const invalid = await app.fetch(
      new Request("http://beignet.test/api/todos", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ title: "" }),
      }),
    );
    expect(invalid.status).toBe(422);

    const input = { body: { title: "Ship it" }, idempotencyKey: "create-once" };
    const created = await app.request(createTodo, input);
    const replay = await app.request(createTodo, input);
    expect(replay.id).toBe(created.id);
    expect((await app.request(listTodos, { query: {} })).page.total).toBe(1);

    const forbidden = await stranger.safeRequest(updateTodo, {
      path: { id: created.id },
      body: { completed: true },
    });
    expect(forbidden.ok).toBe(false);
    if (forbidden.ok) throw new Error("Expected an ownership error");
    expect(forbidden.status).toBe(403);
    expect(forbidden.error.code).toBe("FORBIDDEN");
  } finally {
    await Promise.all([app.stop(), stranger.stop(), anonymous.stop()]);
  }
});

The invalid-input check sends a raw Request. A typed client's input validation can reject bad data before HTTP dispatch, which would not test the server's response. The replay assertion includes the actual hook; contract metadata alone does not enforce idempotency.

Verify the real repository

The starter's createTestDatabase applies your checked-in migrations to a new temporary SQLite database. Insert the user required by the Todo foreign key, then exercise the actual adapter:

typescript
// features/todos/tests/feature-persistence.test.ts
import { user } from "@/infra/db/schema";
import { createTestDatabase } from "@/infra/db/test-database";
import { expect, test } from "@/lib/beignet-test";

test("persists Todo changes in SQLite", async () => {
  const database = await createTestDatabase();
  try {
    await database.db.insert(user).values({
      id: "owner",
      name: "Owner",
      email: "owner@example.com",
      createdAt: new Date(),
      updatedAt: new Date(),
    });
    const repository = database.repositories.todos;
    const todo = await repository.create({
      userId: "owner",
      title: "Persist me",
    });
    await repository.update(todo.id, { completed: true });
    const saved = await repository.findById(todo.id);
    expect(saved).toMatchObject({
      id: todo.id,
      userId: "owner",
      title: "Persist me",
      completed: true,
    });
    await repository.delete(todo.id);
    expect(await repository.findById(todo.id)).toBeNull();
  } finally {
    await database.close();
  }
});

Run the app's checks:

bash
bun run format
bun run test
bun beignet check

Expect the three new tests to pass alongside the generated tests. The database test uses a temporary file, not your development database. Commit new migrations before relying on it to verify a schema change.

Check that the tests catch a regression

As a local experiment, remove the ownership authorization call from updateTodoUseCase. The behavior and HTTP tests should fail because the other account can update the record. Restore the authorization call immediately and rerun the tests. Keep this intentional regression out of your commit.

These tests cover Beignet's request pipeline and persistence. Keep browser tests for sign-in, cookies, forms, and navigation, and a deployment smoke check for the actual Next.js route and production providers.

Troubleshooting

SymptomCheck
A test reads an unbound portSupply the dependency it uses, including transaction-scoped ports.
An HTTP test unexpectedly returns 401The real context blueprint reads the auth port; changing an unrelated test actor does not sign the request in.
Invalid input never reaches the serverSend a raw Request when asserting server parsing or validation.
SQLite reports a missing table or foreign keyGenerate the migration for schema changes and insert required parent records in the isolated database.
An idempotency test creates twiceInclude the production hook and an idempotency port, and reuse the same request and key.

Work with your agent

text
Use the test-a-feature guide to extend this feature's tests. Read local
instructions and existing tests first. Exercise the real use cases, policies,
contracts, context blueprint, and repository. Cover success, invalid input,
anonymous access, another account, and persistence. Include configured hooks
when testing their behavior. Keep each test isolated and close servers and
databases. Verify that removing the ownership check makes the tests fail, then
restore it and run format, tests, and beignet check.