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
});
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 });
};
}
// 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;
},
})
);
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
Authorizationheader. 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.
400for a bad body,401for no auth,404when 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);
});
Run them:
deno test --allow-all supabase/functions/tests/
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);
});
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
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
Authorizationheader 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
}
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']);
});
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
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
undefinedin 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
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);
});
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
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/
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.tsonly wires dependencies and callsDeno.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
404and skip expensive calls - [ ] External failures are handled, not thrown
- [ ] Webhook handlers are idempotent, tested with saved fixtures
Security
- [ ] User clients forward the
Authorizationheader; service role only when needed - [ ]
verify_jwt = falseonly 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)