OpenResty 高性能网关开发教程 / 第 15 章 - 测试与质量保障
第 15 章 - 测试与质量保障
15.1 测试策略
┌───────────────────┐
│ E2E 测试 │ ← 模拟完整用户流程
│ (少量、慢速) │
┌───┴───────────────────┴───┐
│ 集成测试 │ ← 测试组件间交互
│ (中等数量、中速) │
┌───┴───────────────────────────┴───┐
│ 单元测试 │ ← 测试单个函数
│ (大量、快速) │
└───────────────────────────────────┘
| 测试类型 | 范围 | 工具 | 速度 | 数量 |
|---|---|---|---|---|
| 单元测试 | 单个函数/模块 | busted | 毫秒 | 大量 |
| 集成测试 | 多组件协作 | busted + http | 秒 | 中等 |
| 压力测试 | 整体系统 | wrk / wrk2 | 分钟 | 少量 |
| 回归测试 | 修复后的场景 | busted | 秒 | 按需 |
15.2 busted 测试框架
busted 是 Lua 的 BDD 风格测试框架,类似于 JavaScript 的 Jest。
15.2.1 安装
# 使用 LuaRocks 安装
luarocks install busted
# 或使用 OpenResty 的 LuaRocks
/usr/local/openresty/luajit/bin/luarocks install busted
# 验证安装
busted --version
15.2.2 基本用法
-- tests/test_basic.lua
describe("Basic math", function()
it("should add numbers", function()
assert.are.equal(2 + 2, 4)
end)
it("should subtract numbers", function()
assert.are.equal(5 - 3, 2)
end)
it("should handle floating point", function()
assert.is.near(0.1 + 0.2, 0.3, 0.0001)
end)
end)
# 运行测试
busted tests/test_basic.lua
# 输出:
# ●●●
# 3 successes / 0 failures / 0 errors
15.2.3 断言 API
describe("Assertion examples", function()
-- 相等比较
it("equality", function()
assert.are.equal(1, 1)
assert.are_not.equal(1, 2)
end)
-- 类型检查
it("type checks", function()
assert.is_true(true)
assert.is_false(false)
assert.is_nil(nil)
assert.is_not_nil("value")
assert.is_string("hello")
assert.is_number(42)
assert.is_table({})
assert.is_boolean(true)
assert.is_function(function() end)
end)
-- 近似值
it("near values", function()
assert.is.near(3.14, 3.14159, 0.01)
end)
-- 字符串匹配
it("string matching", function()
assert.has_no.errors(function()
assert.is_truthy("hello world":match("world"))
end)
end)
-- 表操作
it("table operations", function()
local t = {a = 1, b = 2, c = 3}
assert.has_no.errors(function() end)
assert.are.same({a = 1, b = 2, c = 3}, t)
end)
-- 错误检查
it("error checking", function()
assert.has.errors(function()
error("something went wrong")
end)
assert.has_no.errors(function()
-- 正常执行
end)
end)
end)
15.3 单元测试
15.3.1 路由器测试
-- tests/test_router.lua
-- 模拟 OpenResty API
local function mock_ngx()
_G.ngx = {
var = { uri = "/", method = "GET" },
req = {
get_method = function() return "GET" end,
get_uri_args = function() return {} end,
get_headers = function() return {} end,
},
say = function(msg) end,
exit = function(code) end,
log = function(...) end,
now = function() return os.time() end,
shared = {
gateway_config = {
get = function(self, key) return nil end,
set = function(self, key, value, ttl) return true end,
},
},
}
end
describe("Router", function()
local router
setup(function()
mock_ngx()
-- 加载被测模块
package.path = "../lua/?.lua;" .. package.path
router = require "router"
end)
before_each(function()
-- 每个测试前重置状态
end)
describe("match()", function()
it("should match exact routes", function()
local route, remainder = router.match("/api/health")
assert.is_not_nil(route)
assert.are.equal(route.upstream, "health_service")
end)
it("should match prefix routes", function()
local route, remainder = router.match("/api/users/123")
assert.is_not_nil(route)
assert.are.equal(route.upstream, "user_service")
end)
it("should return nil for unknown routes", function()
local route = router.match("/unknown/path")
assert.is_nil(route)
end)
it("should handle trailing slash", function()
local route = router.match("/api/users/")
assert.is_not_nil(route)
end)
it("should handle query parameters", function()
local route = router.match("/api/users?page=1")
assert.is_not_nil(route)
end)
end)
end)
15.3.2 限流器测试
-- tests/test_rate_limiter.lua
describe("Rate Limiter", function()
local limiter
setup(function()
mock_ngx()
package.path = "../lua/?.lua;" .. package.path
end)
before_each(function()
-- 每次测试前创建新的限流器实例
local rate_limit = require "limiters.token_bucket"
limiter = rate_limit.new("rate_limit", 10, 60) -- 10 请求/60 秒
end)
describe("token_bucket", function()
it("should allow requests within limit", function()
for i = 1, 10 do
local ok, err = limiter:incoming("user:1", true)
assert.is_true(ok)
end
end)
it("should reject requests over limit", function()
-- 消耗所有令牌
for i = 1, 10 do
limiter:incoming("user:2", true)
end
-- 第 11 个请求应该被拒绝
local ok, err = limiter:incoming("user:2", true)
assert.is_false(ok)
assert.are.equal(err, "rejected")
end)
it("should isolate different keys", function()
-- user:3 消耗所有令牌
for i = 1, 10 do
limiter:incoming("user:3", true)
end
-- user:4 应该仍然可用
local ok = limiter:incoming("user:4", true)
assert.is_true(ok)
end)
end)
end)
15.3.3 JWT 认证测试
-- tests/test_jwt_auth.lua
describe("JWT Authentication", function()
local jwt_auth
setup(function()
mock_ngx()
package.path = "../lua/?.lua;" .. package.path
jwt_auth = require "auth.jwt_auth"
end)
describe("generate_token()", function()
it("should generate valid JWT token", function()
local token, payload = jwt_auth.generate_token({
id = "user_001",
email = "[email protected]",
role = "user",
})
assert.is_string(token)
assert.is_not_nil(token)
assert.are.equal(payload.sub, "user_001")
assert.are.equal(payload.role, "user")
end)
it("should include expiration", function()
local _, payload = jwt_auth.generate_token({
id = "user_001",
}, 3600)
assert.is_number(payload.exp)
assert.is_true(payload.exp > ngx.now())
end)
end)
describe("authenticate()", function()
it("should reject missing token", function()
ngx.req.get_headers = function() return {} end
local ok = jwt_auth.authenticate()
assert.is_false(ok)
end)
it("should reject invalid token", function()
ngx.req.get_headers = function()
return { Authorization = "Bearer invalid.token.here" }
end
local ok = jwt_auth.authenticate()
assert.is_false(ok)
end)
it("should accept valid token", function()
local token = jwt_auth.generate_token({
id = "user_001",
role = "user",
})
ngx.req.get_headers = function()
return { Authorization = "Bearer " .. token }
end
-- 需要 mock ngx.var
ngx.var = { user_id = "", user_role = "" }
local ok = jwt_auth.authenticate()
assert.is_true(ok)
assert.are.equal(ngx.var.user_id, "user_001")
end)
end)
end)
15.4 集成测试
集成测试需要启动 OpenResty 实例并发送真实 HTTP 请求。
-- tests/integration/test_api.lua
local http = require "resty.http"
local cjson = require "cjson"
-- 测试配置
local BASE_URL = os.getenv("TEST_BASE_URL") or "http://localhost:8080"
-- HTTP 请求辅助函数
local function request(method, path, body, headers)
local httpc = http.new()
httpc:set_timeout(5000)
local req_headers = headers or {}
req_headers["Content-Type"] = req_headers["Content-Type"] or "application/json"
local res, err = httpc:request_uri(BASE_URL .. path, {
method = method,
body = body and cjson.encode(body) or nil,
headers = req_headers,
})
return res, err
end
describe("API Integration Tests", function()
describe("Health Check", function()
it("should return 200 OK", function()
local res = request("GET", "/health")
assert.is_not_nil(res)
assert.are.equal(200, res.status)
end)
end)
describe("Authentication", function()
it("should reject unauthenticated requests", function()
local res = request("GET", "/api/users")
assert.are.equal(401, res.status)
end)
it("should accept valid JWT token", function()
-- 先获取 token
local login_res = request("POST", "/api/auth/login", {
username = "admin",
password = "password",
})
assert.are.equal(200, login_res.status)
local login_data = cjson.decode(login_res.body)
local token = login_data.access_token
-- 使用 token 访问受保护的 API
local res = request("GET", "/api/users", nil, {
Authorization = "Bearer " .. token,
})
assert.are.equal(200, res.status)
end)
end)
describe("Rate Limiting", function()
it("should return 429 when rate limit exceeded", function()
-- 快速发送大量请求
for i = 1, 110 do
request("GET", "/api/products")
end
-- 第 111 个请求应该被限流
local res = request("GET", "/api/products")
assert.are.equal(429, res.status)
-- 检查限流响应头
assert.is_not_nil(res.headers["Retry-After"])
assert.is_not_nil(res.headers["X-RateLimit-Limit"])
end)
end)
describe("Error Handling", function()
it("should return 404 for unknown routes", function()
local res = request("GET", "/api/nonexistent")
assert.are.equal(404, res.status)
end)
it("should return proper error format", function()
local res = request("GET", "/api/nonexistent")
local body = cjson.decode(res.body)
assert.is_string(body.error)
end)
end)
end)
运行集成测试
# 确保 OpenResty 已启动
openresty -c /path/to/test/nginx.conf
# 运行集成测试
TEST_BASE_URL=http://localhost:8080 busted tests/integration/
# 测试后清理
openresty -s stop
15.5 压力测试
15.5.1 wrk 基准测试
# 安装 wrk
sudo apt-get install wrk
# 基本压测
wrk -t12 -c400 -d30s http://localhost:8080/api/health
# 参数说明:
# -t12: 12 个线程
# -c400: 400 个并发连接
# -d30s: 持续 30 秒
-- wrk 脚本:复杂场景压测
-- scripts/wrk_test.lua
-- 初始化
wrk.method = "GET"
wrk.headers["Content-Type"] = "application/json"
wrk.headers["Authorization"] = "Bearer your-test-token"
-- 请求计数器
local counter = 0
function request()
counter = counter + 1
-- 轮询不同的 API 端点
local paths = {
"/api/users?page=" .. (counter % 10),
"/api/products?id=" .. (counter % 100),
"/api/orders?page=" .. (counter % 5),
}
local path = paths[(counter % #paths) + 1]
return wrk.format(nil, path)
end
function response(status, headers, body)
if status ~= 200 then
-- 记录错误
io.stderr:write("Error: status=" .. status .. "\n")
end
end
function done(summary, latency, requests)
io.write("------------------------------\n")
io.write(string.format("Requests/sec: %.2f\n", summary.requests / (summary.duration / 1000000)))
io.write(string.format("Avg Latency: %.2fms\n", latency.mean / 1000))
io.write(string.format("P99 Latency: %.2fms\n", latency:percentile(99) / 1000))
io.write(string.format("Max Latency: %.2fms\n", latency.max / 1000))
io.write(string.format("Total Requests: %d\n", summary.requests))
io.write(string.format("Total Errors: %d\n", summary.errors.connect + summary.errors.read + summary.errors.write + summary.errors.timeout))
end
15.5.2 wrk2 精确压测
# wrk2 支持恒定吞吐量压测
wrk -t12 -c400 -d60s -R10000 http://localhost:8080/api/users
# -R10000: 每秒 10000 个请求(恒定速率)
15.5.3 压测脚本
#!/bin/bash
# scripts/benchmark.sh
echo "=== OpenResty Gateway Benchmark ==="
echo ""
# 预热
echo "Warming up..."
wrk -t4 -c100 -d10s http://localhost:8080/health > /dev/null 2>&1
# 测试不同并发级别
for concurrency in 10 50 100 200 500 1000; do
echo "--- Concurrency: $concurrency ---"
wrk -t8 -c$concurrency -d30s -s scripts/wrk_test.lua \
http://localhost:8080 2>&1 | grep -E "Requests/sec|Latency|Errors"
echo ""
sleep 5 # 冷却时间
done
echo "=== Benchmark Complete ==="
15.6 性能基准
15.6.1 关键指标
| 指标 | 目标值 | 说明 |
|---|---|---|
| QPS | > 50,000 | 单节点吞吐量 |
| P50 延迟 | < 5ms | 中位数延迟 |
| P99 延迟 | < 50ms | 99% 请求延迟 |
| 错误率 | < 0.01% | 错误请求比例 |
| 内存占用 | < 512MB | 单节点内存 |
15.6.2 性能回归检测
-- tests/benchmark/test_performance.lua
describe("Performance Regression Tests", function()
it("should respond within 10ms for health check", function()
local httpc = require("resty.http").new()
local start = ngx.now()
local res = httpc:request_uri("http://localhost:8080/health")
local latency = (ngx.now() - start) * 1000
assert.is_true(latency < 10, "Health check latency " .. latency .. "ms > 10ms")
end)
it("should handle 1000 concurrent requests", function()
local threads = {}
local results = {success = 0, failure = 0}
for i = 1, 1000 do
threads[i] = ngx.thread.spawn(function()
local httpc = require("resty.http").new()
local res, err = httpc:request_uri("http://localhost:8080/api/test")
if res and res.status == 200 then
results.success = results.success + 1
else
results.failure = results.failure + 1
end
end)
end
for _, thread in ipairs(threads) do
ngx.thread.wait(thread)
end
assert.is_true(results.failure / 1000 < 0.01,
"Error rate " .. (results.failure / 1000 * 100) .. "% > 1%")
end)
end)
15.7 回归测试
-- tests/regression/test_issue_123.lua
describe("Issue #123: Memory leak in cache module", function()
it("should not leak memory after 10000 cache operations", function()
local cache = ngx.shared.cache_data
cache:flush_all()
local initial_memory = collectgarbage("count")
for i = 1, 10000 do
cache:set("key:" .. i, string.rep("x", 1000), 60)
cache:get("key:" .. i)
cache:delete("key:" .. i)
end
collectgarbage("collect")
local final_memory = collectgarbage("count")
-- 内存增长不应超过 10MB
assert.is_true((final_memory - initial_memory) < 10240,
"Memory grew by " .. (final_memory - initial_memory) .. "KB")
end)
end)
15.8 CI/CD 集成
15.8.1 GitHub Actions
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install OpenResty
run: |
sudo apt-get update
sudo apt-get install -y wget gnupg
wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add -
echo "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list
sudo apt-get update
sudo apt-get install -y openresty luarocks
- name: Install dependencies
run: |
luarocks install busted
sudo opm get SkyLothar/lua-resty-jwt
sudo opm get openresty/lua-resty-http
- name: Run unit tests
run: busted tests/unit/
- name: Run lint
run: |
luarocks install luacheck
luacheck lua/ --no-unused-args
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- name: Start services
run: docker-compose -f docker-compose.test.yml up -d
- name: Wait for services
run: |
for i in $(seq 1 30); do
curl -f http://localhost:8080/health && break
sleep 2
done
- name: Run integration tests
run: |
busted tests/integration/
- name: Stop services
run: docker-compose -f docker-compose.test.yml down
benchmark:
runs-on: ubuntu-latest
needs: integration-tests
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Install wrk
run: sudo apt-get install -y wrk
- name: Start services
run: docker-compose up -d
- name: Run benchmark
run: |
wrk -t8 -c200 -d30s http://localhost:8080/api/health > benchmark.txt
cat benchmark.txt
- name: Upload benchmark results
uses: actions/upload-artifact@v3
with:
name: benchmark-results
path: benchmark.txt
15.8.2 Makefile
# Makefile
.PHONY: test unit integration benchmark lint
# 运行所有测试
test: lint unit integration
# 单元测试
unit:
busted tests/unit/ --verbose
# 集成测试
integration:
openresty -c $(PWD)/tests/nginx.conf
sleep 2
busted tests/integration/ --verbose
openresty -s stop -c $(PWD)/tests/nginx.conf
# 压力测试
benchmark:
openresty -c $(PWD)/tests/nginx.conf
sleep 2
./scripts/benchmark.sh
openresty -s stop -c $(PWD)/tests/nginx.conf
# 代码检查
lint:
luacheck lua/ --no-unused-args
# 生成覆盖率报告
coverage:
busted --coverage tests/unit/
luacov
cat luacov.report.out
15.9 注意事项
测试隔离:每个测试用例应该独立,不依赖其他测试的执行顺序。使用
before_each重置状态。
Mock 适度:过度 Mock 会导致测试与实际行为脱节。优先使用集成测试覆盖关键路径。
压测环境:压测必须在与生产相似的环境中进行,避免在开发机上测试得出误导性结论。
测试数据清理:集成测试后清理测试数据,避免影响后续测试。