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-prodNODE_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 fieldsorderStatus, discountCents, not raw ORM dumps
  4. Link specs to plans// @Scenario: checkout/expired-coupon 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: 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: 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: 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