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.
| Question | Test 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(...):
// 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:
// 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:
// 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:
// 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:
bun run format
bun run test
bun beignet checkExpect 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
| Symptom | Check |
|---|---|
| A test reads an unbound port | Supply the dependency it uses, including transaction-scoped ports. |
| An HTTP test unexpectedly returns 401 | The real context blueprint reads the auth port; changing an unrelated test actor does not sign the request in. |
| Invalid input never reaches the server | Send a raw Request when asserting server parsing or validation. |
| SQLite reports a missing table or foreign key | Generate the migration for schema changes and insert required parent records in the isolated database. |
| An idempotency test creates twice | Include the production hook and an idempotency port, and reuse the same request and key. |
Work with your agent
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.