| Layer | Runs | Good for |
|---|---|---|
| Unit | Jest (server) or Vitest (admin), in process | Pure logic — validators, mappers, the domain/ layer, tool definitions |
| Server end-to-end | The real app, in process, against real Postgres, via supertest | Guards, permissions, transactions, migrations, actual SQL |
| Admin end-to-end | Playwright against the admin dev server, /api/** mocked | Flows, keyboard, accessibility |
npx nx test @acme/reviews-server # unit
npx nx e2e server-e2e # API against a Postgres testcontainer
npx nx e2e admin-e2e # Playwright; starts the dev server itself npm test # server (Jest) + admin (Vitest) unit tests
npm run e2e:server # API against the _e2e database
npm run e2e:admin # Playwright Unit tests
Keep rules in domain/ — no Nest, no Drizzle, no React — and test them
directly. A tool provider is also unit-testable, because its definitions are
plain data:
// review-tool.provider.spec.ts
import { PERMISSIONS } from '@orthacms/identity-server';
import { ReviewToolProvider } from './review-tool.provider';
describe('ReviewToolProvider', () => {
const tools = new ReviewToolProvider({} as never).tools();
it('offers one read tool, on the copilot only', () => {
expect(tools.map((t) => t.name)).toEqual(['review_list']);
expect(tools[0]).toMatchObject({
readOnly: true,
effect: 'read',
requires: [PERMISSIONS.CONTENT_READ],
surfaces: ['copilot']
});
});
it('registers only where a registry exists', () => {
const registered: unknown[] = [];
const provider = new ReviewToolProvider({} as never, {
register: (p: unknown) => registered.push(p)
} as never);
provider.onModuleInit();
expect(registered).toEqual([provider]);
expect(() => new ReviewToolProvider({} as never).onModuleInit()).not.toThrow();
});
});Server end-to-end (monorepo)
The harness in apps/server-e2e/src/support/ boots the real app — same
plugins, prefix, validation pipe and API docs as production — against one
Postgres testcontainer, migrated once by global setup.
Wire your plugin in first:
- Add
ReviewsPlugin()tobuildTestPluginsinsupport/plugins.ts. Global setup migrates from that list. - Add
reviewsto theTRUNCATElist inresetDb(support/seed.ts). It truncates an explicit list, and a plain-uuidtable is reached by no cascade.
// apps/server-e2e/src/server/reviews/reviews.spec.ts
import request from 'supertest';
import { closeTestApp, createTestApp, type TestApp } from '../../support/test-app';
import {
resetDb,
seedActiveUser,
seedAllContentGrants,
seedMembership,
seedWorkspace
} from '../../support/seed';
const PASSWORD = 'SecurePass123!';
let harness: TestApp;
let workspaceId: string;
beforeAll(async () => {
harness = await createTestApp();
});
afterAll(async () => {
await closeTestApp(harness);
});
beforeEach(async () => {
await resetDb();
workspaceId = (await seedWorkspace({ name: 'Reviews', slug: 'reviews' })).id;
await seedAllContentGrants(workspaceId);
});
/** A signed-in agent for a fresh user of `role`, inside the workspace. */
async function signIn(role: 'contributor' | 'viewer') {
const email = `${role}@example.com`;
const user = await seedActiveUser(harness.app, { email, password: PASSWORD, role });
await seedMembership(user.id, workspaceId);
const agent = request.agent(harness.server);
await agent.post('/api/auth/login').send({ email, password: PASSWORD }).expect(201);
agent.set('X-Workspace-Id', workspaceId);
return agent;
}
describe('POST /api/reviews', () => {
const entryId = '00000000-0000-4000-8000-000000000001';
it('creates a review for a contributor', async () => {
const agent = await signIn('contributor');
const res = await agent
.post('/api/reviews')
.send({ entryId, body: 'Tighten the intro.' })
.expect(201);
expect(res.body).toMatchObject({ workspaceId, entryId, resolvedAt: null });
});
it('refuses a viewer, who lacks content:update', async () => {
const agent = await signIn('viewer');
await agent.post('/api/reviews').send({ entryId, body: 'x' }).expect(403);
});
it('needs the workspace header', async () => {
const agent = await signIn('contributor');
agent.set('X-Workspace-Id', '');
await agent.post('/api/reviews').send({ entryId, body: 'x' }).expect(400);
});
});What the container buys you:
- Guards run. A route that forgot
@RequirePermissionsfails here and nowhere else. - Migrations run. A schema change that does not apply fails the suite.
- Constraints are real. A count-then-write race or a missing unique index shows up.
Useful helpers:
| Helper | From | Does |
|---|---|---|
createTestApp(overrides?) / closeTestApp(h) | support/test-app | Boot / stop. overrides changes config: rateLimit, allowedOrigins, mail, webhooks, session… |
resetDb() | support/seed | Truncate the mutable tables (incl. outbox_events); keeps system roles |
seedActiveUser(app, { email, password, role }) | support/seed | A user whose password is hashed by the app’s own HashingService |
seedUserWithPermissions, seedUserWithEmptyRole | support/seed | Prove a route needs a specific key |
seedWorkspace({ name, slug, isShared? }), seedMembership(userId, wsId) | support/seed | Workspace and membership rows |
seedAllContentGrants(wsId), seedSharedContentGrants(…) | support/seed | Content-type grants, own and shared |
drainOutbox(app), getOutboxRows(aggregateId) | support/outbox | Deliver pending events now; assert what was emitted |
Asserting an event was emitted:
import { getOutboxRows } from '../../support/outbox';
const res = await agent.post('/api/reviews').send({ entryId, body: 'Ok' }).expect(201);
const rows = await getOutboxRows(res.body.id);
expect(rows.map((r) => r.kind)).toEqual(['review.created']);Server end-to-end (generated app)
An app from create-orthacms-app ships a smaller harness in
apps/server-e2e/. It boots buildPlugins(config) — so your plugin is
already in it — against an _e2e copy of your database (or E2E_DATABASE_URL), and signs
in as the root admin from .env.
// apps/server-e2e/src/reviews.spec.ts
import request from 'supertest';
import { ALLOWED_ORIGIN, closeTestApp, createTestApp, type TestApp } from './support/test-app';
let harness: TestApp;
beforeAll(async () => {
harness = await createTestApp();
});
afterAll(async () => {
await closeTestApp(harness);
});
it('refuses an anonymous read', async () => {
await request(harness.server).get('/api/reviews').expect(401);
});
it('asks a signed-in admin which workspace', async () => {
const agent = request.agent(harness.server);
await agent
.post('/api/auth/login')
.set('Origin', ALLOWED_ORIGIN)
.send({
email: process.env['ORTHACMS_ROOT_ADMIN_EMAIL'],
password: process.env['ORTHACMS_ROOT_ADMIN_PASSWORD']
})
.expect(201);
// Authenticated and permitted — WorkspaceGuard still wants X-Workspace-Id
await agent.get('/api/reviews?entryId=00000000-0000-4000-8000-000000000001').expect(400);
});resetDatabase(url) in support/db.ts truncates every table except the
migration history.
Admin end-to-end
Playwright against the admin’s Vite dev server, with /api/** intercepted —
the “seed” is a route mock. No server, no Postgres, no Docker. Register your
admin plugin in apps/admin/src/plugins.ts so the dev server serves it.
// apps/admin-e2e/src/reviews/reviews.spec.ts
import { expect, test } from '../support/fixtures';
import { mockSignedIn } from '../support/api/auth';
import { mockWorkspaces } from '../support/api/workspaces';
import { expectNoA11yViolations } from '../support/a11y';
test.beforeEach(async ({ page }) => {
await mockSignedIn(page); // an admin holding every permission
await mockWorkspaces(page); // seeds 'ws_marketing' and others
await page.route('**/api/reviews*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 'r1', body: 'Tighten the intro.', resolvedAt: null }])
})
);
});
test('the Reviews page lists reviews and passes axe', async ({ page, makeAxe }) => {
await page.goto('/workspaces/ws_marketing/reviews');
await expect(page.getByText('Tighten the intro.')).toBeVisible();
await expectNoA11yViolations(makeAxe());
});
test('a user without content:read gets no nav entry', async ({ page }) => {
await mockSignedIn(page, { permissions: [] });
await page.goto('/workspaces/ws_marketing');
await expect(page.getByRole('link', { name: 'Reviews' })).toHaveCount(0);
});- Import
testfromsupport/fixtures, not@playwright/test— it carries the page objects andmakeAxe. - Axe runs WCAG 2.1 A and AA plus best practices, in both themes.
- Add a keyboard spec for any new page; the existing suites walk every screen without a mouse.
Where the conventions live
- Skills —
.agents/skills/:server-plugin,admin-plugin,server-e2e,admin-e2e,accessibility,shadcn. Read the one matching what you build. - Package dossiers —
docs/artifacts/<package>.md: data model, HTTP API, invariants and a testing checklist per package. A snapshot — check against the code before acting on one.
No load tests, no contract tests
There is no performance suite and nothing pins the public API’s response shapes against a consumer. If you depend on a field, an end-to-end test in your codebase is what protects you.