Testing
Beignet apps should test the boundary that owns the behavior. There are four test idioms, one per situation:
| Situation | Idiom |
|---|---|
| Business rules in a use case | createTestPorts(...) + createTestContextFactory(...) + createUseCaseTester(...) |
| Contract request and response over HTTP | createTestApp(...) with the app's shared appContext blueprint |
| Workflow artifacts: jobs, listeners, schedules, notifications, tasks, uploads | createTestContext(...) fixture with dispose() |
| Repository and persistence behavior | createDatabaseTestHarness(...) against a real test database |
Test placement follows the same ownership rule: feature behavior tests live in
features/<feature>/tests/, while infra and server modules may keep adjacent
*.test.ts files beside the module they exercise. Generated features and
resources include starter tests for the generated behavior; see CLI
for what each generator scaffolds.
Use case tests
Use case tests are the default for business behavior because they avoid HTTP setup and run against app-owned ports. A minimal test builds a port fixture, a context factory, and a tester, then runs the use case:
import { expect, test } from "bun:test";
import { createUseCaseTester } from "@beignet/core/application";
import { createTestUserActor } from "@beignet/core/ports/testing";
import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";
import { createProjectUseCase } from "@/features/projects/use-cases";
import { appPorts } from "@/infra/app-ports";
import { createInMemoryProjectRepository } from "@/infra/projects/in-memory-project-repository";
test("creates a project", async () => {
const fixture = createTestPorts<AppContext["ports"]>({
base: appPorts,
overrides: { gate: appPorts.gate, projects: createInMemoryProjectRepository() },
});
const createContext = createTestContextFactory<AppContext, AppContext["ports"]>({
ports: fixture.ports,
actor: createTestUserActor("user_test"),
});
const tester = createUseCaseTester<AppContext>(createContext);
const project = await tester.run(createProjectUseCase, { name: "Roadmap" });
expect(project.name).toBe("Roadmap");
});Port overrides are typed partials: a test declares only the port surface it
exercises, and any missing member throws a named error on use. The context
factory also accepts auth and a tenant built with createTestTenant(...),
and it attaches a live ctx.gate automatically when ports.gate exposes
bind(...), so authorization runs against the final test identity. Use
createTestImpersonatedUserActor(...) when an admin acting as another user
must appear in audit metadata.
When the behavior under test runs inside ctx.ports.uow.run(...), add the
app's production transaction wiring to the same fixture and turn on
transaction.outbox so events recorded inside the transaction commit
atomically with the data:
import { assertOutboxPending } from "@beignet/core/ports/testing";
import { createTransactionPorts } from "@/infra/db/transaction-ports";
import type { AppTransactionPorts } from "@/ports";
const projects = createInMemoryProjectRepository();
const fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
base: appPorts,
overrides: { gate: appPorts.gate, projects },
transaction: {
outbox: true,
ports: (ports) =>
createTransactionPorts({
audit: ports.audit,
repositories: { projects },
idempotency: ports.idempotency,
outbox: ports.outbox,
}),
},
});
// ...run the use case as above, then:
assertOutboxPending(fixture.outbox, { kind: "event", name: "projects.created" });infra/db/transaction-ports.ts is a pure module that assembles the app's
transaction-scoped ports, so production providers and tests share one
definition of what runs inside a transaction. Keep vendor SDK mocks out of
these tests; mock or implement the app-owned port instead.
Route tests
Use route tests when the behavior belongs to HTTP: request parsing, contract validation, response validation, hooks, auth, rate limits, and error ownership.
Route tests reuse the app's real context blueprint. server/context.ts
declares the blueprint once with defineServerContext(...), server/index.ts
passes it to the production server, and route tests pass the same value to
createTestApp(...):
import { expect, it } from "bun:test";
import { createTestPorts } from "@beignet/core/testing";
import { defineRoutes } from "@beignet/web";
import { createTestApp } from "@beignet/web/testing";
import type { AppContext } from "@/app-context";
import { createProject } from "@/features/projects/contracts";
import { projectRoutes } from "@/features/projects/routes";
import { appPorts } from "@/infra/app-ports";
import { createInMemoryProjectRepository } from "@/infra/projects/in-memory-project-repository";
import { appContext } from "@/server/context";
it("creates a project through the contract", async () => {
const fixture = createTestPorts<AppContext["ports"]>({
base: appPorts,
overrides: {
auth: { getSession: async () => ({ user: { id: "user_test" } }) },
gate: appPorts.gate,
projects: createInMemoryProjectRepository(),
},
});
const app = await createTestApp({
ports: fixture.ports,
context: appContext,
routes: defineRoutes<AppContext>([projectRoutes]),
});
const project = await app.request(createProject, { body: { name: "Roadmap" } });
await app.stop();
expect(project.name).toBe("Roadmap");
});createTestApp(...) runs @beignet/web under the hood. Two defaults differ
from production servers, and an explicit option always wins: onUnboundPorts
defaults to "ignore" so apps with deferred provider ports still boot, and
mapUnhandledError surfaces err.message in the 500 body so failing tests
show the real error.
Because the test runs the real blueprint, identity comes from the same place
it does in production: override the auth port to simulate a signed-in
session. Use createTestRequester(...) from @beignet/web/testing to apply
shared headers such as a tenant header, and app.safeRequest(...) when the
test expects an HTTP error as a typed result instead of a thrown
ContractError. Cover both successful responses and declared business errors.
Workflow artifact tests
Jobs, listeners, schedules, notifications, tasks, and uploads run with a
service identity instead of an HTTP request. Test them with the one-call
createTestContext(...) fixture: it builds memory ports, assembles an app
context with actor, tenant, request ID, trace ID, and a live bound gate, and
enters the ambient request context so enrichment matches production. Dispose
the fixture after each test:
import { afterEach, expect, it } from "bun:test";
import { createTestSystemActor } from "@beignet/core/ports/testing";
import { createTestContext } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";
import { LogProjectArchivedJob } from "@/features/projects/jobs";
const makeContext = createTestContext<AppContext>();
let fixture: ReturnType<typeof makeContext>;
afterEach(() => fixture.dispose());
it("audits handled archive jobs", async () => {
fixture = makeContext({ actor: createTestSystemActor("test-worker") });
await LogProjectArchivedJob.handle({
job: LogProjectArchivedJob,
payload: { projectId: "project_1" },
ctx: fixture.ctx,
});
expect(fixture.audit.entries).toMatchObject([
{ action: "jobs.projects.log-archived" },
]);
});The fixture supports using fixture = makeContext(...) for explicit resource
management, and ports accepts the same typed partial overrides as
createTestPorts(...). Operational tasks follow the same idiom: build a
fixture with createTestServiceActor(...) and pass fixture.ctx to
runTask(...) from @beignet/core/tasks. Production context creation belongs
to the CLI runner via server/tasks.ts, not to task tests.
Repository and persistence tests
Repository tests prove that a concrete adapter implements its port contract
against a real database. Keep them adjacent to the infra they exercise, and
use createDatabaseTestHarness(...) with the generated
infra/db/test-database.ts helper to keep setup, seeding, factory resets, and
cleanup in one place:
import { createDatabaseTestHarness } from "@beignet/core/testing";
const databaseHarness = createDatabaseTestHarness({
create: createTestDatabase,
ctx: (database) => ({ ports: database.ports }),
reset: (database) => database.reset(),
close: (database) => database.close(),
factories: [postFactory],
seeds: [demoPostsSeed],
});
afterEach(async () => {
await databaseHarness.cleanup();
});
const { ctx } = await databaseHarness.setup({ seed: true });
const post = await postFactory.create(ctx, {
title: "Database conventions",
});Factories and seeds
Use @beignet/core/testing when tests need realistic records or repeatable
demo data. Factories should build app-owned data and persist through ports, not
through ORM tables or provider SDKs:
// features/posts/tests/factories/post.ts
import { createFactory } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";
export const postFactory = createFactory("posts.post", {
defaults: ({ sequence }) => ({
title: `Post ${sequence}`,
content: "Created by a Beignet test factory.",
}),
persist: (ctx: AppContext, post) => ctx.ports.posts.create(post),
});Seeds wrap factories for repeatable demo data: declare them with
defineSeed(...), run them with runSeeds(...), and reset factory sequences
between tests with resetFactories(...).
Generate starter files with beignet make factory posts/post and
beignet make seed posts/demo-posts; keep factories under
features/<feature>/tests/factories/ and seeds under
features/<feature>/seeds/. Use beignet db seed for app-level demo data
once the app defines a db:seed script and an infra/db/seed.ts entrypoint.
Assertion helpers
@beignet/core/ports/testing ships assertion helpers that work against
Beignet ports and memory adapters without importing concrete infra. Each
assertX(...) has a matching findX(...), and most have an
assertNoX(...) negation. See the
generated API reference for exact signatures.
| Helper | What it asserts |
|---|---|
assertRecordedEvent | Events captured by createRecordingEventBus(...) |
assertDispatchedJob | Jobs captured by createRecordingJobDispatcher(...) |
assertScheduleRun | Run intent captured by createRecordingScheduleRunner(...) |
assertMailDelivery | Deliveries on a memory mail port |
assertNotificationDelivery | Deliveries on a memory notification port |
assertStorageObject | Object content and metadata behind a storage port |
assertAuditEntry | Entries in a memory audit log |
assertIdempotencyCompleted / assertIdempotencyInProgress | Idempotency entry state |
assertOutboxPending / assertOutboxDelivered / assertOutboxRetryScheduled / assertOutboxDeadLettered | Outbox message state |
assertOutboxDrainResult | Claimed and delivered counts from drainOutbox(...) |
assertProviderInstrumentationEvent | Events captured by createRecordingProviderInstrumentation(...) |
The recording helpers pair a port implementation with its captured log. Wire
the port through the context factory rather than spreading an existing context,
because spread copies drop the live ctx.gate:
import { assertRecordedEvent, createRecordingEventBus } from "@beignet/core/ports/testing";
const { bus, events } = createRecordingEventBus();
const ctx = createContext({ ports: { ...fixture.ports, eventBus: bus } });
await publishPostUseCase.run({ ctx, input });
assertRecordedEvent(events, { name: "posts.published", payload: { postId: "post_1" } });Outbox assertions read a memory outbox or a message snapshot, so tests check
durable workflow state without widening the production OutboxPort read API:
import { assertOutboxDelivered, assertOutboxDrainResult } from "@beignet/core/ports/testing";
import { drainOutbox } from "@beignet/core/outbox";
const result = await drainOutbox({ outbox, registry, eventBus, jobs });
assertOutboxDrainResult(result, { claimed: 1, delivered: 1 });
assertOutboxDelivered(outbox.messages, { kind: "event", name: "posts.published" });Use createRecordingScheduleRunner(...) to verify schedule run intent without
executing the handler, and createInlineScheduleRunner from
@beignet/core/schedules when the handler itself is under test. For stateful
workflows, add at least one test that follows the durable chain from
transition use case through outbox, listener, job dispatch, retry, and
dead-letter behavior; see Workflows.
Generated resource checks
After generating or editing a resource, run:
bun run test
bun run lint
bun run typecheck
bun beignet lint
bun beignet doctortest covers behavior. bun run lint runs Biome's code lint. typecheck
catches contract/type drift. beignet lint checks dependency direction.
doctor --strict checks app wiring that TypeScript cannot fully prove, such
as route files that no longer match registered contracts, missing canonical
client helpers, and local AppContext redeclarations.
Provider tests
Provider tests stay close to the adapter and verify the provider implements
its port contract, including startup, teardown, retries, and error
translation. Use installProviderForTest(...) from @beignet/core/testing to
run provider setup against test ports; it returns the merged ports, the raw
setup result, and start/stop runners for the lifecycle hooks:
import { installProviderForTest } from "@beignet/core/testing";
import type { CachePort } from "@beignet/core/ports";
const installed = await installProviderForTest(redisProvider, {
ports: { devtools },
config: { URL: "redis://localhost:6379" },
});
const cache = installed.ports.cache as CachePort;
await cache.set("posts:list", "[]");
await installed.stop();config is passed to provider setup as-is, matching server startup. Pass
createServiceContext when the provider under test builds service contexts
from runtime entrypoints. Application tests should not depend on live
providers unless the test is explicitly an integration test.