Testing Supabase Edge Functions with Deno

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 declarations
  • assertEquals, assertRejects, assertThrows from jsr:@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.ts

The 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.ts

Mocking 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 stdout

Integration 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.ts

Testing 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.ts

Coverage 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.lcov

A 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:

  1. Keep index.ts thin — extract all logic into a handler.ts that takes typed inputs and returns typed outputs
  2. Unit test handler.ts with stubbed Supabase clients — no network, no database, runs in milliseconds
  3. Stub fetch for any external API calls using jsr:@std/testing/mock
  4. Write integration tests against supabase start for database-touching paths
  5. Export serveRequest from index.ts to make the HTTP layer testable without a live server
  6. Use deno test --coverage to 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.

Read more