强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

第 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 延迟< 50ms99% 请求延迟
错误率< 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 会导致测试与实际行为脱节。优先使用集成测试覆盖关键路径。

压测环境:压测必须在与生产相似的环境中进行,避免在开发机上测试得出误导性结论。

测试数据清理:集成测试后清理测试数据,避免影响后续测试。


上一章← 第 14 章 - Docker 与容器化部署 下一章第 16 章 - 最佳实践与生产部署 →