diff --git a/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md b/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md new file mode 100644 index 000000000..9a7885c36 --- /dev/null +++ b/.drive/projects/hex-composition/slices/reusable-system-testing/spec.md @@ -0,0 +1,109 @@ +# Slice spec: reusable auth System + the testing seam, proven live + +The last slice of the system-composition project (H3). It turns the storefront's +auth from an inline service into a **reusable System that owns its database**, +and ships the **testing utilities** that make any app built on the framework +testable at two altitudes — proven by a unit test, an integration test, and the +existing live deploy. + +Design contract: [`docs/design/10-domains/testing.md`](../../../../../docs/design/10-domains/testing.md) +(the testing model) + [`docs/design/10-domains/system-composition.md`](../../../../../docs/design/10-domains/system-composition.md) +(the System boundary). Deviations amend the docs first. + +## Why + +The composition machinery (H1 boundary + ADR-0017 control plane) is built and +merged, but nothing yet proves the value it exists for: that a System can be +**published, reused, and faked**. Today `examples/storefront-auth/systems/auth` +is a bare service whose Postgres is provisioned by the root and wired into its +`db` input. That is not a reusable unit — a consumer would have to know to +provision auth's storage. H3 makes auth a self-contained System and proves an +app composing it can be tested without a cloud. + +## Deliverables + +### 1. The reusable auth System (owns its db) + +`auth` becomes a **System**, not a service: its body provisions its own Postgres +and its own compute service, wires the db in, and exposes the RPC contract as +the System's output. Its boundary has **no `db` input** — it exposes only +`{ rpc: authContract }`. The package declares `@prisma/*` as **peer +dependencies** (as a published reusable System would) and builds via its own +turbo `build` (standing in for publish-time build). The root `system.ts` no +longer provisions the database; it provisions the auth System and storefront, +wiring auth's exposed `rpc` into storefront's `auth` dependency. + +### 2. `@prisma/app/testing` → `mockService` (unit seam) + +Core, target-agnostic. `mockService(service, overrides)` returns a service node +whose `load()` yields `overrides` merged with the service's param defaults, +**typed against the service's `deps`** (a double not assignable to the dep's +hydrated type is a compile error). New export `@prisma/app/testing` +(`packages/app/src/testing.ts`; add `./testing` to the manifest + its tsdown +entry). It performs no module mocking itself — that stays in the test. + +### 3. `bootstrapService` (integration seam) + +The in-process counterpart of the deploy bootstrap. `bootstrapService(service, +config, boot?)` lives in **`@prisma/app-cloud/testing`** (target-specific — +writing the environment is the serializer's job) and returns a handle +`{ url, fetch }`. It writes the chosen config with the target's own `stash` + +`configOf`, boots the real entry, and hands back a driveable server. **No test +code on the production node** — the `compute()` runtime ships only `run`/`load`. +**`server.ts` is not modified.** No `close()`: the entry owns its `Bun.serve` +handle, so teardown rides on bun-test's per-file process isolation (a single +boot per test file, cleaned up when the file's process ends). This is the +accepted trade for leaving the entry untouched. + +### 4. The fake auth (ships from the auth package) + +A `/fake` export on the auth package: an in-memory `verify` (`serve(fakeAuth, { +rpc: { verify: async ({ token }) => ({ ok: token.length > 0 }) } })`), no +Postgres, sharing the real `authContract` so its handler map is typed against +the same contract. Used by both proof tests. + +## Proof + +- **Unit test** — renders storefront's `page.tsx` with `load()` mocked via + `mockService` to a fake `auth`; asserts the rendered output. No server, no env, + no cloud. (vitest — the storefront's runner.) +- **Integration test** — runs the fake auth on a loopback port, boots storefront + via `bootstrapService` with `auth.url` pointed at it, drives the page over + HTTP, asserts the round trip. No cloud. +- **Live e2e** — the existing "Deploy, verify, destroy" job, unchanged in shape, + now deploys the composed **auth System + storefront** to real Prisma Cloud and + verifies the round trip. This is the reusable-System-deployed-for-real proof. +- All repo gates green (typecheck, test, lint, build, casts delta ≤ 0). + +## Out of scope + +- Running a whole composed graph locally (multi-service `dev` orchestration) — + a separate capability the testing doc lists as a non-goal. +- A runner-agnostic module-mock abstraction — `mockService` ships the typed + payload; the `vi.mock`/`mock.module` wiring stays in the tests. +- The post-merge cleanups (LoadedControl-style lookup dedup; folding + `@prisma/alchemy` into `@prisma/app-cloud`) — tracked separately. + +## Decisions (resolved) + +- **Teardown:** option (a) — no `close()`; bun-test's per-file process isolation + cleans up. `server.ts` stays untouched. (A cleaner "target owns the listen" + refactor is explicitly out of scope for H3.) +- **Both paths ship, not either/or.** The unit test (`mockService`) AND the + integration test (`bootstrapService`) are both deliverables. The integration + test boots the **full Next storefront** in-process against a loopback fake auth + — the real round trip. If Next-in-process boot proves genuinely intractable, + fall back to driving a minimal RPC consumer through `bootstrapService` and flag + it in the final report — do not drop the integration path. + +## Notes for implementation + +- `mockService`'s override type is the service's hydrated deps + (`Client` for rpc, the resource binding for resources) plus optional param + overrides — derive it from the node's `Deps`/params, do not hand-roll. +- `bootstrapService` (`@prisma/app-cloud/testing`) reuses `stash` + (serializer.ts) + `configOf` — it must not add a second serialize path, and + must add nothing to the production `compute()` node; writer/reader parity with + deploy is the whole point. +- Keep the auth System's `authContract` and the fake in one package so the + contract cannot drift. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e0dd8eea..ee9a36f77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,8 @@ jobs: env: STATE_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres run: pnpm test + - name: Type-only tests (vitest --typecheck) + run: pnpm turbo run test:types - name: Test scripts (cast-ratchet unit tests) run: pnpm test:scripts diff --git a/docs/design/10-domains/testing.md b/docs/design/10-domains/testing.md new file mode 100644 index 000000000..eaef468ca --- /dev/null +++ b/docs/design/10-domains/testing.md @@ -0,0 +1,185 @@ +# Testing an app built on the framework + +You test a Prisma App by controlling one function: `service.load()`, the single +call through which application code gets its dependencies. You never change the +code under test to make it testable — you decide what `load()` hands it. Two +tools cover the two situations you'll meet: + +- **`mockService`** — unit-test a piece of code (a page, a server action, a + helper) with fake dependencies. +- **`bootstrapService`** — integration-test the real request path: the service + actually boots and serves, talking to stand-ins you run yourself. + +## A worked example + +The `storefront` app has a page that depends on an `auth` service: + +```tsx +// storefront/app/page.tsx — ordinary application code +import service from '../src/service.ts'; + +export default async function Page() { + const { auth } = service.load(); + const { ok } = await auth.verify({ token: 'demo' }); + return

Signed in: {String(ok)}

; +} +``` + +The page gets `auth` by calling `service.load()`. To test it without a real auth +service — no database, no deployment, no cloud account — you replace what +`load()` returns: + +```tsx +// storefront/app/page.test.tsx +import { mockService } from '@prisma/app/testing'; + +vi.mock('../src/service.ts', () => ({ + default: mockService(realService, { + auth: { verify: async () => ({ ok: true }) }, + }), +})); + +import Page from './page.tsx'; + +expect(renderToString(await Page())).toContain('Signed in: true'); +``` + +The page runs its real logic; only `auth` is a stand-in. Everything below +expands on this one move. + +## Why one function is enough + +A service declares the dependencies it needs and the ports it exposes. Its code +— a page, a server action, an RPC handler, a plain function — never receives +those dependencies as arguments and never reaches for a global. It calls +`service.load()`. + +`load()` does three things: it reads the service's configuration (which a +deployment places in the process environment), turns each dependency into the +concrete client the code will call (ADR-0015), and returns them with their real +types. Because this is the *only* way application code reaches a dependency, it +is the only place a test has to intervene — which is what lets the tests leave +the application code completely untouched. + +The two tools intervene at the same point from opposite directions. +`mockService` decides what `load()` **returns**; `bootstrapService` decides what +`load()` **reads**. Which you want depends on how much of the real path you're +testing. + +## Unit tests: `mockService` + +When you want to test a piece of code in isolation — call it directly, assert on +what it returns or renders — use `mockService`. You mock the service module so +`load()` yields doubles, then exercise the code with no server and no +environment. That is the worked example above. + +`mockService(service, doubles)` returns a copy of the service whose `load()` +returns your doubles merged with the service's own parameter defaults. The +doubles are typed against the service's declared dependencies: a fake `auth` +must be a valid `authContract` client, so a wrong-shaped fake is a compile +error, not a test that passes by accident. + +It works for any service — every service has a `load()` — so it lives in the +framework core, `@prisma/app/testing`. The one part that depends on your test +runner is *how* you substitute the module (`vi.mock` in Vitest, `mock.module` +in bun test). The framework gives you the typed value to substitute; wiring it +into the runner stays in your test. + +## Integration tests: `bootstrapService` + +When you want the real request path — the actual server boot, the real network +client, the real wire format — use `bootstrapService`. It starts your service's +real entry point in a configuration you choose, in-process, and hands you +something you can send HTTP requests to. You point one of its dependencies at a +stand-in you run yourself, and drive the round trip. + +```ts +// storefront/app/page.integration.test.ts +import { bootstrapService } from '@prisma/app-cloud/testing'; +import fakeAuth from '@storefront-auth/auth/fake'; // an in-memory auth handler, no database +import storefront from '../src/service.ts'; + +// run the fake auth on a loopback port +const fake = Bun.serve({ port: 0, fetch: fakeAuth }); + +const app = await bootstrapService(storefront, { + service: { port: 4310 }, + inputs: { auth: { url: fake.url.href } }, // point storefront's auth dependency at the fake +}); + +const res = await app.fetch(new Request(app.url)); +expect(await res.text()).toContain('Signed in: true'); +``` + +The point is what *doesn't* change: `storefront`'s server code is untouched. It +boots and listens exactly as it does in production; the test only chooses the +configuration it boots with. `load()` reads that configuration the same way a +deployed process would, so pointing `auth` at `http://localhost:…` is the same +mechanism a deployment uses to point it at the real service. You exercise the +production code path, not a rewrite of it. + +Starting a service the way a deployment does is specific to the platform you +deploy to, so `bootstrapService` ships in that platform's testing entry +(`@prisma/app-cloud/testing`), not the core. (`mockService` only substitutes a +return value, so it needs to know nothing about deployment and stays in core.) + +Three practical notes: + +- **You choose the port.** The service listens on it and never reports an + OS-assigned one back, so pass a concrete number. +- **There is no `close()`.** Run each integration-test file in its own process + (bun test does), and the server it started is cleaned up when the file ends. +- **A Next.js service needs one extra argument.** `bootstrapService` finds most + services' entry points automatically, but a Next.js app's built entry lives + inside Next's standalone output directory, so you pass a small function that + imports it: + + ```ts + import { standaloneEntryPath } from '@prisma/app-nextjs/control'; + + await bootstrapService(storefront, config, async () => { + await import(standaloneEntryPath(storefront.build)); + }); + ``` + +## The stand-in: same contract, checked by the compiler + +What do you pass as the fake? A dependency's type *is* its contract. An RPC +dependency on `authContract` becomes a client with a `verify(input) => +Promise` method, so any value of that shape is a valid double and the +compiler rejects one that isn't. You choose how realistic to make it: + +- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest; no + network, no serialization. Right when only the return value matters. +- **The real client over an in-memory handler** — the framework's own client + talking to your fake through an in-process function instead of the network. + JSON encoding and both schema validations still run; there is just no socket. +- **A real local server** — the fake served on a loopback port and reached over + real HTTP. This is what `bootstrapService` drives. + +A dependency's package can ship its own fake as a separate entry point, kept out +of the deployed code. Because that fake is written against the same contract the +real service exposes, the two cannot drift apart. + +## Alternatives considered + +- **Add injection points to the code under test** (constructor or parameter + injection, so a test passes fakes in directly). Rejected: application code + would carry test-only seams, and there is nothing to add — `load()` already + *is* the single point every dependency flows through. +- **Boot the whole composed app locally**, several services wired together in + one process. Rejected as a *different* capability: `bootstrapService` boots + one service. Running a full graph locally (a local `dev`) is worth building on + its own terms, but it is not this seam. +- **A runner-agnostic mock wrapper** hiding `vi.mock`/`mock.module` behind one + API. Rejected: runners differ in module-mock mechanics (hoisting, ESM + handling) in ways not worth papering over. The framework supplies the typed + value and documents the per-runner pattern instead. + +## Related + +- [`core-model.md`](core-model.md) — the `run`/`load` split these tools drive. +- [`deploy-cli.md`](deploy-cli.md) — the deployment boot that `bootstrapService` + mirrors. +- [`../90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md`](../90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md) + — why a hydrated dependency is a client a test can stand in for. diff --git a/examples/storefront-auth/system.ts b/examples/storefront-auth/system.ts index 8d1cb6f59..8935e6480 100644 --- a/examples/storefront-auth/system.ts +++ b/examples/storefront-auth/system.ts @@ -1,26 +1,19 @@ import { system } from '@prisma/app'; -import { postgres } from '@prisma/app-cloud'; -import authService from '@storefront-auth/auth'; +import authSystem from '@storefront-auth/auth'; import storefrontService from '@storefront-auth/storefront'; /** - * The storefront-auth app: two services and their shared Postgres in one system. - * The system owns the database and wires it into auth's `db` slot; `auth` exposes - * an RPC contract; `storefront` consumes it (auth's `rpc` port → storefront's - * `auth` slot, compat-checked). Transparent wiring, executed at Load. - * - * The provision id is `database`, not `db`: the prisma-cloud target passes it - * through as the Prisma resource name, and the Connection API rejects names - * shorter than 3 characters. The wiring key stays `db` (auth's input name), so - * the deployed env key is still `AUTH_DB_URL` — it derives from the input - * name, not the provision id. + * The storefront-auth app: the reusable auth System (owns its own Postgres) + * and the storefront service, composed in one root. The root provisions + * nothing of auth's internals — it wires auth's exposed `rpc` port into + * storefront's `auth` slot exactly as it would for any other producer of + * that contract. * * A closed root: empty boundary (no inputs, no outputs) — nothing wires into * or out of this system from the outside. */ export default system('storefront-auth', {}, ({ provision }) => { - const db = provision('database', postgres({ name: 'database' })); - const authRef = provision('auth', authService, { db }); - provision('storefront', storefrontService, { auth: authRef.rpc }); + const auth = provision('auth', authSystem); + provision('storefront', storefrontService, { auth: auth.rpc }); return {}; }); diff --git a/examples/storefront-auth/systems/auth/package.json b/examples/storefront-auth/systems/auth/package.json index 9bc7d4eec..998bd7a20 100644 --- a/examples/storefront-auth/systems/auth/package.json +++ b/examples/storefront-auth/systems/auth/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "exports": { - ".": "./src/service.ts", - "./contract": "./src/contract.ts" + ".": "./src/system.ts", + "./contract": "./src/contract.ts", + "./fake": "./testing/fake.ts" }, "scripts": { "dev": "bun run scripts/dev.ts", @@ -13,13 +14,19 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "arktype": "^2.2.3" + }, + "peerDependencies": { "@prisma/app": "workspace:0.2.0", "@prisma/app-node": "workspace:0.2.0", "@prisma/app-cloud": "workspace:0.2.0", - "@prisma/app-rpc": "workspace:0.2.0", - "arktype": "^2.2.3" + "@prisma/app-rpc": "workspace:0.2.0" }, "devDependencies": { + "@prisma/app": "workspace:0.2.0", + "@prisma/app-node": "workspace:0.2.0", + "@prisma/app-cloud": "workspace:0.2.0", + "@prisma/app-rpc": "workspace:0.2.0", "@types/bun": "^1.3.13", "tsdown": "^0.22.3", "typescript": "^6.0.3" diff --git a/examples/storefront-auth/systems/auth/src/system.ts b/examples/storefront-auth/systems/auth/src/system.ts new file mode 100644 index 000000000..3041a8a53 --- /dev/null +++ b/examples/storefront-auth/systems/auth/src/system.ts @@ -0,0 +1,23 @@ +/** + * The auth System: a reusable unit that owns its own Postgres. Provisions the + * database and the auth compute service (service.ts), wires the db into the + * service's `db` input, and exposes the service's `rpc` port as the System's + * own output. A consumer never provisions auth's storage itself — it wires + * only the exposed `rpc` contract (system-composition.md). + * + * The provision id for the database is "database", not "db": the + * prisma-cloud target passes it through as the Prisma resource name, and the + * Connection API rejects names shorter than 3 characters. The wiring key + * (the service's own input name) stays "db" — the deployed env key derives + * from that, not the provision id. + */ +import { system } from '@prisma/app'; +import { postgres } from '@prisma/app-cloud'; +import { authContract } from './contract.ts'; +import authService from './service.ts'; + +export default system('auth', { expose: { rpc: authContract } }, ({ provision }) => { + const db = provision('database', postgres({ name: 'database' })); + const service = provision('service', authService, { db }); + return { rpc: service.rpc }; +}); diff --git a/examples/storefront-auth/systems/auth/testing/fake.ts b/examples/storefront-auth/systems/auth/testing/fake.ts new file mode 100644 index 000000000..d854a1eb9 --- /dev/null +++ b/examples/storefront-auth/systems/auth/testing/fake.ts @@ -0,0 +1,30 @@ +/** + * A dummy auth service for TESTING a system that depends on auth — inject it in + * place of the real one so a consumer's tests need no Postgres and no deploy. + * It serves the real `authContract` (so its handler map is type-checked against + * the same contract the real auth exposes) with an in-memory `verify`. A + * test-only entrypoint, deliberately outside `src/`, so it can never ride into + * the deployed artifact. + * + * `serve()` needs a service node with the right `expose`, so this wraps a + * minimal `compute()`. That node's `build` is inert — the fake is never + * assembled or deployed, only `serve()`d on a loopback port by a consumer's + * integration test. + */ +import { compute } from '@prisma/app-cloud'; +import node from '@prisma/app-node'; +import { serve } from '@prisma/app-rpc'; +import { authContract } from '../src/contract.ts'; + +const fakeAuth = compute({ + name: 'auth-fake', + deps: {}, + build: node({ module: import.meta.url, entry: 'fake.ts' }), + expose: { rpc: authContract }, +}); + +export default serve(fakeAuth, { + rpc: { + verify: async ({ token }) => ({ ok: token.length > 0 }), + }, +}); diff --git a/examples/storefront-auth/systems/storefront/app/page.integration.test.ts b/examples/storefront-auth/systems/storefront/app/page.integration.test.ts new file mode 100644 index 000000000..36097c516 --- /dev/null +++ b/examples/storefront-auth/systems/storefront/app/page.integration.test.ts @@ -0,0 +1,64 @@ +/// +/** + * Integration proof (testing.md § Integration): the real request path — the + * actual Next.js standalone entry, the real RPC client, real HTTP — driven + * by `bootstrapService` against a fake auth listening on a loopback port. No + * cloud, no deploy; `server.ts`/the Next build output are untouched. Run via + * `bun test` (not vitest — the unit test's runner): it needs `Bun.serve` for + * the loopback fake, and the H3 teardown decision (no `close()`) rests on + * bun-test's per-file process isolation. + * + * storefront's build adapter is `nextjs()`: its `entry` is a bare filename + * inside Next's standalone OUTPUT directory, not a path relative to + * `build.module` — `bootstrapService`'s default derivation fits the `node` + * adapter (see auth), not this one, so this test supplies its own boot + * thunk, reusing the same standalone-path math + * `@prisma/app-nextjs/control`'s deploy assembly uses (`nextStandaloneDir`). + * Requires `next build` to have already produced `.next/standalone` (turbo's + * `test` task depends on `build`, so `pnpm -w test` always has it). + */ +import { describe, expect, it } from 'bun:test'; +import { pathToFileURL } from 'node:url'; +import type { BuildAdapter } from '@prisma/app'; +import { bootstrapService } from '@prisma/app-cloud/testing'; +import type { NextjsBuildAdapter } from '@prisma/app-nextjs'; +import { standaloneEntryPath } from '@prisma/app-nextjs/control'; +import fakeAuthHandler from '@storefront-auth/auth/fake'; +import storefrontService from '../src/service.ts'; + +const PORT = 4310; + +function isNextjsBuild(build: BuildAdapter): build is NextjsBuildAdapter { + return build.type === 'nextjs' && 'appDir' in build && typeof build.appDir === 'string'; +} + +/** Boots the built standalone Next entry — its own `server.js`, unmodified — via the same path `assemble()` resolves for deploy (not the same bootstrap chain: deploy goes bootstrap.js -> main.mjs -> server.js; here `bootstrapService`'s `stash` stands in for the wrapper's env write). */ +function bootStandaloneNext(build: NextjsBuildAdapter): () => Promise { + const entryPath = standaloneEntryPath(build); + return async () => { + await import(pathToFileURL(entryPath).href); + }; +} + +describe('storefront -> auth round trip, driven over real HTTP (bootstrapService)', () => { + it('renders auth.verify() -> { ok: true } served by the fake auth on a loopback port', async () => { + if (!isNextjsBuild(storefrontService.build)) { + throw new Error('expected the storefront service to use the nextjs build adapter'); + } + + const fake = Bun.serve({ port: 0, fetch: fakeAuthHandler }); + + const app = await bootstrapService( + storefrontService, + { service: { port: PORT }, inputs: { auth: { url: fake.url.href } } }, + bootStandaloneNext(storefrontService.build), + ); + + const res = await app.fetch(new Request(app.url)); + // React separates the static text from the {String(ok)} expression with an + // empty `` comment; assert around it rather than stripping HTML. + const html = await res.text(); + + expect(html).toContain('Auth /verify says: true'); + }); +}); diff --git a/examples/storefront-auth/systems/storefront/app/page.test.tsx b/examples/storefront-auth/systems/storefront/app/page.test.tsx new file mode 100644 index 000000000..ebaeb2b54 --- /dev/null +++ b/examples/storefront-auth/systems/storefront/app/page.test.tsx @@ -0,0 +1,28 @@ +/** + * Unit proof (testing.md § Unit): mocks storefront's own service module so + * `load()` returns a typed fake auth via `mockService`, then renders the page + * directly — no server, no environment, no cloud. + */ +import { mockService } from '@prisma/app/testing'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import type Service from '../src/service.ts'; + +vi.mock('../src/service.ts', async () => { + const actual = await vi.importActual<{ default: typeof Service }>('../src/service.ts'); + return { + default: mockService(actual.default, { + auth: { verify: async ({ token }) => ({ ok: token.length > 0 }) }, + }), + }; +}); + +describe('Home (page.tsx)', () => { + it('renders the storefront -> auth round trip with load() stubbed to a fake auth', async () => { + const { default: Home } = await import('./page.tsx'); + + const html = renderToStaticMarkup(await Home()); + + expect(html).toContain('Auth /verify says: true'); + }); +}); diff --git a/examples/storefront-auth/systems/storefront/package.json b/examples/storefront-auth/systems/storefront/package.json index d62b0542e..2505e102a 100644 --- a/examples/storefront-auth/systems/storefront/package.json +++ b/examples/storefront-auth/systems/storefront/package.json @@ -9,7 +9,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run && bun test app/page.integration.test.ts" }, "dependencies": { "@prisma/app": "workspace:0.2.0", @@ -23,9 +24,11 @@ "react-dom": "19.2.7" }, "devDependencies": { + "@types/bun": "^1.3.13", "@types/node": "^25.9.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.9" } } diff --git a/examples/storefront-auth/systems/storefront/tsconfig.json b/examples/storefront-auth/systems/storefront/tsconfig.json index 57b85d973..0f45add30 100644 --- a/examples/storefront-auth/systems/storefront/tsconfig.json +++ b/examples/storefront-auth/systems/storefront/tsconfig.json @@ -27,5 +27,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "**/*.integration.test.ts"] } diff --git a/examples/storefront-auth/systems/storefront/vitest.config.ts b/examples/storefront-auth/systems/storefront/vitest.config.ts new file mode 100644 index 000000000..9e0a8024f --- /dev/null +++ b/examples/storefront-auth/systems/storefront/vitest.config.ts @@ -0,0 +1,17 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +// page.tsx relies on Next's automatic JSX runtime (no `import React` in +// scope) and its own tsconfig sets `jsx: "preserve"` for Next's own +// compiler — vite's oxc transform needs an explicit override so it doesn't +// inherit that setting. +// +// *.integration.test.ts runs under `bun test` instead (see that file) — it +// needs `Bun.serve` and the H3 teardown decision rests on bun-test's +// per-file process isolation — so vitest must not also pick it up. +export default defineConfig({ + oxc: { jsx: { runtime: 'automatic' } }, + test: { + environment: 'node', + exclude: [...configDefaults.exclude, '**/*.integration.test.ts'], + }, +}); diff --git a/packages/app-cloud/package.json b/packages/app-cloud/package.json index 5de3f0cd0..16eca9079 100644 --- a/packages/app-cloud/package.json +++ b/packages/app-cloud/package.json @@ -5,6 +5,7 @@ "exports": { ".": "./dist/index.mjs", "./control": "./dist/control.mjs", + "./testing": "./dist/testing.mjs", "./package.json": "./package.json" }, "scripts": { diff --git a/packages/app-cloud/src/__tests__/extension.test.ts b/packages/app-cloud/src/__tests__/extension.test.ts index 5adce2a41..7cfdec5e0 100644 --- a/packages/app-cloud/src/__tests__/extension.test.ts +++ b/packages/app-cloud/src/__tests__/extension.test.ts @@ -3,6 +3,7 @@ import type { Contract } from '@prisma/app'; import { configOf, hydrateSync, isNode } from '@prisma/app'; import { compute, postgres, postgresContract } from '../index.ts'; import { configKey, deserialize } from '../serializer.ts'; +import { bootstrapService } from '../testing.ts'; const build = { extension: '@prisma/app-node', @@ -345,6 +346,63 @@ describe('compute().run(address, boot) → load() — the round trip', () => { }); }); +describe('bootstrapService(service, config, boot) — the in-process integration seam', () => { + test("stashes the given Config address-free (like run('', ...)) so the booted entry's load() reads it", async () => { + const app = compute({ name: 'test-service', deps: { db: postgres() }, build }); + + let loaded: unknown; + await withEnv({ DB_URL: '', PORT: '' }, () => + bootstrapService( + app, + { service: { port: 4321 }, inputs: { db: { url: 'postgres://bootstrap' } } }, + async () => { + loaded = app.load(); + }, + ), + ); + + expect(loaded).toEqual({ db: { url: 'postgres://bootstrap' }, port: 4321 }); + }); + + test('needs no pre-set environment — the caller supplies the Config directly, unlike run()', async () => { + const app = compute({ name: 'test-service', deps: { db: postgres() }, build }); + + let loaded: unknown; + await withEnv({ DB_URL: 'stale', PORT: 'stale' }, () => + bootstrapService( + app, + { service: { port: 5555 }, inputs: { db: { url: 'postgres://fresh' } } }, + async () => { + loaded = app.load(); + }, + ), + ); + + expect(loaded).toEqual({ db: { url: 'postgres://fresh' }, port: 5555 }); + }); + + test('returns { url, fetch } pointing at the configured port', async () => { + const app = compute({ name: 'test-service', deps: {}, build }); + + const svc = await bootstrapService( + app, + { service: { port: 6789 }, inputs: {} }, + async () => {}, + ); + + expect(svc.url).toBe('http://localhost:6789/'); + expect(typeof svc.fetch).toBe('function'); + }); + + test('rejects a Config with no concrete port — the entry self-listens', async () => { + const app = compute({ name: 'test-service', deps: {}, build }); + + await expect( + bootstrapService(app, { service: {}, inputs: {} }, async () => {}), + ).rejects.toThrow(/concrete port number/); + }); +}); + describe('compute().load()', () => { test('returns the deps merged with resolved params, memoized per process (same object on re-load)', async () => { const app = compute({ diff --git a/packages/app-cloud/src/__tests__/invariants.test.ts b/packages/app-cloud/src/__tests__/invariants.test.ts index fbcb276fd..fd4a31ad5 100644 --- a/packages/app-cloud/src/__tests__/invariants.test.ts +++ b/packages/app-cloud/src/__tests__/invariants.test.ts @@ -22,12 +22,12 @@ function shippedSources(): { file: string; text: string }[] { return out; } -describe('entry map: the extension splits into authoring + control only', () => { - test("package.json exports exactly '.' and './control' — no runtime entry", () => { +describe('entry map: authoring + control + testing, no runtime entry', () => { + test("package.json exports '.', './control', and './testing'", () => { const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); // `./package.json` is a conventional manifest export, not a code entry. const codeEntries = Object.keys(pkg.exports).filter((k) => k !== './package.json'); - expect(codeEntries.sort()).toEqual(['.', './control']); + expect(codeEntries.sort()).toEqual(['.', './control', './testing']); }); }); diff --git a/packages/app-cloud/src/testing.ts b/packages/app-cloud/src/testing.ts new file mode 100644 index 000000000..c8a4781c8 --- /dev/null +++ b/packages/app-cloud/src/testing.ts @@ -0,0 +1,55 @@ +/** + * Prisma Cloud's integration-test seam (testing.md § Integration). + * `bootstrapService` boots a compute service's real entry in-process against a + * caller-chosen Config, writing that Config to the environment with the exact + * `stash` the deploy boot uses — so `load()` reads it identically to a + * deployed process. Target-specific, and here rather than in core, because + * writing the environment is this extension's serializer's job; nothing about + * this reaches the production runtime (`compute()` ships only `run`/`load`). + */ + +import type { Deps, Expose, Params, RunnableServiceNode } from '@prisma/app'; +import { type Config, configOf } from '@prisma/app'; +import { stash } from './serializer.ts'; + +/** What `bootstrapService` hands back: a live, driveable instance of the booted entry. */ +export interface BootstrappedService { + readonly url: string; + readonly fetch: typeof fetch; +} + +/** + * Boots `service`'s real entry against `config`, in-process. By default the + * entry is `service.build.entry` resolved against `service.build.module` — + * exactly how the printed deploy bootstrap imports it (see `@prisma/alchemy`'s + * artifact.ts) — which fits a build adapter whose `entry` is a plain + * module-relative path (`@prisma/app-node`'s). A build adapter whose bootable + * path isn't module-relative (`@prisma/app-nextjs`'s standalone output) + * supplies its own `boot` thunk; the target owns that resolution. + * + * `config.service.port` must be concrete — the entry self-listens and never + * reports an OS-assigned port back. No `close()`: teardown rides bun-test's + * per-file process isolation (H3's resolved decision). + */ +export async function bootstrapService( + service: RunnableServiceNode, + config: Config, + boot?: () => Promise, +): Promise { + const port = config.service['port']; + if (typeof port !== 'number') { + throw new Error( + 'bootstrapService(): config.service.port must be a concrete port number — the booted entry ' + + 'self-listens with no way to report an OS-assigned one back to the caller.', + ); + } + const bootEntry = + boot ?? + (async () => { + await import(new URL(service.build.entry, service.build.module).href); + }); + + stash(configOf(service), config); + await bootEntry(); + return { url: `http://localhost:${port}/`, fetch }; +} diff --git a/packages/app-cloud/tsdown.config.ts b/packages/app-cloud/tsdown.config.ts index af0526ad3..3cd8e5fab 100644 --- a/packages/app-cloud/tsdown.config.ts +++ b/packages/app-cloud/tsdown.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from '@prisma/app-tsdown'; export default defineConfig({ - entry: ['src/index.ts', 'src/control.ts'], + entry: ['src/index.ts', 'src/control.ts', 'src/testing.ts'], }); diff --git a/packages/app-nextjs/src/control.ts b/packages/app-nextjs/src/control.ts index 838e78449..f6ab14d37 100644 --- a/packages/app-nextjs/src/control.ts +++ b/packages/app-nextjs/src/control.ts @@ -53,6 +53,12 @@ export function nextStandaloneDir(appDir: string): string { return path.join(resolvedApp, '.next', 'standalone', rel); } +/** The bootable standalone entry for a nextjs build — the output dir joined with the adapter's `entry`. Single-sourced so `assemble()` (deploy) and the integration-test seam can't drift. */ +export function standaloneEntryPath(build: NextjsBuildAdapter): string { + const resolvedApp = path.resolve(path.dirname(fileURLToPath(build.module)), build.appDir); + return path.join(nextStandaloneDir(resolvedApp), build.entry); +} + export async function assemble(input: AssembleInput): Promise { if (!isNextjsBuild(input.build)) { throw new Error( @@ -65,7 +71,7 @@ export async function assemble(input: AssembleInput): Promise { const moduleDir = path.dirname(serviceModule); const resolvedApp = path.resolve(moduleDir, buildDescriptor.appDir); const appOut = nextStandaloneDir(resolvedApp); - const entryPath = path.join(appOut, buildDescriptor.entry); + const entryPath = standaloneEntryPath(buildDescriptor); if (!fs.existsSync(entryPath)) { throw new Error( `no standalone ${buildDescriptor.entry} at ${appOut} — run \`next build\` with output: "standalone" first`, diff --git a/packages/app/package.json b/packages/app/package.json index c72d7b382..a714f47fe 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,6 +8,7 @@ "./casts": "./dist/casts.mjs", "./config": "./dist/config.mjs", "./deploy": "./dist/deploy.mjs", + "./testing": "./dist/testing.mjs", "./package.json": "./package.json" }, "types": "./dist/index.d.mts", diff --git a/packages/app/src/__tests__/invariants.test.ts b/packages/app/src/__tests__/invariants.test.ts index 568ce97a5..f2bfe9b9a 100644 --- a/packages/app/src/__tests__/invariants.test.ts +++ b/packages/app/src/__tests__/invariants.test.ts @@ -33,11 +33,18 @@ const leanTokens = [ ]; describe('entry map: core splits into authoring + deploy + pure utils — no runtime entry', () => { - test("package.json exports '.', './deploy', './config', and the ./casts + ./assertions utilities", () => { + test("package.json exports '.', './deploy', './config', './testing', and the ./casts + ./assertions utilities", () => { const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); // `./package.json` is a conventional manifest export, not a code entry. const codeEntries = Object.keys(pkg.exports).filter((k) => k !== './package.json'); - expect(codeEntries.sort()).toEqual(['.', './assertions', './casts', './config', './deploy']); + expect(codeEntries.sort()).toEqual([ + '.', + './assertions', + './casts', + './config', + './deploy', + './testing', + ]); }); }); diff --git a/packages/app/src/__tests__/testing.test-d.ts b/packages/app/src/__tests__/testing.test-d.ts new file mode 100644 index 000000000..8696aec44 --- /dev/null +++ b/packages/app/src/__tests__/testing.test-d.ts @@ -0,0 +1,84 @@ +/** + * `mockService`'s override argument is typed against the service's own `deps` + * (`HydratedDeps`) and `params` (`Partial>`) — a double that + * doesn't satisfy a dep's hydrated shape, or a param of the wrong type, must + * fail to compile. Type-only (vitest `--typecheck`, never executed): see + * testing.test.ts for the runtime behavior. + */ +import { expectTypeOf, test } from 'vitest'; +import type { ConfigParam } from '../config.ts'; +import type { BuildAdapter, RunnableServiceNode } from '../node.ts'; +import { dependency, service } from '../node.ts'; +import { mockService } from '../testing.ts'; +import { conn } from './helpers.ts'; + +const build: BuildAdapter = { + extension: '@prisma/app-node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +interface Verify { + verify(input: { token: string }): Promise<{ ok: boolean }>; +} + +const authDep = () => + dependency<{ url: { type: 'string' } }, Verify>({ + type: 'fake/rpc', + connection: conn( + { url: { type: 'string' } }, + (): Verify => ({ + verify: async () => ({ ok: false }), + }), + ), + }); + +type ConsumerDeps = { auth: ReturnType }; +type ConsumerParams = { port: ConfigParam<'number'> }; + +const consumer = (): RunnableServiceNode => + Object.freeze({ + ...service({ + name: 'consumer', + extension: 'test/pack', + type: 'fake/compute', + inputs: { auth: authDep() }, + params: { port: { type: 'number', default: 3000 } }, + build, + }), + async run(): Promise { + throw new Error('unused — type-only file, never executed'); + }, + load() { + throw new Error('unused — type-only file, never executed'); + }, + }); + +test('a correctly-shaped double, with or without the optional param override, compiles', () => { + const withoutParam = mockService(consumer(), { + auth: { verify: async ({ token }: { token: string }) => ({ ok: token.length > 0 }) }, + }); + const withParam = mockService(consumer(), { + auth: { verify: async () => ({ ok: true }) }, + port: 8080, + }); + + expectTypeOf(withoutParam).toEqualTypeOf>(); + expectTypeOf(withParam).toEqualTypeOf>(); +}); + +test('omitting the required "auth" override is a compile error', () => { + // @ts-expect-error "auth" is a declared dep with no default — it must be supplied + mockService(consumer(), {}); +}); + +test("a double whose method return shape doesn't satisfy the dep's hydrated contract is a compile error", () => { + // @ts-expect-error verify must resolve to `{ ok: boolean }`, not `{ status: string }` + mockService(consumer(), { auth: { verify: async () => ({ status: 'ok' }) } }); +}); + +test('overriding a param with the wrong type is a compile error', () => { + // @ts-expect-error port is declared `number` + mockService(consumer(), { auth: { verify: async () => ({ ok: true }) }, port: 'nope' }); +}); diff --git a/packages/app/src/__tests__/testing.test.ts b/packages/app/src/__tests__/testing.test.ts new file mode 100644 index 000000000..c95cc2c3e --- /dev/null +++ b/packages/app/src/__tests__/testing.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test'; +import type { ConfigParam } from '../config.ts'; +import type { BuildAdapter, RunnableServiceNode } from '../node.ts'; +import { dependency, service } from '../node.ts'; +import { mockService } from '../testing.ts'; +import { conn } from './helpers.ts'; + +const build: BuildAdapter = { + extension: '@prisma/app-node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +interface Verify { + verify(input: { token: string }): Promise<{ ok: boolean }>; +} + +const authDep = () => + dependency<{ url: { type: 'string' } }, Verify>({ + type: 'fake/rpc', + connection: conn( + { url: { type: 'string' } }, + (): Verify => ({ + verify: async () => ({ ok: false }), + }), + ), + }); + +type ConsumerDeps = { auth: ReturnType }; +type ConsumerParams = { port: ConfigParam<'number'> }; + +/** A RunnableServiceNode fixture whose own run()/load() must never actually run — mockService replaces load() entirely, and run() is never called under a stub. */ +const consumer = (): RunnableServiceNode => + Object.freeze({ + ...service({ + name: 'consumer', + extension: 'test/pack', + type: 'fake/compute', + inputs: { auth: authDep() }, + params: { port: { type: 'number', default: 3000 } }, + build, + }), + async run(): Promise { + throw new Error('consumer.run() should never be called under mockService.'); + }, + load() { + throw new Error( + 'consumer.load() should never be reached — mockService replaces it entirely.', + ); + }, + }); + +describe('mockService', () => { + test("load() yields the override merged with the service's param defaults", async () => { + const stub = mockService(consumer(), { + auth: { verify: async ({ token }) => ({ ok: token.length > 0 }) }, + }); + + const { auth, port } = stub.load(); + expect(port).toBe(3000); + expect(await auth.verify({ token: 'x' })).toEqual({ ok: true }); + }); + + test('an overridden param wins over the default', () => { + const stub = mockService(consumer(), { + auth: { verify: async () => ({ ok: true }) }, + port: 8080, + }); + + expect(stub.load().port).toBe(8080); + }); + + test('load() returns the same object on every call', () => { + const stub = mockService(consumer(), { auth: { verify: async () => ({ ok: true }) } }); + expect(stub.load()).toBe(stub.load()); + }); + + test('run() throws, naming the service', () => { + const stub = mockService(consumer(), { auth: { verify: async () => ({ ok: true }) } }); + expect(() => stub.run('addr', async () => undefined)).toThrow(/"consumer".*load\(\)-only mock/); + }); + + test('deps/params/build/name pass through unchanged', () => { + const original = consumer(); + const stub = mockService(original, { auth: { verify: async () => ({ ok: true }) } }); + expect(stub.inputs).toBe(original.inputs); + expect(stub.params).toBe(original.params); + expect(stub.build).toBe(original.build); + expect(stub.name).toBe(original.name); + }); +}); diff --git a/packages/app/src/testing.ts b/packages/app/src/testing.ts new file mode 100644 index 000000000..1c93c0352 --- /dev/null +++ b/packages/app/src/testing.ts @@ -0,0 +1,59 @@ +/** + * The unit-test seam (testing.md § Unit): `mockService` replaces a service + * node's `load()` output so any code that pulls dependencies through + * `service.load()` — a page, a server action, a helper — runs against typed + * doubles with no server and no environment. Target-agnostic: every service + * node has a `load()`. It does no module mocking; wiring the substitution into + * a test runner (`vi.mock`, `mock.module`) stays in the test. The integration + * seam (`bootstrapService`) is target-specific and lives in the target's own + * testing entry (e.g. `@prisma/app-cloud/testing`). + */ +import { blindCast } from './casts.ts'; +import type { Params, Values } from './config.ts'; +import type { Deps, Expose, HydratedDeps, Loaded, RunnableServiceNode } from './node.ts'; + +/** + * `mockService`'s override argument: every declared dependency, typed against + * its own hydrated shape (`Client` for an RPC dep, the resource binding + * for a resource dep) — a double of the wrong shape is a compile error. The + * service's own params are optional; an omitted one falls back to its + * declared default, same as a real `load()`. + */ +export type LoadOverrides = HydratedDeps & Partial>; + +function paramDefaults

(params: P): Partial> { + const defaults: Record = {}; + for (const [name, param] of Object.entries(params)) { + if (param.default !== undefined) defaults[name] = param.default; + } + return blindCast< + Partial>, + "assembled from each param declaration's own default value, one key per param that declares one — exactly Partial> by construction" + >(defaults); +} + +/** + * Returns a service node whose `load()` yields `overrides` merged with the + * service's own param defaults — everything else about the node (its deps, + * params, build, expose) is unchanged. `run()` is not meaningful on a mock + * (there is no boot, no environment) and throws if called. + */ +export function mockService( + service: RunnableServiceNode, + overrides: LoadOverrides, +): RunnableServiceNode { + const loaded = blindCast< + Loaded, + 'merges the param defaults with the caller-supplied overrides, which LoadOverrides already types against HydratedDeps & Partial> — exactly Loaded once params are filled in' + >({ ...paramDefaults(service.params), ...overrides }); + + return Object.freeze({ + ...service, + run(): Promise { + throw new Error( + `mockService(): "${service.name}" is a load()-only mock — it has no run() (no boot, no environment).`, + ); + }, + load: () => loaded, + }); +} diff --git a/packages/app/tsdown.config.ts b/packages/app/tsdown.config.ts index 5f6c95911..f70301b10 100644 --- a/packages/app/tsdown.config.ts +++ b/packages/app/tsdown.config.ts @@ -10,5 +10,6 @@ export default defineConfig({ casts: 'src/casts.ts', assertions: 'src/assertions.ts', config: 'src/app-config.ts', + testing: 'src/testing.ts', }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3e07869f..99737dc3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,10 @@ importers: examples/storefront-auth/systems/auth: dependencies: + arktype: + specifier: ^2.2.3 + version: 2.2.3 + devDependencies: '@prisma/app': specifier: workspace:0.2.0 version: link:../../../../packages/app @@ -96,10 +100,6 @@ importers: '@prisma/app-rpc': specifier: workspace:0.2.0 version: link:../../../../packages/app-rpc - arktype: - specifier: ^2.2.3 - version: 2.2.3 - devDependencies: '@types/bun': specifier: ^1.3.13 version: 1.3.14 @@ -140,6 +140,9 @@ importers: specifier: 19.2.7 version: 19.2.7(react@19.2.7) devDependencies: + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 '@types/node': specifier: ^25.9.3 version: 25.9.5 @@ -152,6 +155,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.27.0)(jiti@2.6.1)(yaml@2.9.0)) packages/alchemy: dependencies: @@ -5020,7 +5026,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4 diff --git a/skills/testing-prisma-apps/SKILL.md b/skills/testing-prisma-apps/SKILL.md new file mode 100644 index 000000000..f9fe7bd6c --- /dev/null +++ b/skills/testing-prisma-apps/SKILL.md @@ -0,0 +1,110 @@ +--- +name: testing-prisma-apps +description: >- + How to test an app built on the Prisma App Framework. Every dependency an app + uses arrives through one call, `service.load()`; you test by controlling what + it returns or reads, never by editing the code under test. Two tools: + `mockService` (from `@prisma/app/testing`) for unit tests, and + `bootstrapService` (from `@prisma/app-cloud/testing`) for integration tests. + Use when writing tests for a Prisma App, faking a service dependency, + unit-testing a page / server action / RPC handler, or integration-testing the + real request path without a deployment. Triggers on "test a prisma app", + "mockService", "bootstrapService", "fake a service dependency", + "test a page/action without deploying", "@prisma/app/testing". +--- + +# Testing Prisma Apps + +An app's code gets its dependencies from exactly one place — `service.load()` — +and never as arguments or globals. So you test by deciding what `load()` gives +the code, and you leave the code under test unchanged. Pick the tool by how much +of the real path you want to run. + +| You want to… | Use | From | +| --- | --- | --- | +| Test a page / action / handler in isolation | `mockService` | `@prisma/app/testing` | +| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/app-cloud/testing` | + +The full model is in +[`docs/design/10-domains/testing.md`](../../docs/design/10-domains/testing.md). + +## Unit test — `mockService` + +For code you call directly. Mock the code's own service module so `load()` +returns doubles, then run the code with no server and no environment: + +```tsx +// page.test.tsx +import { mockService } from '@prisma/app/testing'; +import realService from '../src/service.ts'; + +vi.mock('../src/service.ts', () => ({ + default: mockService(realService, { + // typed against the dependency's contract — a wrong-shaped fake won't compile + auth: { verify: async () => ({ ok: true }) }, + }), +})); + +import Page from './page.tsx'; +expect(renderToString(await Page())).toContain('Signed in: true'); +``` + +- `mockService(service, doubles)` returns a copy of the service whose `load()` + yields your `doubles` merged with the service's parameter defaults. +- The doubles are type-checked against the service's declared dependencies. +- Wiring the module substitution is your runner's job: `vi.mock` (Vitest), + `mock.module` (bun test). `mockService` only supplies the typed value. + +## Integration test — `bootstrapService` + +For the real request path: the service actually boots and serves, talking to a +stand-in you run. Point a dependency at the stand-in via the config, then drive +HTTP requests: + +```ts +// service.integration.test.ts (run under `bun test`) +import { bootstrapService } from '@prisma/app-cloud/testing'; +import fakeAuth from '@storefront-auth/auth/fake'; // an in-memory handler, no db +import storefront from '../src/service.ts'; + +const fake = Bun.serve({ port: 0, fetch: fakeAuth }); + +const app = await bootstrapService(storefront, { + service: { port: 4310 }, + inputs: { auth: { url: fake.url.href } }, +}); + +const res = await app.fetch(new Request(app.url)); +expect(await res.text()).toContain('Signed in: true'); +``` + +- The service's own code (`server.ts`) is not modified — it boots exactly as in + production; you only choose its configuration. +- **Pass a concrete `service.port`** — the service listens on it; there's no + OS-assigned port reported back. +- **No `close()`** — run each integration-test file in its own process (bun test + does), so the started server is cleaned up when the file ends. +- **Next.js services take a third argument** — a boot thunk, because the built + entry lives in Next's standalone output directory: + + ```ts + import { standaloneEntryPath } from '@prisma/app-nextjs/control'; + await bootstrapService(storefront, config, async () => { + await import(standaloneEntryPath(storefront.build)); + }); + ``` + +## The fake you pass + +A dependency's type *is* its contract, so any value of that shape is a valid +double, checked by the compiler. Three levels of realism: + +- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest. +- **The real client over an in-memory handler** — runs JSON encoding + schema + validation, no socket. +- **A real local server** — the fake served on a loopback port over real HTTP + (what `bootstrapService` drives). + +Ship a dependency's fake from its own package (a `/fake` entry point, outside +`src/` so it never reaches production) so the fake and the real service always +share one contract.