测试
ACE 提供 ace_test 测试辅助模块,核心设计原则是不启动 HTTP Server——TestRequest 直接驱动 App 洋葱,测试速度与单元测试相当,同时覆盖完整的中间件链路。
引入测试模块
在 cjpm.toml 的测试依赖中添加:
[dev-dependencies]
ace_test = { path = "../../ace-test" }测试文件遵循仓颉约定:文件名以 _test.cj 结尾,与被测代码同包。
TestRequest / TestResponse
TestRequest 是构建测试请求的流式 Builder,.execute(app) 直接驱动 App 洋葱并返回 TestResponse。
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 | () -> Int64 | HTTP 状态码 |
header | (name: String) -> Option<String> | 获取响应头 |
bodyAsString | () -> String | 响应体字符串 |
bodyAsBytes | () -> Array<UInt8> | 响应体字节数组 |
withMockBean —— Bean 替换
withMockBean 在测试作用域内将容器中的指定 Bean 替换为 Mock 实现,测试结束后自动恢复原始 Bean,不会污染其他测试用例。
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 内部流程:
- 保存容器当前 Bean 快照
- 注册 Mock Bean 覆盖原有条目
- 执行测试 Lambda
- 无论成功或异常,自动
restore()恢复容器
Bean 名称约定
Bean 名称与 @Service 注解的类名一致(首字母小写),例如 UserRepository → userRepository。可通过 GET /ace/beans 端点查看所有已注册 Bean 名称。
withConfig —— 临时配置覆盖
withConfig 接受键值对列表,在测试块内覆盖对应配置项,块结束后自动还原。
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)
}
}运行测试
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)
@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)
@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 确保测试块之间容器与配置不互相干扰。