Skip to main content

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:

  1. Gate routes to non-prod — NODE_ENV !== 'production' or explicit E2E_TEST_MODE=true
  2. Scope every row by runId — never reuse global test@example.com without a suffix
  3. Probes return stable fields — orderStatus, discountCents, not raw ORM dumps
  4. 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​

ScenarioEdge caseWhy tests breakApproach
Shared couponOne code, N workers"Already used" flakeSeed unique coupon per runId
Webhook lagUI success firstToast passes, order pendingPoll probe until status=paid
Teardown missingPrior run dataWrong totalsPrefer fresh seed over delete
Probe too chattyFull table scanSlow CIReturn aggregates (count, status)
RLS / multi-tenantWrong org rowFalse greenSeed tenant + user in Arrange
Supabase AuthSession vs DBUI logged in, row missingService-role seed + probe (Supabase guide)
Idempotent seedRe-run same specDuplicate rowsUpsert on runId + entity id

Anti-patterns​

Anti-patternWhy it failsBetter approach
beforeAll shared userParallel overwritePer-test runId seed
Assert page.url() onlyHandler never ranProbe business state
Production DB seedsData leaksTest env + gated routes
Giant probe JSONBrittle assertsStable 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.

  1. Arrange: POST /api/test/seed-cart with runId and EXPIRED10 coupon.
  2. Act: Submit checkout in UI.
  3. 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.

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.

Start free on TestChimp · Book a demo