Skip to content
Edge Functions

测试你的 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

1
supabase/
2
├── functions/
3
│ ├── _shared/
4
│ │ └── types.ts # Database types
5
│ ├── 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 pricing
13
│ └── index.test.ts # Integration tests with fetch mocking
14
├── config.toml
15
└── deno.json

请参阅开发环境依赖管理指南,了解推荐的 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.ts
2
export const AGE_TIERS = {
3
CHILDREN: 1, // free access
4
YOUNG: 0.2, // 20% off (ages 9-17)
5
ADULT: 0, // no discount
6
}
7
8
export function getAgeDiscount(age: number) {
9
if (age <= 8) {
10
return AGE_TIERS.CHILDREN
11
}
12
13
if (age > 8 && age < 18) {
14
return AGE_TIERS.YOUNG
15
}
16
17
return AGE_TIERS.ADULT
18
}
19
20
export function applyTicketDiscount(price: number, age: number) {
21
const discount = getAgeDiscount(age)
22
return price - price * discount
23
}
View source

单元测试 #

🌐 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.ts
2
import { assertEquals } from 'jsr:@std/assert'
3
import { describe, it } from 'jsr:@std/testing/bdd'
4
5
import { AGE_TIERS, applyTicketDiscount, getAgeDiscount } from '../../process-ticket/pricing.ts'
6
7
describe('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
})
15
16
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
})
23
24
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
})
32
33
describe('applyTicketDiscount', () => {
34
it('kids have free access', () => {
35
const price = applyTicketDiscount(10, 8)
36
assertEquals(price, 0)
37
})
38
39
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% = 8
44
})
45
})
46
47
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
})
View source

运行单元测试:

🌐 Run the unit tests:

1
deno 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.ts
2
import '@supabase/functions-js/edge-runtime.d.ts'
3
4
import { withSupabase } from '@supabase/server'
5
6
import { Database } from '../_shared/types.ts'
7
import { applyTicketDiscount } from './pricing.ts'
8
9
console.log('Hello from Functions!')
10
11
export type Payload = {
12
price: number
13
}
14
15
export default {
16
fetch: withSupabase<Database>({ auth: ['user'] }, async (req, ctx) => {
17
const { price } = (await req.json()) as Payload
18
if (!price) {
19
return Response.json({ error: 'missing price field' }, { status: 400 })
20
}
21
22
const { data, error: getAgeError } = await ctx.supabase
23
.from('profiles')
24
.select('age')
25
.limit(1)
26
.single()
27
28
if (getAgeError || !Number.isInteger(data.age)) {
29
return Response.json({ error: 'could not process' }, { status: 500 })
30
}
31
32
const result = applyTicketDiscount(price, data.age)
33
34
return Response.json({ result })
35
}),
36
} satisfies Deno.ServeDefaultExport
View source

要点:

🌐 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.ts
2
import { exportJWK, generateKeyPair, GenerateKeyPairResult, SignJWT } from 'jsr:@panva/jose@6'
3
4
const jwk: GenerateKeyPairResult = await generateKeyPair('RS256')
5
const publicJwk = await exportJWK(jwk.publicKey)
6
publicJwk.alg = 'RS256'
7
publicJwk.use = 'sig'
8
9
export 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
}
17
18
export 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
}
24
25
export 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)
35
36
return token
37
}
View source

完整集成测试 #

🌐 Full integration tests

1
// supabase/functions/tests/process-ticket/index.test.ts
2
import { assertEquals } from 'jsr:@std/assert'
3
import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd'
4
import { assertSpyCalls, spy } from 'jsr:@std/testing/mock'
5
6
import EdgeFunction from '../../process-ticket/index.ts'
7
import { env, exportEnv, generateUserToken } from '../utils/supabase_env.ts'
8
9
const FUNCTION_URL = `${env.url}/functions/v1/process-ticket`
10
11
function 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
}
21
22
describe('requests for /process-ticket endpoint', () => {
23
const originalFetch = globalThis.fetch
24
const originalEnv = Deno.env
25
const mockEnv = new Map<string, string>()
26
27
beforeEach(() => {
28
// @ts-ignore
29
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.Env
36
37
mockEnv.clear()
38
exportEnv()
39
})
40
41
afterEach(() => {
42
// @ts-ignore
43
Deno.env = originalEnv
44
globalThis.fetch = originalFetch
45
})
46
47
it('should return the correct price based on user age', async () => {
48
const expects = [
49
{ age: 8, result: 0 }, // Free
50
{ age: 15, result: 8 }, // 20% off
51
{ age: 18, result: 10 }, // No discount
52
]
53
54
for (const { age, result } of expects) {
55
const token = await generateUserToken()
56
57
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
})
65
66
const mockProfiles = mockTableRequest('profiles?select=age&limit=1', { age })
67
globalThis.fetch = mockProfiles
68
69
const res = await EdgeFunction.fetch(req)
70
71
assertEquals(res.status, 200)
72
assertEquals(await res.json(), { result })
73
assertSpyCalls(mockProfiles, 1)
74
}
75
})
76
77
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
})
87
88
const res = await EdgeFunction.fetch(req)
89
assertEquals(res.status, 400)
90
assertEquals(await res.json(), { error: 'missing price field' })
91
})
92
93
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
})
103
104
const mockProfiles = mockTableRequest('profiles?select=age&limit=1', {})
105
globalThis.fetch = mockProfiles
106
107
const res = await EdgeFunction.fetch(req)
108
assertEquals(res.status, 500)
109
assertEquals(await res.json(), { error: 'could not process' })
110
})
111
112
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
})
122
123
const mockProfiles = mockTableRequest(
124
'profiles?select=age&limit=1',
125
{ code: 'PGRST303', message: 'JWT expired' },
126
401
127
)
128
globalThis.fetch = mockProfiles
129
130
const res = await EdgeFunction.fetch(req)
131
assertEquals(res.status, 500)
132
assertEquals(await res.json(), { error: 'could not process' })
133
})
134
})
View source

运行集成测试:

🌐 Run the integration tests:

1
deno 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
{
2
3
// ...
4
5
"tasks": {
6
"test": "deno test supabase/functions/tests/ --allow-env"
7
}
8
}
View source

然后:

🌐 Then:

1
deno task test

最佳实践 #

🌐 Best practices

  • 把纯业务逻辑放在单独的模块里(即使和函数在同一位置也没关系)
  • 使用 withSupabase + 输入的 Database 来进行干净、经过认证的访问
  • 当你不想修改生产代码时,集成测试最好在 fetch 边界处使用模拟
  • 使用 @std/testing/bdd + @std/testing/mock 来编写有表现力且易于维护的测试
  • 当你的函数依赖已认证的 Supabase 客户端时,在测试中生成真实的 JWT
  • 测试正常情况和错误情况(缺少输入、数据库失败、数据无效)

资源 #

🌐 Resources