How to Test Supabase Apps with Playwright
Short answer
Supabase E2E fails when tests share one Auth user or assert UI toasts while RLS blocks the row you think you created. Use service-role seed routes (server-only), per-run users with runId, and probe Assert on tables Realtime may lag behind—never anon keys in Playwright for privileged setup.
Part of Testing Guides by integrations.
Who this is for
Teams shipping Lovable, Next.js + Supabase, or Vite apps with Supabase Auth, Postgres RLS, and optional Realtime. Common pain: agent-generated UI refactors break locators while tenant isolation bugs slip through green CI.
Why Supabase needs different Arrange/Assert
| Layer | Pitfall | E2E fix |
|---|---|---|
| Auth | Shared test@ user across workers | Create user per runId via Admin API in seed |
| RLS | Insert "works" in SQL editor, fails in app | Seed with user's JWT or service role + probe as that user |
| Realtime | UI updates before/after DB commit | Poll probe, don't assert first paint |
| Storage | Public bucket assumptions | Seed object + probe metadata row |
UI-only asserts miss RLS mistakes—a toast can show success while INSERT was silently denied.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Parallel CI | Same auth email | Session collision | user+${runId}@test.local |
| RLS deny | Wrong org_id | Empty UI, test passes | Probe row count for runId |
| Magic link | Email not in CI | Stuck on check inbox | Test-only password login seed |
| Realtime lag | Channel fires late | Flaky text assert | Poll probe then optional UI |
| Service role leak | Key in browser bundle | Security incident | Seeds only on server routes |
| Lovable regen | Selector drift | Locator timeout | test ids on forms; ai.act on marketing only |
| OAuth provider | Google popup in CI | Blocked | Seed session via Admin API |
| FK cascade | Delete user orphans | Probe wrong count | Seed full graph per scenario |
Seed pattern (Next.js + service role)
// app/api/test/seed-user/route.ts
import { createClient } from '@supabase/supabase-js';
import { NextResponse } from 'next/server';
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } },
);
export async function POST(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { runId, orgId } = await req.json();
const email = `e2e+${runId}@test.local`;
const { data: user, error } = await admin.auth.admin.createUser({
email,
password: `pw-${runId}`,
email_confirm: true,
user_metadata: { runId, orgId },
});
if (error) return NextResponse.json({ error }, { status: 500 });
await admin.from('profiles').upsert({ id: user.user!.id, org_id: orgId, run_id: runId });
return NextResponse.json({ email, password: `pw-${runId}`, userId: user.user!.id });
}
// app/api/test/probe-rows/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 table = new URL(req.url).searchParams.get('table') ?? 'orders';
const { count, error } = await admin
.from(table)
.select('*', { count: 'exact', head: true })
.eq('run_id', runId);
if (error) return NextResponse.json({ error }, { status: 500 });
return NextResponse.json({ count, runId, table });
}
Never expose SUPABASE_SERVICE_ROLE_KEY to Playwright browser context—only server-side seed/probe routes.
Playwright spec with login
// @Scenario: billing/trial-expired
import { test, expect } from '../fixtures/run';
test('expired trial blocks premium action', async ({ page, request, runId }) => {
const seed = await request.post('/api/test/seed-user', {
data: { runId, orgId: `org-${runId}`, plan: 'trial_expired' },
});
const { email, password } = await seed.json();
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('button', { name: 'Export report' }).click();
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-rows?runId=${runId}&table=exports`);
return (await res.json()).count;
}).toBe(0);
});
RLS testing strategy
- Positive path — seed as user A, probe as user A (via user-scoped probe or signed JWT)
- Negative path — attempt cross-tenant read in UI; probe confirms zero rows for other
org_id - Policy regression — API Arrange POST with wrong
org_idexpects 403
For deep RLS matrices, keep SQL policy tests in migration CI; E2E covers user-visible enforcement on critical paths.
Lovable / agent-built apps
Lovable stacks regenerate UI often—use stable test ids on checkout/auth; ai.act sparingly on marketing sections. Pair with agent-built QA workflow and /testchimp init for seed scaffolding.
Realtime
await page.goto('/dashboard');
// Don't assert first subscription payload—poll probe
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-rows?runId=${runId}&table=notifications`);
return (await res.json()).count;
}, { timeout: 20_000 }).toBeGreaterThan(0);
See websockets and live updates for similar wait patterns.
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
Service role in playwright.config | Key leak | Server seed routes only |
| Shared staging user | RLS cross-talk | Per-run Auth user |
| Assert channel message text | Ordering flake | Probe table row |
| Disable RLS in test | False confidence | Test real policies |
SQL seed in beforeAll | Not black-box E2E | HTTP seeds |
External references
Example scenario
Situation: User in org A tries to view org B invoice via guessed URL.
Expected outcome: 403 or empty state; no row leak.
Why UI-only automation breaks: Generic 'not found' UI while network returns 200 with JSON body.
- Arrange: Seed org A user and org B invoice with runId-scoped ids via service-role seed.
- Act: Log in as org A; navigate to org B invoice URL.
- Assert: Probe confirms zero invoices visible for org A user; optional UI empty state.
TestChimp workflow: // @Scenario: links RLS spec to markdown; /testchimp test preserves probe Assert when Lovable regen changes CSS.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Related
Frequently asked questions
Can Playwright use Supabase anon key for setup?
Anon key is fine for client-realistic login flows. Privileged setup (bypass RLS, create users) belongs in server-side seed routes with service role.
How do we test magic links in CI?
Prefer password users created via Admin API in test env. If magic links are required, use test inbox API or expose test-only token exchange route gated by E2E_TEST_MODE.
Local Supabase CLI vs hosted project?
CLI gives fast reset for dev; CI often uses dedicated hosted test project with E2E_TEST_MODE. Keep runId scoping either way.
Does RLS make probes impossible?
Use service-role probes on server routes returning aggregates, or user-scoped probes that forward the test user JWT—never disable RLS in production-like envs.
Lovable changed our UI—tests broke?
Add data-testid on auth/checkout; use ai.act only on volatile sections. /testchimp test repairs specs with scenario context.
How to test Realtime without flake?
Poll probe for row existence/count; treat Realtime as optimization, not source of truth in Assert.
Is storing run_id on every table required?
Strongly recommended for parallel CI—filters probes and teardown. Use org_id + run_id composite for multi-tenant apps.
TestChimp with Supabase stacks?
/testchimp init scaffolds seed/probe routes compatible with Supabase Admin API patterns and links SmartTests to markdown scenarios for Lovable velocity teams.
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.