DEV Community

Russel Dsouza
Russel Dsouza

Posted on

The Supabase Edge Functions Testing Playbook: Unit, Auth, Webhooks, and Local Integration Tests

Most Supabase Edge Functions get tested exactly once: by deploying them and hitting them from the app. That works until a function sends a duplicate charge, emails the wrong user, or quietly returns 500 for anyone who isn't signed in.

Edge Functions are where your app's most sensitive logic tends to live: payments, AI calls with secret keys, webhooks, anything that needs the service role. They deserve better tests than "it worked when I clicked it."

This is a practical playbook: how to structure functions so they're testable, and the five kinds of tests that catch the bugs that matter. Everything here works with the standard Supabase CLI. At the end there's a lighter local option if Docker is slowing your loop down.

Step 0: Split the handler from the server

Almost every Edge Function example puts everything inside Deno.serve:

Deno.serve(async (req) => {
  // 80 lines of logic
});
Enter fullscreen mode Exit fullscreen mode

That's hard to test, because the only way to run the logic is to start a server. Split it into two files instead:

// supabase/functions/summarize/handler.ts
import { createClient, type SupabaseClient } from 'jsr:@supabase/supabase-js@2';

export type Deps = {
  // Everything that talks to the outside world is injectable
  makeUserClient: (authHeader: string) => SupabaseClient;
  summarize: (text: string) => Promise<string>;
};

export function makeHandler(deps: Deps) {
  return async (req: Request): Promise<Response> => {
    if (req.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const auth = req.headers.get('Authorization');
    if (!auth) return new Response('Unauthorized', { status: 401 });

    let body: { noteId?: string };
    try {
      body = await req.json();
    } catch {
      return Response.json({ error: 'Invalid JSON' }, { status: 400 });
    }
    if (!body.noteId) {
      return Response.json({ error: 'noteId is required' }, { status: 400 });
    }

    // A client acting as the caller, so RLS decides what they can read
    const supabase = deps.makeUserClient(auth);
    const { data: note, error } = await supabase
      .from('notes')
      .select('body')
      .eq('id', body.noteId)
      .single();

    if (error || !note) {
      return Response.json({ error: 'Not found' }, { status: 404 });
    }

    const summary = await deps.summarize(note.body);
    return Response.json({ summary });
  };
}
Enter fullscreen mode Exit fullscreen mode
// supabase/functions/summarize/index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2';
import { makeHandler } from './handler.ts';

const url = Deno.env.get('SUPABASE_URL')!;
const key = Deno.env.get('SUPABASE_PUBLISHABLE_KEY') ?? Deno.env.get('SUPABASE_ANON_KEY')!;

Deno.serve(
  makeHandler({
    makeUserClient: (authHeader) =>
      createClient(url, key, { global: { headers: { Authorization: authHeader } } }),
    summarize: async (text) => {
      const res = await fetch('https://api.example-ai.com/v1/summarize', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${Deno.env.get('AI_API_KEY')}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ text }),
      });
      if (!res.ok) throw new Error(`AI provider returned ${res.status}`);
      return (await res.json()).summary;
    },
  })
);
Enter fullscreen mode Exit fullscreen mode

Now the logic is a plain function from Request to Response, with its dependencies passed in. You can call it directly in a test, with fakes, in milliseconds.

Two details in the handler are worth copying into every function:

  • The user client forwards the caller's Authorization header. Queries then run as that user, and your RLS policies apply. Reach for the service role only when the function genuinely needs to bypass RLS.
  • Every bad input returns a specific status code. 400 for a bad body, 401 for no auth, 404 when RLS hides the row. Tests can assert on these, and your app can handle them.

Test 1: Unit tests (no server, no database)

Supabase recommends putting function tests in supabase/functions/tests/, named after the function with a -test.ts suffix. Unit tests call the handler directly:

// supabase/functions/tests/summarize-test.ts
import { assertEquals } from 'jsr:@std/assert';
import { makeHandler } from '../summarize/handler.ts';

// A fake Supabase client that returns whatever row you give it
function fakeClient(row: { body: string } | null) {
  return {
    from: () => ({
      select: () => ({
        eq: () => ({
          single: async () => (row ? { data: row, error: null } : { data: null, error: { message: 'not found' } }),
        }),
      }),
    }),
  } as any;
}

const post = (body: unknown, auth = 'Bearer test-token') =>
  new Request('http://localhost/summarize', {
    method: 'POST',
    headers: { Authorization: auth, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });

Deno.test('returns a summary for a readable note', async () => {
  const handler = makeHandler({
    makeUserClient: () => fakeClient({ body: 'long text' }),
    summarize: async () => 'short text',
  });

  const res = await handler(post({ noteId: 'n1' }));
  assertEquals(res.status, 200);
  assertEquals(await res.json(), { summary: 'short text' });
});

Deno.test('rejects requests without auth', async () => {
  const handler = makeHandler({
    makeUserClient: () => fakeClient(null),
    summarize: async () => '',
  });

  const req = new Request('http://localhost/summarize', { method: 'POST', body: '{}' });
  assertEquals((await handler(req)).status, 401);
});

Deno.test('returns 404 when RLS hides the note', async () => {
  const handler = makeHandler({
    makeUserClient: () => fakeClient(null),
    summarize: async () => 'should not be called',
  });

  assertEquals((await handler(post({ noteId: 'someone-elses' }))).status, 404);
});

Deno.test('validates the body', async () => {
  const handler = makeHandler({
    makeUserClient: () => fakeClient(null),
    summarize: async () => '',
  });

  assertEquals((await handler(post({}))).status, 400);
});
Enter fullscreen mode Exit fullscreen mode

Run them:

deno test --allow-all supabase/functions/tests/
Enter fullscreen mode Exit fullscreen mode

These run in well under a second and cover the logic you're most likely to break: validation, auth, not-found handling, and the happy path.

What to unit test in every function:

  • [ ] Wrong HTTP method → 405
  • [ ] Missing auth → 401
  • [ ] Invalid or missing body fields → 400
  • [ ] Row hidden by RLS → 404, and the expensive call (AI, payment) is not made
  • [ ] External API failure → a clear error, not an unhandled exception
  • [ ] Happy path → correct response shape

That fifth one deserves its own test:

Deno.test('surfaces AI provider failures cleanly', async () => {
  const handler = makeHandler({
    makeUserClient: () => fakeClient({ body: 'text' }),
    summarize: async () => { throw new Error('AI provider returned 503'); },
  });

  const res = await handler(post({ noteId: 'n1' }));
  // Decide what you want here and pin it down: 502 is a reasonable choice
  assertEquals(res.status >= 500, true);
});
Enter fullscreen mode Exit fullscreen mode

Writing this test will probably make you add a try/catch around the external call, which is the point.

Test 2: Auth and JWT behavior

By default, Supabase verifies the JWT on every function request before your code runs. You can turn that off per function in supabase/config.toml:

[functions.stripe-webhook]
verify_jwt = false
Enter fullscreen mode Exit fullscreen mode

Turn it off only for functions called by third parties (webhooks) or genuinely public endpoints, and then verify the caller yourself: a webhook signature, a shared secret header, or similar.

For functions that keep JWT verification on, test both sides against a running local backend:

  • An anonymous request returns 401.
  • A signed-in user can reach their own data.
  • User A can't reach user B's data through the function. This is where forwarding the Authorization header pays off: RLS does the work, and your test proves it.

Test 3: Webhook-triggered functions

Database webhooks call your function with a fixed payload shape: type, table, schema, record, and old_record. Save real payloads as fixtures and unit-test against them:

// supabase/functions/tests/fixtures/order-insert.json
{
  "type": "INSERT",
  "table": "orders",
  "schema": "public",
  "record": { "id": "o1", "user_id": "u1", "total_cents": 4200, "status": "paid" },
  "old_record": null
}
Enter fullscreen mode Exit fullscreen mode
Deno.test('sends a receipt for paid orders only', async () => {
  const sent: string[] = [];
  const handler = makeOrderHandler({ sendReceipt: async (id) => { sent.push(id); } });

  const payload = JSON.parse(await Deno.readTextFile(
    new URL('./fixtures/order-insert.json', import.meta.url)
  ));

  await handler(new Request('http://localhost', { method: 'POST', body: JSON.stringify(payload) }));
  assertEquals(sent, ['o1']);
});
Enter fullscreen mode Exit fullscreen mode

Add fixtures for the cases that cause real incidents: an UPDATE where the status didn't change (no duplicate receipt), a DELETE (where record is null), and the same payload delivered twice. Webhooks can retry, so make handlers idempotent and test it.

Test 4: Secrets and environment

Locally, put function secrets in supabase/functions/.env (and add it to .gitignore), or pass a file with --env-file when serving. In production, set them with:

supabase secrets set --env-file ./supabase/functions/.env.production
supabase secrets list
Enter fullscreen mode Exit fullscreen mode

Two tests worth having:

  • The function fails loudly if a required secret is missing. Read secrets at startup and throw a clear error, rather than discovering undefined in a request to your AI provider.
  • Secrets never reach the response. A test that serializes every error response and asserts it doesn't contain your key catches the classic return Response.json({ error }) leak.

Test 5: Integration tests against a local backend

Unit tests prove the logic. Integration tests prove the wiring: the real supabase-js client, real JWTs, real RLS, the real function runtime.

The standard way is the Supabase CLI:

supabase start
supabase functions serve --env-file ./supabase/functions/.env
Enter fullscreen mode Exit fullscreen mode

Then invoke through the client, exactly as your app does:

import { createClient } from 'jsr:@supabase/supabase-js@2';
import { assertEquals } from 'jsr:@std/assert';

const supabase = createClient(
  Deno.env.get('SUPABASE_URL') ?? 'http://127.0.0.1:54321',
  Deno.env.get('SUPABASE_PUBLISHABLE_KEY') ?? Deno.env.get('SUPABASE_ANON_KEY')!,
  { auth: { persistSession: false } }
);

Deno.test('anonymous callers are rejected', async () => {
  const { error } = await supabase.functions.invoke('summarize', { body: { noteId: 'x' } });
  assertEquals(error !== null, true);
});
Enter fullscreen mode Exit fullscreen mode

Sign a test user in first to test the authenticated paths.

A lighter local option

supabase start runs a dozen Docker containers, roughly 1.4 GB of RAM on a Mac, and takes about a minute to boot. If that's what keeps you from running integration tests on every change, tinbase is an open-source alternative that runs a Supabase-compatible backend in one process, with no Docker, in about two seconds.

npx tinbase start
Enter fullscreen mode Exit fullscreen mode

It loads functions from supabase/functions/<name>/index.ts, and functions written with Deno.serve(handler) and Deno.env run unchanged, so supabase.functions.invoke() works against it. It also runs database webhooks with the same payload shape and emulates pg_cron and pg_net, so a "cron job calls a function" flow can be exercised end to end.

Be aware of the trade-offs before you switch:

  • Imports: functions that use npm:, jsr:, or URL imports need to be bundled first. Functions using only web APIs run as-is.
  • Maturity: tinbase is alpha, built for local development and CI, not production.
  • Parity: run your integration suite against the real Supabase CLI before deploying.

A common setup is tinbase for the fast inner loop and every pull request, and the Supabase CLI in a nightly or pre-deploy job.

Putting it in CI

# .github/workflows/functions.yml
name: Edge Functions
on:
  pull_request:
    paths: ['supabase/functions/**']

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v2
        with: { deno-version: v2.x }
      - run: deno test --allow-all supabase/functions/tests/
Enter fullscreen mode Exit fullscreen mode

Unit tests need nothing but Deno, so they run in seconds on every pull request. Add the integration job (with the Supabase CLI or tinbase) once the unit layer is solid.

The checklist

Structure

  • [ ] Logic lives in handler.ts; index.ts only wires dependencies and calls Deno.serve
  • [ ] External calls (AI, payments, email) are injected, so tests can fake them

Unit tests

  • [ ] Method, auth, and body validation return the right status codes
  • [ ] RLS-hidden rows return 404 and skip expensive calls
  • [ ] External failures are handled, not thrown
  • [ ] Webhook handlers are idempotent, tested with saved fixtures

Security

  • [ ] User clients forward the Authorization header; service role only when needed
  • [ ] verify_jwt = false only where you verify the caller another way
  • [ ] Missing secrets fail at startup; secrets never appear in responses

Integration

  • [ ] Anonymous, own-data, and other-user's-data cases tested through functions.invoke
  • [ ] Runs on every pull request that touches supabase/functions/

Edge Functions hold the code you least want to break. An hour spent splitting handlers and writing these tests pays for itself the first time it stops a duplicate charge from reaching production.

What's the bug your Edge Function tests would have caught? I'd like to collect them.

Top comments (0)