How to Test Edge Functions Locally (Deno)

To test Supabase edge functions locally, start the local stack with supabase start, run supabase functions serve to host your functions at http://localhost:54321/functions/v1/<name> with live reload, and call them with curl or the client library. For automated tests, put a pure handler in its own module and test it with Deno's built-in runner (deno test); for breakpoints, serve with --inspect-mode brk and attach Chrome DevTools.

This guide walks through all three levels, from quick manual checks to unit tests to integration tests against a real local database, then shows the same workflow with the StitchDB CLI, which runs the same Deno functions locally without Docker. Supabase commands are from the Supabase docs on testing and local debugging, as of November 2026.

#Three levels of testing

LevelWhat it checksSpeedNeeds
Manual (curl)The function starts and answersSecondsLocal stack running
Unit testsYour logic, with the database and APIs fakedMillisecondsDeno only
Integration testsThe function against a real local database and AuthSecondsLocal stack running

Most bugs are caught at the unit level, so structure your code to make that level easy. That's the main idea of this article.

#Step 1: structure the function so it can be tested

A typical function puts everything inside Deno.serve. That works, but you can't call it from a test without starting a server. Split it into a handler module and a thin entry point:

TypeScript
// functions/create-invoice/handler.ts
export type Deps = {
  insertInvoice: (row: { customer_id: string; amount_cents: number }) => Promise<{ id: number }>
}

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

  const body = await req.json().catch(() => null)
  if (!body?.customer_id || !Number.isInteger(body.amount_cents) || body.amount_cents <= 0) {
    return Response.json({ error: 'customer_id and a positive amount_cents are required' }, { status: 400 })
  }

  const invoice = await deps.insertInvoice({ customer_id: body.customer_id, amount_cents: body.amount_cents })
  return Response.json(invoice, { status: 201 })
}
TypeScript
// functions/create-invoice/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
import { handle } from './handler.ts'

Deno.serve((req) => {
  // Queries run as the caller, so Row Level Security applies.
  const db = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, {
    global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } },
  })

  return handle(req, {
    insertInvoice: async (row) => {
      const { data, error } = await db.from('invoices').insert(row).select('id').single()
      if (error) throw error
      return data
    },
  })
})

The handler takes a standard Request and returns a Response, so tests can build requests directly. The database is passed in, so tests can replace it.

#Step 2: serve locally and check by hand

With the Supabase CLI:

Terminal
supabase start                     # local Postgres, Auth, REST, Storage...
supabase functions serve           # serves every function, reloads on save

Then call it with the local anon key that supabase start printed:

Terminal
curl -i http://localhost:54321/functions/v1/create-invoice \
  -H "Authorization: Bearer <local anon key>" \
  -H "Content-Type: application/json" \
  -d '{"customer_id":"c_123","amount_cents":4900}'

Things to check at this level:

  • The status code for good and bad input (201, 400, 405).
  • JWT verification. By default a function needs a valid token. Leave out the header and you should get a 401. For webhooks that can't send your key, turn verification off for that function and check the sender's signature in code instead.
  • Secrets. Local functions don't see your production secrets. Keep local values in an env file and pass it to serve (--env-file), and never commit it.

#Step 3: unit tests with Deno

Deno has a test runner built in. Supabase's guide suggests a tests folder under functions, with one -test.ts file per function. Test the handler with a fake dependency:

TypeScript
// functions/tests/create-invoice-test.ts
import { assertEquals } from 'jsr:@std/assert@1'
import { handle } from '../create-invoice/handler.ts'

const inserted: unknown[] = []
const deps = {
  insertInvoice: async (row: { customer_id: string; amount_cents: number }) => {
    inserted.push(row)
    return { id: 1 }
  },
}

const post = (body: unknown) =>
  new Request('http://local/create-invoice', { method: 'POST', body: JSON.stringify(body) })

Deno.test('creates an invoice', async () => {
  const res = await handle(post({ customer_id: 'c_1', amount_cents: 4900 }), deps)
  assertEquals(res.status, 201)
  assertEquals(await res.json(), { id: 1 })
})

Deno.test('rejects a negative amount', async () => {
  const res = await handle(post({ customer_id: 'c_1', amount_cents: -5 }), deps)
  assertEquals(res.status, 400)
})

Deno.test('rejects GET', async () => {
  const res = await handle(new Request('http://local/create-invoice'), deps)
  assertEquals(res.status, 405)
})
Terminal
deno test --allow-all functions/tests/create-invoice-test.ts

These tests need no server, no database and no network. They run in milliseconds, so you can run them on every save with deno test --watch.

#Step 4: integration tests against the local stack

Unit tests can't tell you whether your Row Level Security policy lets a user insert an invoice. For that, test through the real stack. Load the local URL and anon key from an env file and call the served function as a signed-in user:

TypeScript
// functions/tests/create-invoice-integration-test.ts
import { assertEquals } from 'jsr:@std/assert@1'
import { createClient } from 'npm:@supabase/supabase-js@2'

const url = Deno.env.get('API_URL')!          // http://localhost:54321 (or 127.0.0.1)
const anonKey = Deno.env.get('ANON_KEY')!

Deno.test('a signed-in user can create an invoice', async () => {
  const client = createClient(url, anonKey, { auth: { persistSession: false } })
  const email = `test-${crypto.randomUUID()}@example.com`
  await client.auth.signUp({ email, password: 'a-long-test-password' })

  const { data, error } = await client.functions.invoke('create-invoice', {
    body: { customer_id: 'c_1', amount_cents: 4900 },
  })
  assertEquals(error, null)
  assertEquals(typeof data.id, 'number')
})
Terminal
deno test --allow-all --env-file=.env.test functions/tests/

For sign-up to return a session right away, e-mail confirmation must be off in your local Auth config (enable_confirmations = false under [auth.email] in the local config.toml). Reset the database between runs so tests don't depend on old rows.

#Step 5: debug with breakpoints

console.log and the serve output cover most debugging. When you need to step through code, Supabase's CLI supports the V8 inspector:

  1. Run supabase functions serve --inspect-mode brk. It pauses on the first line.
  2. Open chrome://inspect in Chrome, add 127.0.0.1:8083 as a discovery target and open the dedicated DevTools for Node.
  3. Send a request, open your index.ts under Sources and set breakpoints.

Unit tests can also be debugged directly with Deno: deno test --inspect-brk waits for a debugger before running.

#The same workflow on StitchDB

StitchDB runs Deno Edge Functions with Deno.serve, and its local stack uses the same default port, so the steps above carry over almost one to one. Functions get the project URL and keys both as STITCHDB_* variables and under the names the Supabase stack uses, so the index.ts above works unchanged (environment and secrets).

Install the CLI and start a local project. It runs as plain processes, without Docker (local development):

Terminal
npm install -g stitchdb-cli
stitchdb init                       # creates stitchdb/ with config.toml, migrations/, functions/
stitchdb start                      # API on 127.0.0.1:54321, Postgres on 54322, Studio on 54323
stitchdb functions new create-invoice
stitchdb functions serve            # live reload, follows the logs

functions serve runs every folder in stitchdb/functions at http://127.0.0.1:54321/functions/v1/<name>, each in its own Deno process that restarts when you save. Local secrets go in stitchdb/functions/.env (CLI functions). Some details that help with tests:

  • A mail catcher at http://127.0.0.1:54324 receives every e-mail Auth sends, so you can test sign-up and magic-link flows without a real inbox.
  • stitchdb db reset rebuilds the local database from your migrations and seed.sql, a clean start before each integration run.
  • Each git worktree is its own local project with its own database and ports, so two branches (or two coding agents) can run tests side by side.
  • The Deno unit tests above run as they are: deno test doesn't depend on the platform.

In CI, run the unit tests on every push, then deploy with the CLI (CI):

Terminal
deno test --allow-all stitchdb/functions/tests/
stitchdb db push --yes
stitchdb functions deploy --all --if-present

On paid plans, stitchdb init --github adds a workflow that gives every pull request its own preview branch with the PR's migrations and functions deployed, which makes a good last check before production.

Supabase's tooling is the more established of the two, with documented breakpoint debugging through the CLI, and many examples to copy. StitchDB's local stack is lighter (no Docker, services start on first request). Both run the same Deno code. For writing the functions themselves, see the edge functions guide, and for runtime limits, Supabase Edge Functions limits.

#FAQ

#Do I need Docker to test edge functions locally?

For the Supabase CLI's full local stack, yes. Deno unit tests need only Deno. The StitchDB CLI runs its local stack, functions included, without Docker.

#How do I pass secrets to a locally served function?

Put them in an env file that is not committed and pass it to the serve command (Supabase), or put them in stitchdb/functions/.env (StitchDB). Read them with Deno.env.get().

#Why does my local function return 401?

JWT verification is on and the request has no valid token. Send Authorization: Bearer <local anon key>, or turn verification off for that function if it is a webhook.

#Can I unit test a function that uses the database?

Yes. Pass the database calls into your handler as a dependency and replace them with fakes in tests. Use integration tests against the local stack for policies and real queries.

#How do I debug an edge function with breakpoints?

With the Supabase CLI, serve with --inspect-mode brk and attach Chrome DevTools. For unit tests, run deno test --inspect-brk.

Install the CLI and run your functions locally with a free StitchDB project as the deploy target.