Skip to main content

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

LayerPitfallE2E fix
AuthShared test@ user across workersCreate user per runId via Admin API in seed
RLSInsert "works" in SQL editor, fails in appSeed with user's JWT or service role + probe as that user
RealtimeUI updates before/after DB commitPoll probe, don't assert first paint
StoragePublic bucket assumptionsSeed object + probe metadata row

UI-only asserts miss RLS mistakes—a toast can show success while INSERT was silently denied.

Complexity map

ScenarioEdge caseWhy tests breakApproach
Parallel CISame auth emailSession collisionuser+${runId}@test.local
RLS denyWrong org_idEmpty UI, test passesProbe row count for runId
Magic linkEmail not in CIStuck on check inboxTest-only password login seed
Realtime lagChannel fires lateFlaky text assertPoll probe then optional UI
Service role leakKey in browser bundleSecurity incidentSeeds only on server routes
Lovable regenSelector driftLocator timeouttest ids on forms; ai.act on marketing only
OAuth providerGoogle popup in CIBlockedSeed session via Admin API
FK cascadeDelete user orphansProbe wrong countSeed 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

  1. Positive path — seed as user A, probe as user A (via user-scoped probe or signed JWT)
  2. Negative path — attempt cross-tenant read in UI; probe confirms zero rows for other org_id
  3. Policy regression — API Arrange POST with wrong org_id expects 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-patternWhy it failsBetter approach
Service role in playwright.configKey leakServer seed routes only
Shared staging userRLS cross-talkPer-run Auth user
Assert channel message textOrdering flakeProbe table row
Disable RLS in testFalse confidenceTest real policies
SQL seed in beforeAllNot black-box E2EHTTP 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.

  1. Arrange: Seed org A user and org B invoice with runId-scoped ids via service-role seed.
  2. Act: Log in as org A; navigate to org B invoice URL.
  3. 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.

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.

Start free on TestChimp · Book a demo