测试你的 Edge 函数
Writing Unit Tests for Edge Functions using Deno Test
测试是开发过程中一个非常重要的步骤,它可以确保你的边缘函数的正确性、可靠性和性能。因为边缘函数通常会结合 HTTP 处理、认证、数据库访问和业务逻辑,所以一个好的测试策略可以在部署到生产环境之前,给你快速的反馈和很高的信心。
🌐 Testing is an essential step in the development process to ensure the correctness, reliability, and performance of your Edge Functions. Because Edge Functions often combine HTTP handling, authentication, database access, and business logic, a good testing strategy gives you fast feedback and high confidence before deploying to production.
在本指南中,你将学会如何写:
🌐 In this guide you will learn how to write:
- 单元测试 针对纯业务逻辑,比如定价规则、计算等。
- 集成测试通过在网络层进行模拟来测试完整的 Edge Function
这里展示的示例和模式采用了 Supabase Edge Functions 团队内部使用的相同方法。
🌐 The examples and patterns shown here follow the same approaches used internally by Supabase's Edge Functions team.
Deno 自带一个快速的本地测试运行器和出色的 @std/testing 模拟工具。想了解更多,可以看看 官方 Deno 测试文档。
🌐 Deno ships with a fast, native test runner and excellent mocking utilities in @std/testing. See the official Deno testing documentation for more background.
示例场景 #
🌐 The example scenario
你可以使用一个叫做 process-ticket 的真实 Edge Function,它会根据已认证用户的年龄(从 profiles 表加载)来计算票的最终价格。
🌐 You can use a realistic Edge Function called process-ticket that calculates the final price of a ticket based on the authenticated user's age (loaded from the profiles table).
业务规则:
- 8岁及以下儿童 → 免费(
0) - 9–17 岁的年轻人 → 享受 20% 折扣
- 18岁及以上成人 → 全价
这个函数接收一个带有 price 字段的 JSON 负载,然后返回 { result: finalPrice }。
🌐 The function receives a JSON payload with a price field and returns { result: finalPrice }.
这个例子展示了常见的现实需求:
🌐 This example demonstrates common real-world requirements:
- 请求验证
- 通过
withSupabase进行已认证的数据库访问 - 业务规则应用
- 正确的错误处理
推荐的项目结构 #
🌐 Recommended project structure
1supabase/2├── functions/3│ ├── _shared/4│ │ └── types.ts # Database types5│ ├── process-ticket/6│ │ ├── index.ts # Edge Function (uses withSupabase)7│ │ └── pricing.ts # Pure business logic (co-located)8│ └── tests/9│ ├── utils/10│ │ └── supabase_env.ts # Test helpers (env + JWT)11│ └── process-ticket/12│ ├── pricing.test.ts # Unit tests for pricing13│ └── index.test.ts # Integration tests with fetch mocking14├── config.toml15└── deno.json在这个参考实现中,定价逻辑位于 process-ticket/pricing.ts 函数文件夹内。如果你想在多个函数中复用它,也可以把它移到 _shared/。
🌐 In this reference implementation the pricing logic lives inside the function folder
process-ticket/pricing.ts. You can also move it to _shared/ if you want to reuse it across
multiple functions.
请参阅开发环境和依赖管理指南,了解推荐的 deno.json 和编辑器设置。
🌐 See the Development Environment and Managing dependencies guides for recommended deno.json and editor setup.
单元测试:测试纯业务逻辑 #
🌐 Unit tests: Testing pure business logic
定价规则是纯函数,没有副作用,所以它们非常适合进行快速、独立的单元测试。
🌐 The pricing rules are pure functions with no side effects, so they are perfect candidates for fast, isolated unit tests.
定价模块 #
🌐 The pricing module
1// supabase/functions/process-ticket/pricing.ts2export const AGE_TIERS = {3 CHILDREN: 1, // free access4 YOUNG: 0.2, // 20% off (ages 9-17)5 ADULT: 0, // no discount6}78export function getAgeDiscount(age: number) {9 if (age <= 8) {10 return AGE_TIERS.CHILDREN11 }1213 if (age > 8 && age < 18) {14 return AGE_TIERS.YOUNG15 }1617 return AGE_TIERS.ADULT18}1920export function applyTicketDiscount(price: number, age: number) {21 const discount = getAgeDiscount(age)22 return price - price * discount23}单元测试 #
🌐 Unit tests
参考实现使用了来自 @std/testing/bdd 的 BDD 风格 API:
🌐 The reference implementation uses the BDD-style API from @std/testing/bdd:
1// supabase/functions/tests/process-ticket/pricing.test.ts2import { assertEquals } from 'jsr:@std/assert'3import { describe, it } from 'jsr:@std/testing/bdd'45import { AGE_TIERS, applyTicketDiscount, getAgeDiscount } from '../../process-ticket/pricing.ts'67describe('getAgeDiscount', () => {8 it('should return valid discount for children', () => {9 const ages = [0, 5, 8]10 ages.forEach((age) => {11 const discount = getAgeDiscount(age)12 assertEquals(discount, AGE_TIERS.CHILDREN)13 })14 })1516 it('should return valid discount for young people (9-17)', () => {17 const ages = [9, 12, 17]18 ages.forEach((age) => {19 const discount = getAgeDiscount(age)20 assertEquals(discount, AGE_TIERS.YOUNG)21 })22 })2324 it('should return valid discount for adults', () => {25 const ages = [18, 35, 80]26 ages.forEach((age) => {27 const discount = getAgeDiscount(age)28 assertEquals(discount, AGE_TIERS.ADULT)29 })30 })31})3233describe('applyTicketDiscount', () => {34 it('kids have free access', () => {35 const price = applyTicketDiscount(10, 8)36 assertEquals(price, 0)37 })3839 it('young people get 20% off', () => {40 const ages = [9, 12, 17]41 ages.forEach((age) => {42 const price = applyTicketDiscount(10, age)43 assertEquals(price, 8) // 10 - 20% = 844 })45 })4647 it('adults pay full price', () => {48 const ages = [18, 35, 80]49 ages.forEach((age) => {50 const price = applyTicketDiscount(10, age)51 assertEquals(price, 10)52 })53 })54})运行单元测试:
🌐 Run the unit tests:
1deno test supabase/functions/tests/process-ticket/pricing.test.ts这些测试运行只需毫秒,让你在更改折扣规则时立即获得安全保障。
🌐 These tests run in milliseconds and give you immediate safety when changing discount rules.
集成测试:测试完整的 Edge 功能 #
🌐 Integration tests: Testing the full Edge Function
参考实现使用了一个模式:模拟 globalThis.fetch 来拦截 Edge Function 发出的 Supabase REST 调用。这种方法 无需对你的生产代码做任何更改 就可以进行测试。
🌐 The reference implementation uses a pattern: mocking globalThis.fetch to intercept the Supabase REST calls made by the Edge Function. This approach requires zero changes to your production code for testability.
边缘功能 #
🌐 The Edge Function
1// supabase/functions/process-ticket/index.ts2import '@supabase/functions-js/edge-runtime.d.ts'34import { withSupabase } from '@supabase/server'56import { Database } from '../_shared/types.ts'7import { applyTicketDiscount } from './pricing.ts'89console.log('Hello from Functions!')1011export type Payload = {12 price: number13}1415export default {16 fetch: withSupabase<Database>({ auth: ['user'] }, async (req, ctx) => {17 const { price } = (await req.json()) as Payload18 if (!price) {19 return Response.json({ error: 'missing price field' }, { status: 400 })20 }2122 const { data, error: getAgeError } = await ctx.supabase23 .from('profiles')24 .select('age')25 .limit(1)26 .single()2728 if (getAgeError || !Number.isInteger(data.age)) {29 return Response.json({ error: 'could not process' }, { status: 500 })30 }3132 const result = applyTicketDiscount(price, data.age)3334 return Response.json({ result })35 }),36} satisfies Deno.ServeDefaultExport要点:
🌐 Key points:
- 使用来自
@supabase/server的高级withSupabase辅助功能 - 自动提供经过认证的
ctx.supabase客户端 - 业务逻辑交给同地的
pricing.ts处理
集成测试设置 #
🌐 Integration test setup
这个助手搭建了一个模拟的 Supabase 环境,并为经过身份验证的请求生成有效的 RS256 JWT:
🌐 This helper sets up a mock Supabase environment and generates valid RS256 JWTs for authenticated requests:
1// supabase/functions/tests/utils/supabase_env.ts2import { exportJWK, generateKeyPair, GenerateKeyPairResult, SignJWT } from 'jsr:@panva/jose@6'34const jwk: GenerateKeyPairResult = await generateKeyPair('RS256')5const publicJwk = await exportJWK(jwk.publicKey)6publicJwk.alg = 'RS256'7publicJwk.use = 'sig'89export const env = {10 url: 'http://mocksupabase',11 publishableKeys: { default: 'sb_publishable_xy' },12 secretKeys: { default: 'sb_secret_xy' },13 jwks: {14 keys: [publicJwk],15 },16}1718export function exportEnv() {19 Deno.env.set('SUPABASE_URL', env.url)20 Deno.env.set('SUPABASE_PUBLISHABLE_KEYS', JSON.stringify(env.publishableKeys))21 Deno.env.set('SUPABASE_SECRET_KEYS', JSON.stringify(env.secretKeys))22 Deno.env.set('SUPABASE_JWKS', JSON.stringify(env.jwks))23}2425export async function generateUserToken() {26 const token = await new SignJWT({27 sub: 'user-123',28 role: 'authenticated',29 email: 'test@example.com',30 })31 .setProtectedHeader({ alg: 'RS256' })32 .setIssuedAt()33 .setExpirationTime('1h')34 .sign(jwk.privateKey)3536 return token37}完整集成测试 #
🌐 Full integration tests
1// supabase/functions/tests/process-ticket/index.test.ts2import { assertEquals } from 'jsr:@std/assert'3import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd'4import { assertSpyCalls, spy } from 'jsr:@std/testing/mock'56import EdgeFunction from '../../process-ticket/index.ts'7import { env, exportEnv, generateUserToken } from '../utils/supabase_env.ts'89const FUNCTION_URL = `${env.url}/functions/v1/process-ticket`1011function mockTableRequest(restQuery: string, result: object, status = 200) {12 return spy(async (input: RequestInfo | URL): Promise<Response> => {13 const url = input.toString()14 if (url === `${env.url}/rest/v1/${restQuery}`) {15 const res = Response.json(result, { status })16 return await Promise.resolve(res)17 }18 return await Promise.resolve(new Response(null, { status: 404 }))19 })20}2122describe('requests for /process-ticket endpoint', () => {23 const originalFetch = globalThis.fetch24 const originalEnv = Deno.env25 const mockEnv = new Map<string, string>()2627 beforeEach(() => {28 // @ts-ignore29 Deno.env = {30 has: (key) => mockEnv.has(key),31 get: (key) => mockEnv.get(key),32 set: (key, value) => mockEnv.set(key, value),33 delete: (key) => mockEnv.delete(key),34 toObject: () => Object.fromEntries(mockEnv),35 } satisfies Deno.Env3637 mockEnv.clear()38 exportEnv()39 })4041 afterEach(() => {42 // @ts-ignore43 Deno.env = originalEnv44 globalThis.fetch = originalFetch45 })4647 it('should return the correct price based on user age', async () => {48 const expects = [49 { age: 8, result: 0 }, // Free50 { age: 15, result: 8 }, // 20% off51 { age: 18, result: 10 }, // No discount52 ]5354 for (const { age, result } of expects) {55 const token = await generateUserToken()5657 const req = new Request(FUNCTION_URL, {58 method: 'POST',59 headers: {60 apikey: env.publishableKeys.default,61 Authorization: `Bearer ${token}`,62 },63 body: JSON.stringify({ price: 10 }),64 })6566 const mockProfiles = mockTableRequest('profiles?select=age&limit=1', { age })67 globalThis.fetch = mockProfiles6869 const res = await EdgeFunction.fetch(req)7071 assertEquals(res.status, 200)72 assertEquals(await res.json(), { result })73 assertSpyCalls(mockProfiles, 1)74 }75 })7677 it('should return 400 when price is missing', async () => {78 const token = await generateUserToken()79 const req = new Request(FUNCTION_URL, {80 method: 'POST',81 headers: {82 apikey: env.publishableKeys.default,83 Authorization: `Bearer ${token}`,84 },85 body: JSON.stringify({}),86 })8788 const res = await EdgeFunction.fetch(req)89 assertEquals(res.status, 400)90 assertEquals(await res.json(), { error: 'missing price field' })91 })9293 it('should return 500 when age is missing or invalid', async () => {94 const token = await generateUserToken()95 const req = new Request(FUNCTION_URL, {96 method: 'POST',97 headers: {98 apikey: env.publishableKeys.default,99 Authorization: `Bearer ${token}`,100 },101 body: JSON.stringify({ price: 15 }),102 })103104 const mockProfiles = mockTableRequest('profiles?select=age&limit=1', {})105 globalThis.fetch = mockProfiles106107 const res = await EdgeFunction.fetch(req)108 assertEquals(res.status, 500)109 assertEquals(await res.json(), { error: 'could not process' })110 })111112 it('should handle database errors gracefully', async () => {113 const token = await generateUserToken()114 const req = new Request(FUNCTION_URL, {115 method: 'POST',116 headers: {117 apikey: env.publishableKeys.default,118 Authorization: `Bearer ${token}`,119 },120 body: JSON.stringify({ price: 15 }),121 })122123 const mockProfiles = mockTableRequest(124 'profiles?select=age&limit=1',125 { code: 'PGRST303', message: 'JWT expired' },126 401127 )128 globalThis.fetch = mockProfiles129130 const res = await EdgeFunction.fetch(req)131 assertEquals(res.status, 500)132 assertEquals(await res.json(), { error: 'could not process' })133 })134})运行集成测试:
🌐 Run the integration tests:
1deno test supabase/functions/tests/process-ticket/index.test.ts --allow-env模拟方法的优点 #
🌐 Advantages of mocking approach
本指南使用 fetch() 模拟来展示以下好处:
🌐 This guide uses fetch() mock to demonstrate the following benefits:
- 测试真实的 Edge Function 代码路径——生产代码中无需依赖注入
- 模拟数据库响应、认证失败、网络错误
- 保持你的生产 Edge Function 简洁专注
- 依然可以获得快速、确定性的测试,而且不需要运行 Supabase 实例
这个模式很适合在你可以控制内部代码的高级辅助函数中使用,比如 withSupabase。
🌐 This pattern fits great in higher-level helpers that you can control inner code, like withSupabase.
正在运行所有测试 #
🌐 Running all tests
添加到你的 deno.json:
🌐 Add to your deno.json:
1{23 // ...45 "tasks": {6 "test": "deno test supabase/functions/tests/ --allow-env"7 }8}然后:
🌐 Then:
1deno task test最佳实践 #
🌐 Best practices
- 把纯业务逻辑放在单独的模块里(即使和函数在同一位置也没关系)
- 使用
withSupabase+ 输入的Database来进行干净、经过认证的访问 - 当你不想修改生产代码时,集成测试最好在
fetch边界处使用模拟 - 使用
@std/testing/bdd+@std/testing/mock来编写有表现力且易于维护的测试 - 当你的函数依赖已认证的 Supabase 客户端时,在测试中生成真实的 JWT
- 测试正常情况和错误情况(缺少输入、数据库失败、数据无效)
资源 #
🌐 Resources
- 阅读 Deno 测试指南
- 了解更多关于
withSupabase和@supabase/server的信息 - 查看其他 Edge Functions 指南:开发环境、管理依赖、部署到生产