Testing

Beignet apps should test the boundary that owns the behavior. There are four test idioms, one per situation:

SituationIdiom
Business rules in a use casecreateTestPorts(...) + createTestContextFactory(...) + createUseCaseTester(...)
Contract request and response over HTTPcreateTestApp(...) with the app's shared appContext blueprint
Workflow artifacts: jobs, listeners, schedules, notifications, tasks, uploadscreateTestContext(...) fixture with dispose()
Repository and persistence behaviorcreateDatabaseTestHarness(...) 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.

HelperWhat it asserts
assertRecordedEventEvents captured by createRecordingEventBus(...)
assertDispatchedJobJobs captured by createRecordingJobDispatcher(...)
assertScheduleRunRun intent captured by createRecordingScheduleRunner(...)
assertMailDeliveryDeliveries on a memory mail port
assertNotificationDeliveryDeliveries on a memory notification port
assertStorageObjectObject content and metadata behind a storage port
assertAuditEntryEntries in a memory audit log
assertIdempotencyCompleted / assertIdempotencyInProgressIdempotency entry state
assertOutboxPending / assertOutboxDelivered / assertOutboxRetryScheduled / assertOutboxDeadLetteredOutbox message state
assertOutboxDrainResultClaimed and delivered counts from drainOutbox(...)
assertProviderInstrumentationEventEvents 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 doctor

test 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.