Testing
Your app's code gets every dependency from one call — service.load() — and
nothing from arguments or globals. That makes testing a matter of deciding
what load() returns; the code under test is never modified. There are two
tools, and you pick by how much of the real path you want to exercise:
| You want to… | Use | From |
|---|---|---|
| Call a page / action / handler directly, dependencies faked | mockService |
@prisma/composer/testing |
| Boot the real built entry and drive it over real HTTP | bootstrapService |
@prisma/composer-prisma-cloud/testing |
Most tests are the first kind. Reach for the second when the thing you're proving is the wiring itself — that the service boots, reads its config, builds its clients, and answers requests.
Unit tests — mockService
mockService(service, overrides) returns a copy of the service whose
load() yields your fakes and whose config() yields param defaults overlaid
with any overrides (one flat object — dependency names route to load(),
param names to config()). The fakes are type-checked against the service's
declared dependencies, so a fake with the wrong shape doesn't compile.
Substituting the mocked service for the real one is your test runner's job —
vi.mock in Vitest, mock.module in bun test. A Vitest example, testing a
Next.js page:
// page.test.tsx
import { renderToString } from 'react-dom/server';
import { mockService } from '@prisma/composer/testing';
import realService from '../src/service.ts';
vi.mock('../src/service.ts', () => ({
default: mockService(realService, {
auth: { verify: async () => ({ ok: true }) },
}),
}));
import Page from './page.tsx';
it('renders the verified state', async () => {
expect(renderToString(await Page())).toContain('Signed in: true');
});
No server, no database, no environment — the page just renders against the fake.
Integration tests — bootstrapService
bootstrapService boots the service's real built entry in-process, fed
the same way a deployed boot is fed — you just choose the values. Point a
dependency at a stand-in you run on a loopback port, then make real HTTP
requests. Run these under bun test:
// service.integration.test.ts
import { bootstrapService } from '@prisma/composer-prisma-cloud/testing';
import fakeAuth from '@my-app/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');
Four things to know:
- Build first. It boots the built entry, so the test task must depend
on the build (in the examples, turbo's
testtask depends onbuild). - Pass a concrete
service.port. The entry listens itself; there's no OS-assigned port reported back. - There is no
close(). Run each integration-test file in its own process — bun test does this per file — and the server dies with it. - The service's code is untouched. If you find yourself editing
server.tsto make it testable, something upstream is wrong.
Next.js services need a third argument — a boot function — because the
built entry lives inside Next's standalone output. Resolve it with
standaloneServerPath, and hand Next the port explicitly (its standalone
server reads process.env.PORT, not the service's config):
import { pathToFileURL } from 'node:url';
import { standaloneServerPath } from '@prisma/composer/nextjs/control';
await bootstrapService(storefront, config, async () => {
process.env.PORT = String(PORT);
await import(pathToFileURL(standaloneServerPath(storefront.build)).href);
});
The complete working version is
examples/storefront-auth/modules/storefront/app/page.integration.test.ts.
Writing good fakes
A dependency's type is its contract, so anything of that shape is a valid fake, and the compiler holds it to that. In increasing order of realism:
- A bare object —
{ verify: async () => ({ ok: true }) }. Right for most unit tests. - The real client over an in-memory handler — exercises JSON encoding and schema validation, still no socket.
- A real local server — the fake served over actual HTTP, which is what
bootstrapServicedrives.
One habit pays for all of this: ship each service's fake from its own package
as a /fake entry point (outside src/, so it can't reach production).
Fake and service then share one contract — when the contract changes, both
stop compiling at once, and every consumer's tests find out immediately.
The reasoning behind the two-seam design is in
docs/design/10-domains/testing.md.