Testing
Test RLS, APIs and SQL against the local stack as real users.
better-supabase/testing signs tokens with the local stack's ES256 signing
key, the same kind of key a hosted project uses. The same user then works
through PostgREST, direct Postgres and your API, so tests exercise the real
RLS policies instead of mocks.
Without Docker, liteStack() runs Supabase Lite
inside the test process on in-memory SQLite or PGlite, with the same
asUser and RLS, as long as the schema stays within what Lite supports.
Signing key
Create the key once with better-supabase keys. It writes
supabase/signing_keys.json and warns until the file is in .gitignore.
Point supabase/config.toml at it and restart the stack, so Auth signs with
it and the JWKS publishes it:
[auth]
signing_keys_path = "./signing_keys.json"pnpm better-supabase keys
supabase stop && supabase startasUser
import { asUser } from "better-supabase/testing";
const alice = await asUser(
betterSupabase,
{ sub: aliceId, tenant_id: acme },
{ postgres },
);
expect(await alice.db.customers.count().orThrow()).toBe(3);
expect(await alice.sql!.customers.count().orThrow()).toBe(3);
await alice.supabase.storage.from("customer-logos").list(acme);asUser reads the URL and publishable key from the environment, with or
without a framework prefix, just as better-supabase env
writes them. It signs with the first key in the file signing_keys_path
points to (or $SUPABASE_SIGNING_KEYS_PATH); pass signingKey to use
another. db goes through PostgREST, and sql goes through direct Postgres
with the same claims, when you pass postgres.
A stack without a signing key still verifies tokens signed with the shared
JWT secret. Pass { alg: 'HS256' } to sign that way; it reads
SUPABASE_JWT_SECRET and refuses any URL that is not local.
signLocalJwt(claims) signs a token the same way without building
repositories, for requests you send yourself.
Fixtures
Share rows between supabase db reset and your tests with a
typed seed:
import { seed } from "../supabase/seed.ts";
beforeAll(() => seed.insert(postgres.admin));
const alice = await asUser(betterSupabase, {
sub: aliceId,
tenant_id: seed.rows.organizations.acme.id,
});supabaseClaimFixtures holds access-token claims where someone acts for the
user, with the actor, impersonator and delegation each one reads as:
supportSession, supportSessionReadOnly, impersonation, oauthClient
and agentChain. They follow the claim contract every hook writes, so a test can sign
one and check a guard or a support banner:
import {
createTestSigner,
supabaseClaimFixtures,
} from "better-supabase/testing";
const signer = await createTestSigner();
const token = await signer.sign(supabaseClaimFixtures.supportSession.claims);Impersonation lists what
each act.kind means.
APIs
ES256 test tokens verify against the local stack's JWKS, so your framework adapter needs no test-only resolver and sees the same claims that RLS sees:
const bs = createHono(betterSupabase);
await app.request("/api/customers", {
headers: { authorization: `Bearer ${alice.token}` },
});For HS256 tokens, add localAuth(secret) to auth.resolvers. Use it in
tests only.
Authorizers
staticAuthorizer is an in-memory authorizer
for tests and examples. Permissions come from the subject's role and id,
never from the request's resource or context:
import { staticAuthorizer } from "better-supabase/testing";
const authorizer = staticAuthorizer({
roles: {
authenticated: ["customers.read"],
service_role: ["customers.delete"],
},
grants: { [alice.id]: ["customers.delete"] },
approvals: ["invoices.void"],
});
const bs = createHono(betterSupabase, { authorizer });| Option | What it grants |
|---|---|
roles | Permissions per role: the subject's role property (the Postgres role, such as authenticated), or its type (anon) |
grants | Permissions per subject id, on top of the role's |
approvals | Permissions that answer approval-required, with the approval id approval:<permission> |
Anything else is denied. To check an authorizer of your own, run
testAuthorizer (see Conformance).
Tenant isolation
expectTenantIsolation(betterSupabase, { tenants, tables }) checks that RLS keeps two
tenants apart. For each table it seeds one row per tenant through the service
role, then signs in as a user of each tenant and tries to select, insert,
update and delete the other tenant's row:
import { expectTenantIsolation } from "better-supabase/testing";
it("keeps organizations apart", async () => {
await expectTenantIsolation(betterSupabase, {
tenants: [
{ id: ACME, name: "acme", claims: { sub: alice, tenant_id: ACME } },
{ id: GLOBEX, name: "globex", claims: { sub: bob, tenant_id: GLOBEX } },
],
tables: {
tags: {
row: (tenant, n) => ({ organizationId: tenant.id, name: `iso-${n}` }),
update: { color: "red" },
},
},
seed: () => seed.insert(postgres.admin),
});
});- A leak is a row that comes back from a select, or an insert, update or delete that changed the other tenant's data. The helper checks the row through the service role, so a blocked update that returns 0 rows counts as blocked.
- Each user must also read its own row. Otherwise the test passes because the user can see nothing, and the helper fails with a hint about claims and memberships.
row(tenant, n)builds a row owned bytenant.nis 0 for the seeded row and 1 for the insert attempt, so put it in unique columns.updatemust change the row.- It runs without
betterSupabase's plugins, so thetenant()plugin can't hide a missing policy. - On a leak it throws a
ConformanceErrornaming every table and command that leaked, such astags: globex can't delete leaky's rows. It removes every row it created either way. - It needs the service-role key:
stack.secretKeyor$SUPABASE_SECRET_KEY.
doctor reports tenant tables whose policies skip
a command before a test has to.
Database budget
expectDbBudget(page, { maxCalls, maxWaves, during }) fails a Playwright
test when one render makes more database calls or sequential waves than
allowed. It reads the request id from every document and _rsc response
during during (a reload by default) and fetches each render's totals from
bs.debugRoute():
import { expectDbBudget } from "better-supabase/testing";
const renders = await expectDbBudget(page, {
maxCalls: 8,
maxWaves: 2,
during: () => page.goto("/customers"),
});The failure lists each render over budget with its tables, so the diff that
added a query shows up in the message. The page type is structural, so
Playwright stays your dependency, not better-supabase's. The app needs
createNext(betterSupabase, { debug: { enabled: true } }) for the test build.
A response the browser aborts mid-stream (the router does this for some
navigations) counts as finished, so the check doesn't wait out timeoutMs
for it. After during resolves, the check keeps listening until no new
response has arrived for settleMs (500 ms by default), so a render that
starts late, such as the dynamic render after an instant() lock releases,
is still counted.
Instant navigations
expectInstant(page, { during, visible, absent, maxCalls, maxWaves }) runs
during inside @next/playwright's
instant(),
then checks what the prefetched UI shows while the lock still holds: every
visible locator must be visible, and every absent locator must match
nothing. With maxCalls or maxWaves it also checks the navigation's
database budget, as expectDbBudget does, and returns the measured renders.
import { expect, test } from "@playwright/test";
import { expectInstant } from "better-supabase/testing";
test("customers come from the per-session App Shell", async ({ page }) => {
await page.goto("/");
const link = page.getByRole("link", { name: "Customers", exact: true });
await expect(link).toBeVisible();
await expectInstant(page, {
during: async () => {
await link.click();
await page.waitForURL((url) => url.pathname === "/customers");
},
visible: [
page.getByRole("heading", { name: "Customers" }),
page.getByText("Road Runner Inc"),
],
maxCalls: 8,
maxWaves: 2,
});
});- Install
@next/playwrightas a dev dependency. It is an optional peer thatexpectInstantloads when it runs, so apps that never call it don't need it. - The app needs a
next buildwithexperimental.exposeTestingApiInProductionBuild; without it the lock is ignored and the check passes without testing anything. The budget options also needdebug.enabledandbs.debugRoute(), as above. - A missing
visiblelocator fails aftertimeoutMs(5 seconds by default) with the locator in the message. Content that should stream in after the navigation, such as a count read per request, goes inabsent(absent: [page.getByTestId("unread-summary")]); assert it withexpectafterexpectInstantreturns. - Pass
baseURLwhenduringloads the first page, so the lock can be set before the page has a URL. - With
maxCallsormaxWaves, a navigation that never reaches the proxy fails: the client cache served it, so there is no render to measure. Drop the budget for that navigation;visiblealready proves it is instant.
pgTAP
better-supabase sql add pgtap writes
supabase/tests/000_better_supabase_pgtap.test.sql. It runs first and
installs helpers that later test files can use with supabase test db:
begin;
select plan(2);
select tests.rls_enabled('public');
select tests.authenticate_as(tests.create_user('alice@example.com'), '{"tenant_id": "00000000-0000-4000-8000-000000000001"}');
select is((select count(*) from public.customers where organization_id <> '00000000-0000-4000-8000-000000000001'), 0::bigint, 'no cross-tenant rows');
select * from finish();
rollback;| Helper | Does |
|---|---|
tests.create_user(email, app_metadata, user_metadata) | Inserts a confirmed user and returns its id; call it before switching roles |
tests.authenticate_as(user_id, claims) | Switches to authenticated with the user's claims for the rest of the transaction |
tests.authenticate_as_anon() | Switches to anon |
tests.clear_authentication() | Switches back to the test runner's role |
tests.rls_enabled(schema) | Fails and lists every table in the schema without row level security |
The helpers stay installed after the run, so supabase db advisors sees
them too. Each one sets its own search_path, so the advisors report no
function_search_path_mutable warning for them; run sql sync after
upgrading to rewrite a module file from an earlier version.
Last updated on