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 —
// @Scenario: checkout/expired-couponfor 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: 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: 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: 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.