Testing Supabase Apps
The sqlproof.contrib.supabase module bundles helpers for the parts of a
Supabase test setup that don’t generalize to plain Postgres: auth-user
seeding and JWT-claim impersonation. These live in contrib/ (not core)
because the JWT-claim shape and the auth.users table are
Supabase/PostgREST conventions, not Postgres features.
Seeding test users
You have two paths depending on whether your test environment can reach Supabase’s admin API:
Direct SQL insert (preferred locally)
When you’re running against a local Supabase or any DB connection that has
write access to auth.users:
from sqlproof.contrib.supabase import seed_test_users_directly
user_ids = seed_test_users_directly(db, count=5)# Inserts users with emails [email protected] ... [email protected]# Returns a list of user_ids matching the email pattern.# Idempotent: re-running won't duplicate.Admin API (preferred in CI when service-role key is available)
When you have SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY set:
from sqlproof.contrib.supabase import seed_supabase_test_users
seed_supabase_test_users(db=object(), count=5) # `db` arg unused for admin APIBoth helpers use the same email pattern, so a test that samples from
auth.users WHERE email LIKE 'sqlproof_%@test.invalid' works regardless of
which path was taken.
Wiring into ExternalTableSpec
Once users exist, register auth.users as an external parent for FK
generation:
from sqlproof import ExternalTableSpec, SqlProoffrom hypothesis import strategies as st
def sample_test_user_ids(db) -> list[str]: rows = db.query( "SELECT id FROM auth.users WHERE email LIKE 'sqlproof_%%@test.invalid'" ) return [row["id"] for row in rows]
proof = SqlProof.from_connection_string( "postgresql://...", external_tables={ "auth.users": ExternalTableSpec( primary_key="id", seed_count=st.integers(min_value=1, max_value=5), sample=sample_test_user_ids, ) },)Now any FK column referencing auth.users(id) in your generated dataset
draws from the seeded test users.
Acting as a user (RLS testing)
as_supabase_user is a context manager that sets request.jwt.claims for
the current transaction so PostgREST/Supabase auth helpers (auth.uid(),
auth.jwt(), auth.role()) resolve to the given user:
from sqlproof.contrib.supabase import as_supabase_user
with as_supabase_user(db, user_id): # Inside the block, RLS policies that check auth.uid() see `user_id`. rows = db.query("SELECT * FROM projects") # filtered by RLSImportant properties:
- Restores prior claim on exit. Nested
as_supabase_usercalls stack and unwind correctly. - Safe under exceptions. Implemented with
try/finally. - Composable with
db.savepoint()— wrap whichever you want to take precedence first. - Plain Postgres, no Supabase RPC. The helper only sets a GUC; it doesn’t talk to the auth API.
Custom claims
Pass extra_claims to merge additional JWT fields:
with as_supabase_user( db, user_id, role="service_role", # or pass via extra_claims; explicit arg wins extra_claims={"app_metadata": {"plan": "pro"}},): ...Order: {"sub": user_id, "role": role, **extra_claims}. Pass role in
extra_claims to override the default "authenticated".
Stateful + RLS
The combo earns its keep on RLS regression tests, where bugs surface
across membership churn rather than a single permission check. See the
stateful testing guide for an end-to-end example
covering get_member_project_ids / get_editor_project_ids against
project_members mutations.
Caveats
seed_test_users_directlyrequires the DB connection to have INSERT privilege onauth.users. Local Supabase grants this by default; managed Supabase typically does not — use the admin-API path there.- Setting
request.jwt.claimsonly changes whatauth.uid()returns for the transaction; it does not bypass RLS or change the connection’s Postgres role. Assume your tests run as the connection’s role (typicallypostgresorservice_role) for non-RLS queries. - The
auth.usersschema can drift across Supabase versions. The helpers insert minimal columns (id,aud,role,email); if your test data needs richer auth metadata, insert directly with raw SQL.