Skip to content

测试

ACE 提供 ace_test 测试辅助模块,核心设计原则是不启动 HTTP Server——TestRequest 直接驱动 App 洋葱,测试速度与单元测试相当,同时覆盖完整的中间件链路。

引入测试模块

cjpm.toml 的测试依赖中添加:

toml
[dev-dependencies]
ace_test = { path = "../../ace-test" }

测试文件遵循仓颉约定:文件名以 _test.cj 结尾,与被测代码同包。


TestRequest / TestResponse

TestRequest 是构建测试请求的流式 Builder,.execute(app) 直接驱动 App 洋葱并返回 TestResponse

cangjie
import ace_test.*
import std.unittest.*
import std.unittest.testmacro.*

@Test
func testCreateUser(): Unit {
    let app = createTestApp()   // 复用应用工厂函数

    let resp = TestRequest()
        .method("POST")
        .path("/api/users")
        .header("Content-Type", "application/json")
        .header("Authorization", "Bearer test-token")
        .body("""{"name":"Alice","email":"alice@example.com"}""")
        .execute(app)

    @Expect(resp.status(), 201)
    @Expect(resp.header("Content-Type"), "application/json")

    let body = resp.bodyAsString()
    @Expect(body.contains("\"id\""), true)
}

TestRequest API

方法签名说明
method(m: String) -> TestRequest设置 HTTP 方法(GET/POST/…)
path(p: String) -> TestRequest设置请求路径(含查询字符串)
header(k: String, v: String) -> TestRequest添加请求头
body(b: String) -> TestRequest设置请求体(字符串)
bodyBytes(b: Array<UInt8>) -> TestRequest设置请求体(字节数组)
execute(app: App) -> TestResponse驱动 App 并返回响应

TestResponse API

方法签名说明
status() -> Int64HTTP 状态码
header(name: String) -> Option<String>获取响应头
bodyAsString() -> String响应体字符串
bodyAsBytes() -> Array<UInt8>响应体字节数组

withMockBean —— Bean 替换

withMockBean 在测试作用域内将容器中的指定 Bean 替换为 Mock 实现,测试结束后自动恢复原始 Bean,不会污染其他测试用例。

cangjie
import ace_test.*

@Test
func testGetUserWithMock(): Unit {
    // FakeUserRepo 实现 UserRepo 接口,返回固定数据
    class FakeUserRepo <: UserRepo {
        public func findById(id: String): Option<User> {
            Some(User(id: id, name: "Mock Alice"))
        }
    }

    withMockBean("userRepo", { FakeUserRepo() as Any }) {
        let app = createTestApp()
        let resp = TestRequest()
            .method("GET")
            .path("/api/users/42")
            .execute(app)

        @Expect(resp.status(), 200)
        @Expect(resp.bodyAsString().contains("Mock Alice"), true)
    }
    // 作用域结束,容器恢复为真实 userRepo
}

withMockBean 内部流程:

  1. 保存容器当前 Bean 快照
  2. 注册 Mock Bean 覆盖原有条目
  3. 执行测试 Lambda
  4. 无论成功或异常,自动 restore() 恢复容器

Bean 名称约定

Bean 名称与 @Service 注解的类名一致(首字母小写),例如 UserRepositoryuserRepository。可通过 GET /ace/beans 端点查看所有已注册 Bean 名称。


withConfig —— 临时配置覆盖

withConfig 接受键值对列表,在测试块内覆盖对应配置项,块结束后自动还原。

cangjie
import ace_test.*

@Test
func testRateLimitConfig(): Unit {
    withConfig([
        ("server.port",          "9000"),
        ("rateLimit.maxPerSec",  "5"),
        ("cache.ttl",            "60")
    ]) {
        let app = createTestApp()

        // 连续发送 5 次,均应成功
        for (_ in 0..5) {
            let resp = TestRequest().path("/api/data").execute(app)
            @Expect(resp.status(), 200)
        }
        // 第 6 次超出限制,应触发 429
        let resp = TestRequest().path("/api/data").execute(app)
        @Expect(resp.status(), 429)
    }
}

运行测试

bash
source scripts/env.sh         # 必须,设置 SDKROOT 和 DYLD_LIBRARY_PATH
cjpm test                     # 运行全 workspace 单测
cjpm test -m ace-web          # 只跑指定 member(注意用目录名,非包名)
cjpm test --no-capture-output # 显示测试中的 println 输出(调试用)

cjpm test 路径约定

成员目录名(如 ace-router)与包名(ace_router)不同,cjpm test ace_router 会报"路径不存在"。正确用法是 cjpm test -m ace-router(目录名),或直接 cjpm test 跑全 workspace。


最佳实践

Service 单元测试(隔离 Repo)

cangjie
@Test
func testCreateUserService(): Unit {
    class FakeRepo <: UserRepo {
        var saved: Option<User> = None
        public func save(u: User): User {
            saved = Some(u)
            return u
        }
        public func findByEmail(email: String): Option<User> { None }
    }

    let fakeRepo = FakeRepo()
    withMockBean("userRepo", { fakeRepo as Any }) {
        let svc = UserService()
        let user = svc.create("Bob", "bob@example.com")
        @Expect(user.name, "Bob")
        @Expect(fakeRepo.saved.isSome(), true)
    }
}

Controller 集成测试(TestRequest)

cangjie
@Test
func testUserController(): Unit {
    let app = createTestApp()

    // 创建用户
    let createResp = TestRequest()
        .method("POST").path("/api/users")
        .header("Content-Type", "application/json")
        .body("""{"name":"Carol","email":"carol@test.com"}""")
        .execute(app)
    @Expect(createResp.status(), 201)

    // 查询刚创建的用户
    let getResp = TestRequest()
        .method("GET").path("/api/users/carol@test.com")
        .execute(app)
    @Expect(getResp.status(), 200)
    @Expect(getResp.bodyAsString().contains("Carol"), true)
}

测试分层建议

层次工具覆盖目标
Service 单元测试withMockBean 隔离 Repo业务逻辑分支、异常路径
Controller 集成测试TestRequest + 真实容器路由绑定、参数解析、序列化
中间件测试TestRequest + 裸 App认证、限流、CORS 等横切逻辑
配置测试withConfig不同配置下的行为差异

覆盖率目标

Service 层力争 90%+ 分支覆盖;Controller 层通过集成测试覆盖所有成功路径和主要错误路径(400/401/404/500),整体不低于 80%。每个 @Test 函数保持完全独立,withMockBean / withConfig 确保测试块之间容器与配置不互相干扰。

基于 Apache-2.0 许可证发布