# Testing

> Test RLS, APIs and SQL against the local stack as real users.

Source: https://bettersupabase.com/docs/testing

`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](/docs/platform/lite#testing)
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 [#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:

```toml title="supabase/config.toml"
[auth]
signing_keys_path = "./signing_keys.json"
```

```bash
pnpm better-supabase keys
supabase stop && supabase start
```

## asUser [#asuser]

```ts title="customers.test.ts"
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`](/docs/cli/local#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 [#fixtures]

Share rows between `supabase db reset` and your tests with a
[typed seed](/docs/cli/local#seed):

```ts
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:

```ts
import {
  createTestSigner,
  supabaseClaimFixtures,
} from "better-supabase/testing";

const signer = await createTestSigner();
const token = await signer.sign(supabaseClaimFixtures.supportSession.claims);
```

[Impersonation](/docs/auth/impersonation#reading-the-act-claim) lists what
each `act.kind` means.

## APIs [#apis]

ES256 test tokens verify against the local stack's JWKS, so your
[framework adapter](/docs/frameworks) needs no test-only resolver and sees
the same claims that RLS sees:

```ts
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 [#authorizers]

`staticAuthorizer` is an in-memory [authorizer](/docs/extending/authorizers)
for tests and examples. Permissions come from the subject's role and id,
never from the request's resource or context:

```ts
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](/docs/extending/conformance)).

## Tenant isolation [#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:

```ts title="isolation.test.ts"
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 by `tenant`. `n` is 0 for the seeded
  row and 1 for the insert attempt, so put it in unique columns. `update`
  must change the row.
* It runs without `betterSupabase`'s plugins, so the [`tenant()` plugin](/docs/plugins)
  can't hide a missing policy.
* On a leak it throws a `ConformanceError` naming every table and command
  that leaked, such as `tags: globex can't delete leaky's rows`. It removes
  every row it created either way.
* It needs the service-role key: `stack.secretKey` or `$SUPABASE_SECRET_KEY`.

[`doctor`](/docs/cli/doctor#bs107) reports tenant tables whose policies skip
a command before a test has to.

## Database budget [#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()`](/docs/frameworks/next-cache-components#budget):

```ts
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 [#instant-navigations]

`expectInstant(page, { during, visible, absent, maxCalls, maxWaves })` runs
`during` inside `@next/playwright`'s
[`instant()`](/docs/frameworks/next-cache-components#testing-instant-navigations),
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.

```ts title="e2e/customers.spec.ts"
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/playwright` as a dev dependency. It is an optional peer that
  `expectInstant` loads when it runs, so apps that never call it don't need
  it.
* The app needs a `next build` with
  `experimental.exposeTestingApiInProductionBuild`; without it the lock is
  ignored and the check passes without testing anything. The budget options
  also need `debug.enabled` and `bs.debugRoute()`, as above.
* A missing `visible` locator fails after `timeoutMs` (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 in `absent`
  (`absent: [page.getByTestId("unread-summary")]`); assert it with `expect`
  after `expectInstant` returns.
* Pass `baseURL` when `during` loads the first page, so the lock can be set
  before the page has a URL.
* With `maxCalls` or `maxWaves`, 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; `visible` already proves it is instant.

## pgTAP [#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`:

```sql title="supabase/tests/customers.test.sql"
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.