mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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
|
||||
}
|
||||
+134
@@ -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' })
|
||||
})
|
||||
})
|
||||
+54
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user