feat(docs): update edge function testing guide (#47055)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

Based on the contents from: @kallebysantos 

https://github.com/kallebysantos/play.supabase.unit-tests

## What kind of change does this PR introduce?

Bug fix, feature, docs update, ...

## What is the current behavior?

Please link any relevant issues here.

## What is the new behavior?

Feel free to include screenshots if it includes visual changes.

## Additional context

Add any other context or screenshots.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Release Notes

* **Documentation**
* Completely redesigned the unit and integration testing guide for
Deno-based Supabase Edge Functions using a concrete example
* Added a recommended test folder structure and example business-rule
module
  * Introduced BDD-style unit testing patterns with runnable commands
* Expanded end-to-end integration testing guidance, including mocking of
outgoing REST calls
  * Updated test utilities for environment setup and JWT generation
  * Refreshed best practices and resources sections
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kalleby Santos <kalleby_santos@hotmail.com>
Co-authored-by: Kalleby Santos <105971119+kallebysantos@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-19 11:12:59 +01:00
1 parent ea909e998b
commit 93d3de9954
9 files changed
+646 -125

No files matched your search

@@ -0,0 +1,3 @@
# Unit-Testing example
In order to try out, check the respective docs [Testing Functions](https://supabase.com/docs/guides/functions/unit-test) page.
@@ -0,0 +1,187 @@
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]
export type Database = {
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
extensions?: Json
operationName?: string
query?: string
variables?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
profiles: {
Row: {
age: number
created_at: string
id: string
}
Insert: {
age: number
created_at?: string
id: string
}
Update: {
age?: number
created_at?: string
id?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, '__InternalSupabase'>
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, 'public'>]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
? (DefaultSchema['Tables'] & DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema['Tables']
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables']
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema['Tables']
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables']
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema['Enums']
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums']
? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema['CompositeTypes']
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes']
? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {},
},
} as const
@@ -0,0 +1,9 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2",
"@supabase/server": "npm:@supabase/server@^1"
},
"tasks": {
"test": "deno test supabase/functions/tests/ --allow-env"
}
}
@@ -0,0 +1,36 @@
// supabase/functions/process-ticket/index.ts
import '@supabase/functions-js/edge-runtime.d.ts'
import { withSupabase } from '@supabase/server'
import { Database } from '../_shared/types.ts'
import { applyTicketDiscount } from './pricing.ts'
console.log('Hello from Functions!')
export type Payload = {
price: number
}
export default {
fetch: withSupabase<Database>({ auth: ['user'] }, async (req, ctx) => {
const { price } = (await req.json()) as Payload
if (!price) {
return Response.json({ error: 'missing price field' }, { status: 400 })
}
const { data, error: getAgeError } = await ctx.supabase
.from('profiles')
.select('age')
.limit(1)
.single()
if (getAgeError || !Number.isInteger(data.age)) {
return Response.json({ error: 'could not process' }, { status: 500 })
}
const result = applyTicketDiscount(price, data.age)
return Response.json({ result })
}),
} satisfies Deno.ServeDefaultExport
@@ -0,0 +1,23 @@
// supabase/functions/process-ticket/pricing.ts
export const AGE_TIERS = {
CHILDREN: 1, // free access
YOUNG: 0.2, // 20% off (ages 9-17)
ADULT: 0, // no discount
}
export function getAgeDiscount(age: number) {
if (age <= 8) {
return AGE_TIERS.CHILDREN
}
if (age > 8 && age < 18) {
return AGE_TIERS.YOUNG
}
return AGE_TIERS.ADULT
}
export function applyTicketDiscount(price: number, age: number) {
const discount = getAgeDiscount(age)
return price - price * discount
}
@@ -0,0 +1,134 @@
// supabase/functions/tests/process-ticket/index.test.ts
import { assertEquals } from 'jsr:@std/assert'
import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd'
import { assertSpyCalls, spy } from 'jsr:@std/testing/mock'
import EdgeFunction from '../../process-ticket/index.ts'
import { env, exportEnv, generateUserToken } from '../utils/supabase_env.ts'
const FUNCTION_URL = `${env.url}/functions/v1/process-ticket`
function mockTableRequest(restQuery: string, result: object, status = 200) {
return spy(async (input: RequestInfo | URL): Promise<Response> => {
const url = input.toString()
if (url === `${env.url}/rest/v1/${restQuery}`) {
const res = Response.json(result, { status })
return await Promise.resolve(res)
}
return await Promise.resolve(new Response(null, { status: 404 }))
})
}
describe('requests for /process-ticket endpoint', () => {
const originalFetch = globalThis.fetch
const originalEnv = Deno.env
const mockEnv = new Map<string, string>()
beforeEach(() => {
// @ts-ignore
Deno.env = {
has: (key) => mockEnv.has(key),
get: (key) => mockEnv.get(key),
set: (key, value) => mockEnv.set(key, value),
delete: (key) => mockEnv.delete(key),
toObject: () => Object.fromEntries(mockEnv),
} satisfies Deno.Env
mockEnv.clear()
exportEnv()
})
afterEach(() => {
// @ts-ignore
Deno.env = originalEnv
globalThis.fetch = originalFetch
})
it('should return the correct price based on user age', async () => {
const expects = [
{ age: 8, result: 0 }, // Free
{ age: 15, result: 8 }, // 20% off
{ age: 18, result: 10 }, // No discount
]
for (const { age, result } of expects) {
const token = await generateUserToken()
const req = new Request(FUNCTION_URL, {
method: 'POST',
headers: {
apikey: env.publishableKeys.default,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ price: 10 }),
})
const mockProfiles = mockTableRequest('profiles?select=age&limit=1', { age })
globalThis.fetch = mockProfiles
const res = await EdgeFunction.fetch(req)
assertEquals(res.status, 200)
assertEquals(await res.json(), { result })
assertSpyCalls(mockProfiles, 1)
}
})
it('should return 400 when price is missing', async () => {
const token = await generateUserToken()
const req = new Request(FUNCTION_URL, {
method: 'POST',
headers: {
apikey: env.publishableKeys.default,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({}),
})
const res = await EdgeFunction.fetch(req)
assertEquals(res.status, 400)
assertEquals(await res.json(), { error: 'missing price field' })
})
it('should return 500 when age is missing or invalid', async () => {
const token = await generateUserToken()
const req = new Request(FUNCTION_URL, {
method: 'POST',
headers: {
apikey: env.publishableKeys.default,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ price: 15 }),
})
const mockProfiles = mockTableRequest('profiles?select=age&limit=1', {})
globalThis.fetch = mockProfiles
const res = await EdgeFunction.fetch(req)
assertEquals(res.status, 500)
assertEquals(await res.json(), { error: 'could not process' })
})
it('should handle database errors gracefully', async () => {
const token = await generateUserToken()
const req = new Request(FUNCTION_URL, {
method: 'POST',
headers: {
apikey: env.publishableKeys.default,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ price: 15 }),
})
const mockProfiles = mockTableRequest(
'profiles?select=age&limit=1',
{ code: 'PGRST303', message: 'JWT expired' },
401
)
globalThis.fetch = mockProfiles
const res = await EdgeFunction.fetch(req)
assertEquals(res.status, 500)
assertEquals(await res.json(), { error: 'could not process' })
})
})
@@ -0,0 +1,54 @@
// supabase/functions/tests/process-ticket/pricing.test.ts
import { assertEquals } from 'jsr:@std/assert'
import { describe, it } from 'jsr:@std/testing/bdd'
import { AGE_TIERS, applyTicketDiscount, getAgeDiscount } from '../../process-ticket/pricing.ts'
describe('getAgeDiscount', () => {
it('should return valid discount for children', () => {
const ages = [0, 5, 8]
ages.forEach((age) => {
const discount = getAgeDiscount(age)
assertEquals(discount, AGE_TIERS.CHILDREN)
})
})
it('should return valid discount for young people (9-17)', () => {
const ages = [9, 12, 17]
ages.forEach((age) => {
const discount = getAgeDiscount(age)
assertEquals(discount, AGE_TIERS.YOUNG)
})
})
it('should return valid discount for adults', () => {
const ages = [18, 35, 80]
ages.forEach((age) => {
const discount = getAgeDiscount(age)
assertEquals(discount, AGE_TIERS.ADULT)
})
})
})
describe('applyTicketDiscount', () => {
it('kids have free access', () => {
const price = applyTicketDiscount(10, 8)
assertEquals(price, 0)
})
it('young people get 20% off', () => {
const ages = [9, 12, 17]
ages.forEach((age) => {
const price = applyTicketDiscount(10, age)
assertEquals(price, 8) // 10 - 20% = 8
})
})
it('adults pay full price', () => {
const ages = [18, 35, 80]
ages.forEach((age) => {
const price = applyTicketDiscount(10, age)
assertEquals(price, 10)
})
})
})
@@ -0,0 +1,37 @@
// supabase/functions/tests/utils/supabase_env.ts
import { exportJWK, generateKeyPair, GenerateKeyPairResult, SignJWT } from 'jsr:@panva/jose@6'
const jwk: GenerateKeyPairResult = await generateKeyPair('RS256')
const publicJwk = await exportJWK(jwk.publicKey)
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
export const env = {
url: 'http://mocksupabase',
publishableKeys: { default: 'sb_publishable_xy' },
secretKeys: { default: 'sb_secret_xy' },
jwks: {
keys: [publicJwk],
},
}
export function exportEnv() {
Deno.env.set('SUPABASE_URL', env.url)
Deno.env.set('SUPABASE_PUBLISHABLE_KEYS', JSON.stringify(env.publishableKeys))
Deno.env.set('SUPABASE_SECRET_KEYS', JSON.stringify(env.secretKeys))
Deno.env.set('SUPABASE_JWKS', JSON.stringify(env.jwks))
}
export async function generateUserToken() {
const token = await new SignJWT({
sub: 'user-123',
role: 'authenticated',
email: 'test@example.com',
})
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(jwk.privateKey)
return token
}