--- title: "Test a feature from business rule to HTTP" description: "Test validation, ownership, HTTP errors, idempotency, and SQLite persistence using the starter's real Todos feature." url: "https://www.beignetjs.com/guides/test-a-feature" markdown_url: "https://www.beignetjs.com/markdown/guides/test-a-feature.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "468e8570b12fe2af9e77d1a32502f728b098107c" source_modified: true --- # 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. To add ownership to a new resource first, follow [Protect a resource](https://www.beignetjs.com/markdown/guides/protect-a-resource.md). This guide extends the starter's existing Todos tests. ## Before you start Use the Next.js and SQLite app from the [quickstart](https://www.beignetjs.com/markdown/getting-started.md), 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(...)`: ```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({ base: initialPorts, overrides: { gate: initialPorts.gate, auth: createStaticAuth(auth), todos, devtools: createInMemoryDevtools(), }, transaction: { ports: (ports) => ({ ...ports, todos }) }, }); const context = createTestContextFactory({ ports: fixture.ports, actor: userId ? createUserActor(userId) : createAnonymousActor(), auth, }); return { ports: fixture.ports, todos, tester: createUseCaseTester(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()], routes: defineRoutes([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 | 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 ```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. ```