Testing Supabase Edge Functions with Deno
Supabase Edge Functions run on Deno Deploy — a V8-isolate runtime that is fast to cold-start and strict about permissions. Testing them requires understanding three distinct layers: unit tests for your business logic, integration tests against a local Supabase instance, and end-to-end tests that hit the deployed function through its HTTP interface.
This guide covers all three layers with real code, and explains the specific patterns that make Edge Function testing different from standard Node.js testing.
The Testing Stack
Supabase Edge Functions use Deno's built-in test runner, which has been production-ready since Deno 1.35. No Jest, no Vitest, no extra dependencies. The runner supports:
Deno.test()for test declarationsassertEquals,assertRejects,assertThrowsfromjsr:@std/assert- Parallel and sequential test execution
- Subtests via
t.step() - Coverage reporting with
deno test --coverage
For mocking, Deno's standard library includes jsr:@std/testing/mock with stub, spy, and returnsNext utilities.
Project Structure
A well-organized Edge Functions project separates testable business logic from the Deno.serve entry point:
supabase/functions/
├── _shared/
│ ├── supabase-client.ts # Supabase client factory
│ └── auth.ts # Auth helpers
├── send-notification/
│ ├── index.ts # Entry point (Deno.serve)
│ ├── handler.ts # Business logic (testable)
│ └── handler.test.ts # Unit tests
└── process-webhook/
├── index.ts
├── handler.ts
└── handler.test.tsThe key insight: index.ts should be thin — it reads environment variables, constructs dependencies, and calls handler.ts. Your tests import handler.ts directly, injecting test doubles for the Supabase client and any external services.
A Real Edge Function
Here is the Edge Function we'll test — it sends a notification to a user after verifying they own the target resource:
// supabase/functions/send-notification/handler.ts
import { SupabaseClient } from "jsr:@supabase/supabase-js@2";
export interface NotificationRequest {
resource_id: string;
message: string;
}
export interface HandlerContext {
supabase: SupabaseClient;
userId: string;
}
export async function handleSendNotification(
request: NotificationRequest,
ctx: HandlerContext,
): Promise<{ success: boolean; notification_id?: string; error?: string }> {
// Verify the user owns the resource
const { data: resource, error: resourceError } = await ctx.supabase
.from("resources")
.select("id, owner_id")
.eq("id", request.resource_id)
.single();
if (resourceError || !resource) {
return { success: false, error: "Resource not found" };
}
if (resource.owner_id !== ctx.userId) {
return { success: false, error: "Forbidden" };
}
// Insert the notification
const { data: notification, error: insertError } = await ctx.supabase
.from("notifications")
.insert({
user_id: ctx.userId,
resource_id: request.resource_id,
message: request.message,
})
.select("id")
.single();
if (insertError) {
return { success: false, error: "Failed to create notification" };
}
return { success: true, notification_id: notification.id };
}The entry point reads auth context and wires up dependencies:
// supabase/functions/send-notification/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";
import { handleSendNotification } from "./handler.ts";
Deno.serve(async (req: Request) => {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: authHeader } } },
);
// Extract user from JWT
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const body = await req.json() as { resource_id: string; message: string };
const result = await handleSendNotification(body, {
supabase,
userId: user.id,
});
return new Response(JSON.stringify(result), {
status: result.success ? 200 : (result.error === "Forbidden" ? 403 : 400),
headers: { "Content-Type": "application/json" },
});
});Unit Tests with Deno.test
Unit tests for handler.ts stub out the Supabase client entirely. We never touch the network.
// supabase/functions/send-notification/handler.test.ts
import { assertEquals } from "jsr:@std/assert";
import { stub } from "jsr:@std/testing/mock";
import { handleSendNotification } from "./handler.ts";
const ALICE_ID = "00000000-0000-0000-0000-000000000001";
const RESOURCE_ID = "res-123";
// Helper to build a fake Supabase client
function buildFakeClient(overrides: Record<string, unknown> = {}) {
return {
from: (table: string) => ({
select: () => ({
eq: () => ({
single: () => overrides[table] ?? { data: null, error: null },
}),
}),
insert: () => ({
select: () => ({
single: () => overrides[`${table}:insert`] ?? { data: null, error: null },
}),
}),
}),
} as unknown;
}
Deno.test("handleSendNotification — happy path", async () => {
const fakeClient = buildFakeClient({
resources: {
data: { id: RESOURCE_ID, owner_id: ALICE_ID },
error: null,
},
"notifications:insert": {
data: { id: "notif-456" },
error: null,
},
});
const result = await handleSendNotification(
{ resource_id: RESOURCE_ID, message: "Hello" },
{ supabase: fakeClient, userId: ALICE_ID },
);
assertEquals(result, { success: true, notification_id: "notif-456" });
});
Deno.test("handleSendNotification — resource not found", async () => {
const fakeClient = buildFakeClient({
resources: { data: null, error: { message: "not found" } },
});
const result = await handleSendNotification(
{ resource_id: "nonexistent", message: "Hello" },
{ supabase: fakeClient, userId: ALICE_ID },
);
assertEquals(result, { success: false, error: "Resource not found" });
});
Deno.test("handleSendNotification — forbidden when user is not owner", async () => {
const fakeClient = buildFakeClient({
resources: {
data: { id: RESOURCE_ID, owner_id: "different-user-id" },
error: null,
},
});
const result = await handleSendNotification(
{ resource_id: RESOURCE_ID, message: "Hello" },
{ supabase: fakeClient, userId: ALICE_ID },
);
assertEquals(result, { success: false, error: "Forbidden" });
});
Deno.test("handleSendNotification — handles insert failure", async () => {
const fakeClient = buildFakeClient({
resources: {
data: { id: RESOURCE_ID, owner_id: ALICE_ID },
error: null,
},
"notifications:insert": {
data: null,
error: { message: "duplicate key" },
},
});
const result = await handleSendNotification(
{ resource_id: RESOURCE_ID, message: "Hello" },
{ supabase: fakeClient, userId: ALICE_ID },
);
assertEquals(result, { success: false, error: "Failed to create notification" });
});Run unit tests:
deno test supabase/functions/send-notification/handler.test.tsMocking Fetch for External API Calls
When your Edge Function calls external APIs (Stripe, Resend, Twilio), use stub from jsr:@std/testing/mock to intercept the global fetch:
import { assertEquals } from "jsr:@std/assert";
import { stub, returnsNext } from "jsr:@std/testing/mock";
Deno.test("sends email via Resend when notification created", async () => {
// Stub global fetch
const fetchStub = stub(
globalThis,
"fetch",
returnsNext([
Promise.resolve(
new Response(JSON.stringify({ id: "email-123" }), { status: 200 }),
),
]),
);
try {
// Call the function that internally uses fetch to hit Resend API
const result = await sendEmailNotification({
to: "alice@example.com",
subject: "New notification",
body: "You have a new notification",
});
assertEquals(result.email_id, "email-123");
assertEquals(fetchStub.calls.length, 1);
// Verify the request was formed correctly
const [url, init] = fetchStub.calls[0].args;
assertEquals(url, "https://api.resend.com/emails");
assertEquals((init as RequestInit).method, "POST");
} finally {
fetchStub.restore();
}
});Always restore stubs in a finally block — if the test throws, the stub stays in place and corrupts subsequent tests.
Integration Tests with Local Supabase
Unit tests prove your logic is correct. Integration tests prove your logic works against a real PostgreSQL database with real RLS policies applied.
Start the local stack:
supabase start
# Local API: http://127.0.0.1:54321
<span class="hljs-comment"># Local anon key: printed to stdoutIntegration tests use the real Supabase client but point at the local instance:
// supabase/functions/send-notification/handler.integration.test.ts
import { assertEquals } from "jsr:@std/assert";
import { createClient } from "jsr:@supabase/supabase-js@2";
const SUPABASE_URL = "http://127.0.0.1:54321";
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_LOCAL_ANON_KEY")!;
const SUPABASE_SERVICE_KEY = Deno.env.get("SUPABASE_LOCAL_SERVICE_KEY")!;
async function signInAsUser(email: string, password: string) {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
const { data, error } = await client.auth.signInWithPassword({ email, password });
if (error) throw error;
return { client, user: data.user, session: data.session };
}
// Setup: create test users and resources using service role (bypasses RLS)
async function setupTestData() {
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
await admin.auth.admin.createUser({
email: "alice@test.com",
password: "password123",
email_confirm: true,
});
// ... create resources, etc.
}
Deno.test({
name: "integration: sends notification for owned resource",
async fn() {
await setupTestData();
const { client, user } = await signInAsUser("alice@test.com", "password123");
// Call handler with real client (has Alice's auth context)
const result = await handleSendNotification(
{ resource_id: "alice-resource-id", message: "Test" },
{ supabase: client, userId: user.id },
);
assertEquals(result.success, true);
assertEquals(typeof result.notification_id, "string");
},
sanitizeResources: false, // needed when using real network connections
sanitizeOps: false,
});Run integration tests with environment variables:
SUPABASE_LOCAL_ANON_KEY=$(supabase status --output json | jq -r <span class="hljs-string">'.["anon key"]') \
SUPABASE_LOCAL_SERVICE_KEY=$(supabase status --output json <span class="hljs-pipe">| jq -r <span class="hljs-string">'.["service_role key"]') \
deno <span class="hljs-built_in">test --allow-net --allow-env supabase/functions/send-notification/handler.integration.test.tsTesting Auth Context in Edge Functions
The auth context — which user is making the request — flows through the Authorization: Bearer <jwt> header. To test that your function correctly extracts and uses this context, you need to test the index.ts entry point directly by simulating HTTP requests.
Deno 1.40+ supports testing HTTP handlers without starting a real server using the Request/Response pattern:
// supabase/functions/send-notification/index.test.ts
// Tests the full HTTP handler including auth extraction
import { assertEquals } from "jsr:@std/assert";
import { stub, returnsNext } from "jsr:@std/testing/mock";
// Import the handler function extracted from index.ts
// (refactor index.ts to export the handler for testability)
import { serveRequest } from "./index.ts";
Deno.test("returns 401 when Authorization header is missing", async () => {
const req = new Request("http://localhost/send-notification", {
method: "POST",
body: JSON.stringify({ resource_id: "r1", message: "hi" }),
headers: { "Content-Type": "application/json" },
});
const response = await serveRequest(req);
assertEquals(response.status, 401);
const body = await response.json();
assertEquals(body.error, "Unauthorized");
});
Deno.test("returns 401 when JWT is invalid", async () => {
const req = new Request("http://localhost/send-notification", {
method: "POST",
body: JSON.stringify({ resource_id: "r1", message: "hi" }),
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer invalid-token",
},
});
const response = await serveRequest(req);
assertEquals(response.status, 401);
});To make index.ts testable without starting the server, export a serveRequest function:
// index.ts — testable structure
export async function serveRequest(req: Request): Promise<Response> {
// ... handler logic
}
// Only call Deno.serve in the actual runtime, not when imported by tests
if (import.meta.main) {
Deno.serve(serveRequest);
}Running Tests in CI
A GitHub Actions workflow that runs unit tests on every push and integration tests when the Supabase schema changes:
name: Test Edge Functions
on:
push:
paths:
- "supabase/functions/**"
- "supabase/migrations/**"
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: Run unit tests
run: |
deno test \
--allow-env \
supabase/functions/**/*.test.ts
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Start Supabase
run: supabase start
- name: Run integration tests
run: |
SUPABASE_LOCAL_ANON_KEY=$(supabase status --output json | jq -r '.["anon key"]') \
SUPABASE_LOCAL_SERVICE_KEY=$(supabase status --output json | jq -r '.["service_role key"]') \
deno test \
--allow-net \
--allow-env \
supabase/functions/**/*.integration.test.tsCoverage Reporting
Deno's built-in coverage tool shows you which branches in your handler are untested:
# Run tests with coverage collection
deno <span class="hljs-built_in">test --coverage=coverage/ supabase/functions/send-notification/handler.test.ts
<span class="hljs-comment"># Generate a human-readable report
deno coverage coverage/ --exclude=<span class="hljs-string">"test"
<span class="hljs-comment"># Generate LCOV for CI coverage reporting
deno coverage coverage/ --lcov > coverage.lcovA handler with four code paths (resource not found, forbidden, insert fails, success) needs four test cases to reach 100% branch coverage. The coverage report will show exactly which branches you've missed.
The Three Layers Together
Here is how the three testing layers relate:
| Layer | Tool | What it proves | Speed |
|---|---|---|---|
| Unit | Deno.test + stubs |
Logic is correct for all inputs | ~50ms |
| Integration | Deno.test + local Supabase |
Database queries and RLS work together | ~2s |
| E2E | HTTP requests to deployed function | Full stack works in production environment | ~5s |
Run unit tests on every file save. Run integration tests before every commit. Run E2E tests after every deployment.
For the E2E layer, you can write plain HTTP assertions in your integration test suite using fetch against the deployed function URL, or use a platform like HelpMeTest to run Playwright-based E2E scenarios that exercise your Edge Functions through the full application UI. HelpMeTest's 24/7 health monitoring will catch a broken Edge Function within minutes of deployment — useful when you have functions that process webhooks or run on a schedule, where failures are otherwise silent. It runs on Robot Framework and Playwright at $100/month.
Common Pitfalls
Forgetting sanitizeResources: false for integration tests. Deno's test runner checks for unclosed resources (network connections, file handles) after each test. Real Supabase clients hold open WebSocket connections for realtime subscriptions. Add sanitizeResources: false and sanitizeOps: false to tests that use real clients.
Testing index.ts directly. Code inside Deno.serve() is difficult to test. Always extract your handler logic into a separate function that takes a Request and returns a Response. Then Deno.serve just calls that function.
Not testing the JWT extraction path. The most common production failure is a function that works in the happy path but silently returns empty data when the JWT is missing or malformed. Write explicit tests for Authorization header absence, malformed tokens, and expired tokens.
Shared state between tests. Deno tests can run in parallel by default. If two integration tests write to the same database rows, you'll get flaky results. Use unique IDs per test run (crypto.randomUUID()) and clean up in finally blocks.
Summary
Edge Function testing works best in layers:
- Keep
index.tsthin — extract all logic into ahandler.tsthat takes typed inputs and returns typed outputs - Unit test
handler.tswith stubbed Supabase clients — no network, no database, runs in milliseconds - Stub
fetchfor any external API calls usingjsr:@std/testing/mock - Write integration tests against
supabase startfor database-touching paths - Export
serveRequestfromindex.tsto make the HTTP layer testable without a live server - Use
deno test --coverageto find untested branches
The Deno test runner is fast, built-in, and supports everything you need. The only investment is the discipline to keep business logic separate from the framework entry point — a habit that pays off immediately the first time you need to test a complex authorization scenario.