How to Build Seed Routes and Probe Assert for E2E
Short answer
Reliable E2E separates Arrange (per-run seed via test-only APIs), Act (short UI path), and Assert (probe endpoints for authoritative state). Every spec gets a unique runId; probes answer business questions toasts cannot—this is the expired-coupon pattern generalized.
Part of E2E testing foundations.
Who this is for
Teams on Playwright (or SmartTests) who need parallel CI without shared staging coupons, users, or carts. Typical stacks: Next.js App Router, Remix, Rails, Laravel—any app with a database behind the UI.
Why seed routes and probes matter
Without them:
- Parallel workers collide on the same promo code or admin user (world-state gotcha)
- UI-only asserts lie—success toasts before webhooks finish (UI-only gotcha)
- Agents regenerate orphan scripts with no link to business rules
Probes turn "did checkout work?" into a queryable fact independent of CSS and copy.
Architecture
┌─────────────┐ POST /api/test/seed-* ┌──────────────┐
│ Playwright │ ─────────────────────────────► │ App + DB │
│ (Arrange) │ { runId, ...payload } │ (test env) │
└─────────────┘ └──────────────┘
│ ▲
│ UI Act │
▼ │
┌─────────────┐ GET /api/test/probe-* ┌──────┴───────┐
│ Playwright │ ◄───────────────────────────── │ Authoritative│
│ (Assert) │ { status, counts, ... } │ state │
└─────────────┘ └──────────────┘
Rules:
- Gate routes to non-prod —
NODE_ENV !== 'production'or explicitE2E_TEST_MODE=true - Scope every row by
runId— never reuse globaltest@example.comwithout a suffix - Probes return stable fields —
orderStatus,discountCents, not raw ORM dumps - Link specs to plans —
{ type: 'scenario', description: '#TS-<n>' }for traceability
Playwright fixture pattern
// tests/fixtures/run.ts
import { test as base } from '@playwright/test';
import { randomUUID } from 'crypto';
type RunFixtures = { runId: string; seed: (path: string, data?: object) => Promise<void> };
export const test = base.extend<RunFixtures>({
runId: async ({}, use) => {
await use(randomUUID());
},
seed: async ({ request, runId }, use) => {
await use(async (path, data = {}) => {
const res = await request.post(`/api/test/seed-${path}`, {
data: { runId, ...data },
});
if (!res.ok()) throw new Error(`Seed failed: ${path} ${await res.text()}`);
});
},
});
export { expect } from '@playwright/test';
// tests/checkout/expired-coupon.spec.ts
scenario annotations checkout/expired-coupon
import { test, expect } from '../fixtures/run';
test('expired coupon does not create order', async ({ page, request, runId, seed }) => {
await seed('cart', { couponCode: 'EXPIRED10', cartId: `cart-${runId}` });
await page.goto(`/checkout?cartId=cart-${runId}`);
await page.getByRole('button', { name: 'Pay' }).click();
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-order?runId=${runId}`);
return (await res.json()).paidOrderCount;
}).toBe(0);
});
Example seed route (Next.js)
// app/api/test/seed-cart/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function POST(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { runId, couponCode, cartId } = await req.json();
await db.cart.upsert({
where: { id: cartId },
create: { id: cartId, runId, couponCode, items: [] },
update: { couponCode },
});
return NextResponse.json({ ok: true });
}
// app/api/test/probe-order/route.ts
export async function GET(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const runId = new URL(req.url).searchParams.get('runId');
const paidOrderCount = await db.order.count({
where: { runId, status: 'paid' },
});
return NextResponse.json({ paidOrderCount, runId });
}
Adapt naming to your ORM—keep runId on every mutable entity created during the test.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Shared coupon | One code, N workers | "Already used" flake | Seed unique coupon per runId |
| Webhook lag | UI success first | Toast passes, order pending | Poll probe until status=paid |
| Teardown missing | Prior run data | Wrong totals | Prefer fresh seed over delete |
| Probe too chatty | Full table scan | Slow CI | Return aggregates (count, status) |
| RLS / multi-tenant | Wrong org row | False green | Seed tenant + user in Arrange |
| Supabase Auth | Session vs DB | UI logged in, row missing | Service-role seed + probe (Supabase guide) |
| Idempotent seed | Re-run same spec | Duplicate rows | Upsert on runId + entity id |
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
beforeAll shared user | Parallel overwrite | Per-test runId seed |
Assert page.url() only | Handler never ran | Probe business state |
| Production DB seeds | Data leaks | Test env + gated routes |
| Giant probe JSON | Brittle asserts | Stable summary fields |
TestChimp workflow
/testchimp init scaffolds tests/fixtures/, seed/probe route stubs, and links SmartTests to markdown scenarios. On each PR, /testchimp test adds probes when scenario markdown states invariants agents can enforce—coverage stays tied to requirements, not chat history.
External references
Example scenario
Situation: Shopper applies an expired coupon at checkout.
Expected outcome: No paid order; cart shows validation error.
Why UI-only automation breaks: Toast says invalid coupon but a paid order row exists with discount applied.
- Arrange: POST /api/test/seed-cart with runId and EXPIRED10 coupon.
- Act: Submit checkout in UI.
- Assert: GET /api/test/probe-order?runId=… returns paidOrderCount=0.
TestChimp workflow: scenario annotation links spec to markdown plan; /testchimp test keeps probe Assert when UI copy changes.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Related
Frequently asked questions
Are seed routes safe in production?
Never expose unauthenticated seed routes in prod. Gate with E2E_TEST_MODE, separate test env, or IP allowlists. Probes should return non-sensitive summary fields only.
How is this different from Playwright storageState?
storageState saves cookies/localStorage for Act. Seed routes create authoritative DB/API world-state for Arrange; probes Assert outcomes storageState cannot see.
Do I need a service role for Supabase seeds?
Usually yes for bypassing RLS in test env—use service role server-side only in seed routes, never in browser. See the Supabase integration guide.
Can I use factories instead of HTTP seeds?
In-process factories work for unit/integration tests. HTTP seeds keep E2E black-box and match how agents/MCP tools invoke setup—same routes locally and in CI.
What fields should probes return?
Minimal stable summaries: counts, enum status, key ids. Avoid full nested graphs that break on schema refactors.
How does TestChimp use probes?
/testchimp init scaffolds routes; /testchimp test reads scenario markdown and adds or repairs probe Assert when business rules demand authoritative checks.
Should probes replace all UI assertions?
No—use probes for business truth; optional lightweight UI checks for visible regressions after probes pass.
How do I trace specs to requirements?
Add scenario annotations path in spec header matching markdown test plans. Requirement traceability reports show which probes enforce which scenarios.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.