【仓颉语言入门 · 第25课】
【仓颉语言入门 · 第25课】单元测试:给代码配一份"自动化考卷"
第 24 课的待办 API 是"人肉验证"的——跑一次、看输出、凭眼睛判断对不对。功能一多,这种方式既不靠谱也没法回归:今天改了 A 函数,怎么知道没把 B 函数碰坏?本课介绍仓颉标准库自带的
std.unittest:@Test圈出测试套件、@TestCase标记用例、@Assert断言结果、cjpm test一键跑全套。学完你就能给函数写一份"自动化的考卷",每改一行代码立刻知道有没有闯祸。本文所有代码与输出均在仓颉 SDK 1.2.0 下逐行实测编译运行。
目录(系列导航)
整套路线共 7 个模块、30 课:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、环境与入门 | 01~05 | 环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环 |
| 二、常用类型与数据组织 | 06~10 | 字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理 |
| 三、函数与函数式 | 11~14 | 函数、Lambda 与高阶函数、闭包、迭代器与惰性序列 |
| 四、面向对象与类型系统 | 15~20 | struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展 |
| 五、工程化与标准库 | 21~25 | cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试 |
| 六、并发编程 | 26~28 | 线程、Channel 通道与同步原语、并发实战 |
| 七、项目实战 | 29~30 | 命令行小工具、GeoJSON 数据处理实战 |
- 环境搭建与第一个仓颉程序
- 变量与常量:let / var 与基本数据类型
- 运算符与标准输入输出
- 分支结构:if 与 match 表达式
- 循环结构:while / for / Range
- 字符串详解与字符串插值
- 数组 Array 与区间 Range
- 集合框架:ArrayList、HashMap、HashSet
- 可空类型
?与 Option - 错误处理:异常机制与 Result
- 函数定义、参数与返回值
- Lambda 与高阶函数
- 闭包、作用域与函数类型
- 迭代器 Iterator 与 Sequence
- 结构体 struct 与类 class
- 构造函数、属性与方法
- 接口 interface 与实现
- 枚举 enum、代数数据类型与 match 模式匹配
- 泛型编程
- 扩展、类型别名与可见性控制
- cjpm 包管理与多文件项目组织
- 文件与目录 IO
- JSON 处理(结合 stdx 扩展库)
- 网络编程入门
- 单元测试(本文)
- 并发基础:线程的创建与等待
- Channel 通道与同步原语
- 并发实战:多线程任务处理
- 实战一:带文件持久化的命令行小工具
- 实战二:GeoJSON 数据处理程序
一、单元测试到底解决什么问题
先看没有测试的日子是怎么过的。假设你写了一个函数:
func grade(score: Int64): String {
if (score >= 90) {
return "优秀"
} else if (score >= 60) {
return "及格"
}
return "不及格"
}
验证它对不对,土办法是在 main 里写几行 println(grade(95)),瞪大眼睛看输出。问题很明显:
- 慢:函数一多,人肉对答案对到眼花;
- 不可回归:下周你改了
grade的边界(90 改成 91 算优秀),上次手动验证的结论全部作废,得重来一遍; - 容易漏:边界值(0、59、60、89、90、100)最容易出错,也最容易被忘掉。
单元测试的思路是:把"期望"写成代码。调用 grade(95),断言结果必须等于 "优秀"——对不对由机器判断,不对就红字报出来。以后每次改代码,跑一遍测试,全部通过才敢放心。这就是"自动化考卷":题目(用例)你出,阅卷(断言)机器来。
仓颉标准库内置了 std.unittest,不需要安装任何第三方库,cjpm 自带 test 子命令,开箱即用。
二、第一个测试:三个新面孔
单元测试的骨架只有三个新元素,先跑起来再说。
用 cjpm init 建一个工程,把 src/main.cj 改成:
package probe
import std.unittest.*
import std.unittest.testmacro.*
@Test
class MathTest {
@TestCase
func testAdd(): Unit {
@Assert(2 + 3, 5)
}
@TestCase
func testBool(): Unit {
@Assert(1 < 2)
}
}
main(): Int64 {
return 0
}
三个新面孔:
@Test:标在 class 上,表示这是一个测试套件(Test Suite),里面装着一组测试用例;@TestCase:标在套件内的成员函数上,每个@TestCase就是一个独立用例;@Assert:断言宏。@Assert(2 + 3, 5)表示"左边算出来必须等于右边";@Assert(1 < 2)表示"这个布尔表达式必须为 true"。断言不成立,用例就判失败。
⚠️ 两个 import 缺一不可。
@Test/@TestCase/@Assert这些宏来自std.unittest.testmacro,只写import std.unittest.*会报error: undeclared identifier 'Test'。另外注意断言写法是@Assert(...)宏调用,不存在Assert.eq(...)这种静态方法写法,写了会报error: undeclared identifier 'Assert'(本课 FAQ 有完整报错)。
运行测试用 cjpm test(不是 cjpm run):
PS> cjpm test
输出:
--------------------------------------------------------------------------------------------------
TP: probe, time elapsed: 17591200 ns, RESULT:
TCS: MathTest, time elapsed: 17591200 ns, RESULT:
[ PASSED ] CASE: testAdd (305300 ns)
[ PASSED ] CASE: testBool (51800 ns)
Summary: TOTAL: 2
PASSED: 2, SKIPPED: 0, ERROR: 0
FAILED: 0
--------------------------------------------------------------------------------------------------
cjpm test success
📌 报告里
ns是纳秒耗时,每次运行数字都不同,不用管;要看的是PASSED: 2, FAILED: 0——2 个用例全过。
逐行读这份报告:
TP(Test Package):哪个包,probe就是我们的工程;TCS(Test Case Suite):哪个测试套件,MathTest;CASE:具体用例,[ PASSED ]通过;Summary:总数、通过、跳过、出错、失败;- 最后
cjpm test success表示整个工程绿灯。
测试代码和 main 可以住在同一个包里,cjpm run 照常运行业务代码、cjpm test 跑测试,互不影响。
三、断言家族:Assert、Expect 与 Fail
3.1 @Assert 的两种形态
@Assert(布尔表达式) // 形态一:表达式必须为 true
@Assert(实际值, 期望值) // 形态二:两者必须相等
形态二最常用,失败时报告会贴心地打印两边各是什么。故意写一个错的:
@Test
class FailDemo {
@TestCase
func wrong(): Unit {
@Assert(2 + 2, 5)
println("这行不会执行")
}
}
cjpm test 输出(节选,耗时数字每次不同):
[ FAILED ] CASE: wrong (191900 ns)
Assert Failed: `(2 + 2 == 5)`
left: 4
right: 5
Summary: TOTAL: 1
PASSED: 0, SKIPPED: 0, ERROR: 0
FAILED: 1, listed below:
TCS: FailDemo, CASE: wrong
Error: cjpm test failed
关键信息:
Assert Failed: (2 + 2 == 5)告诉你哪条断言挂了;left: 4 / right: 5告诉你实际算出 4、期望是 5——排查时先看这两行;- 整个
cjpm test的退出码变成非 0(Error: cjpm test failed),意味着可以接进 CI 脚本,测试不过就拦下发布; - 注意
println("这行不会执行")真的没有执行——@Assert失败会立刻中止当前用例,这是它和@Expect的核心区别,马上讲。
3.2 @Expect:失败了也继续跑
有时候一个用例里有多条断言,你希望"全部跑完再一起算账",而不是第一条挂了就停。用 @Expect,参数和 @Assert 完全一样:
@Test
class ExpectDemo {
@TestCase
func twoFailures(): Unit {
@Expect(1 + 1, 3)
println("Expect 失败后这行仍会执行")
@Expect("a", "b")
}
}
输出(节选):
[ FAILED ] CASE: twoFailures (205600 ns)
Expect Failed: `(1 + 1 == 3)`
left: 2
right: 3
Expect Failed: "a" != "b"
"a": "a"
"b": "b"
STDOUT:
Expect 失败后这行仍会执行
两条 @Expect 都失败了、都报了出来,中间的 println 也执行了(被收进 STDOUT 段)。
📌 怎么选:后面的断言依赖前面的结果(比如先断言数组非空、再取下标)用
@Assert,一挂就停避免连环报错;各条断言互相独立、想一次看全,用@Expect。
3.3 @Fail:主动判死刑
走到某条分支就说明逻辑错了,直接 @Fail("描述") 判失败:
@Test
class FailDemo2 {
@TestCase
func mustBeEven(): Unit {
let v = 42
if (v % 2 != 0) {
@Fail("v 必须是偶数,实际是 ${v}")
}
@Expect(v % 2, 0)
}
}
@Fail 一旦执行,当前用例直接判 FAILED 并中止(语义和 @Assert 失败一样)。
四、测试"该抛的异常抛没抛"
第 10 课学了异常。有些函数的正确行为恰恰就是抛异常——比如端口解析函数收到 "abc" 应该抛 IllegalArgumentException。用 @AssertThrows[异常类型](表达式):
import std.convert.*
func parsePort(s: String): Int64 {
let n = Int64.tryParse(s)
if (n.isNone()) {
throw IllegalArgumentException("端口必须是数字: ${s}")
}
let port = n.getOrThrow()
if (port < 0 || port > 65535) {
throw IllegalArgumentException("端口超出范围: ${port}")
}
return port
}
@Test
class ParsePortTest {
@TestCase
func rejectsGarbage(): Unit {
@AssertThrows[IllegalArgumentException](parsePort("abc"))
}
@TestCase
func rejectsOutOfRange(): Unit {
@AssertThrows[IllegalArgumentException](parsePort("70000"))
}
@TestCase
func acceptsValid(): Unit {
@Assert(parsePort("8080"), 8080)
}
}
语义:括号里的表达式必须抛出指定类型的异常,用例才算通过;没抛、或抛了别的类型,都算失败。
⚠️ 小提示:
@AssertThrows里要放一个会执行到的调用(如parsePort("abc")),不要直接内联写throw Exception(...)——宏展开后编译器会报unreachable expression警告(实测过)。把抛异常的动作包进函数再调,干干净净。另外
Int64.tryParse来自std.convert包(第 21 课讲过按包导入),记得import std.convert.*。
还有一个孪生宏 @ExpectThrows,区别与 @Expect 相同:失败后不中止,继续跑后面的语句。
五、套件的组织:BeforeEach、AfterEach 与 Skip
5.1 @BeforeEach / @AfterEach:每个用例的前后各跑一次
测试常常需要准备环境(建个空列表、连个临时文件),跑完再收拾。标 @BeforeEach 的函数会在每个 @TestCase 之前执行,@AfterEach 在之后执行:
import std.collection.* // ArrayList 在这个包里,别漏
@Test
class CartTest {
var items = ArrayList<String>()
@BeforeEach
func setUp(): Unit {
// 关键:重新赋一个空列表,而不是 add——套件实例会被复用(见下方实测细节)
items = ArrayList<String>()
items.add("默认商品")
println("setUp,当前数量: ${items.size}")
}
@AfterEach
func tearDown(): Unit {
println("tearDown")
}
@TestCase
func startsWithOne(): Unit {
@Assert(items.size, 1)
}
@TestCase
func addOne(): Unit {
items.add("苹果")
@Assert(items.size, 2)
}
}
跑一下(节选):
[ PASSED ] CASE: startsWithOne (403500 ns)
[ PASSED ] CASE: addOne (25100 ns)
Summary: TOTAL: 2
PASSED: 2, SKIPPED: 0, ERROR: 0
两个用例都过了。关键在 setUp 第一行的 items = ArrayList<String>()——它保证每个用例拿到的都是"只有一件默认商品"的干净列表。
📌 实测踩坑(1.2.0):测试套件的实例是被复用的,不是每个用例 new 一个。笔者最初把
setUp写成只items.add("默认商品")不重置,结果第二个用例addOne真的红了:[ FAILED ] CASE: addOne (151600 ns) Assert Failed: `(items.size == 2)` left: 3 right: 2原因:
startsWithOne先跑,add 了一件"默认商品";轮到addOne,实例没换、items还是那个列表,setUp又 add 一件变成 2,用例再 add"苹果"就成了 3。所以准备环境的正确姿势是在@BeforeEach里把字段重新赋值成干净状态(清空或重建),而不是只做"累加"。字段也因此要声明成var。记住一句话:每个用例从零开始,环境重置写在@BeforeEach里,别指望构造函数每个用例跑一次。
对应的还有 @BeforeAll / @AfterAll(整个套件只跑一次),本课先记住名字,用到再查。
5.2 @Skip:暂时禁用一个用例
某个功能还没实现完,测试先写好了,但不想让它天天飘红。标 @Skip:
@Test
class SkipDemo {
@Skip
@TestCase
func notReadyYet(): Unit {
@Assert(false) // 写了也不会跑
}
}
报告里会显示 SKIPPED: 1——跳过不算失败,但它出现在 Summary 里提醒你别真忘了。
⚠️ 注意顺序:
@Skip写在@TestCase上面。也别试@TestCase[skip: true]这种写法,宏会直接展开失败。
六、参数化测试:一组数据跑同一个用例
边界测试最烦的是:同一个函数要喂 6 个值,难道写 6 个用例?不用。@TestCase 支持参数化:
func isPass(score: Int64): Bool {
return score >= 60
}
@Test
class ParamTest {
@TestCase[x in (0..5)]
func squareNotSmaller(x: Int64): Unit {
@Assert(x * x >= x)
}
@TestCase[y in [60, 75, 89]]
func allPass(y: Int64): Unit {
@Assert(isPass(y))
}
}
@TestCase[x in (0..5)]:x依次取 0、1、2、3、4(区间左闭右开,第 7 课),跑 5 轮;@TestCase[y in [60, 75, 89]]:数组字面量也行,跑 3 轮。
报告里它们各算一个 CASE(内部跑多轮),任意一轮断言失败整个用例判失败。
⚠️ 语法细节:区间要加括号
(0..5);参数名必须是单个标识符,@TestCase[(x, y) in ...]这种元组写法实测会被宏拒绝(报Left part of the parameter must consist of a single identifier)。多组数据就在用例内部用循环喂。
七、CIDE 实操:给 grade 函数配完整考卷
把本课知识串起来。场景:第 24 课风格的"成绩评级"函数,要求:
- 90~100 → “优秀”
- 60~89 → “及格”
- 0~59 → “不及格”
- 超出 0~100 → 抛
IllegalArgumentException
在 CIDE 里 cjpm init 新建工程 gradeapp,src/main.cj 完整代码:
package gradeapp
import std.unittest.*
import std.unittest.testmacro.*
func grade(score: Int64): String {
if (score < 0 || score > 100) {
throw IllegalArgumentException("分数必须在 0-100 之间: ${score}")
}
if (score >= 90) {
return "优秀"
} else if (score >= 60) {
return "及格"
}
return "不及格"
}
@Test
class GradeTest {
@TestCase
func excellent(): Unit {
@Assert(grade(90), "优秀")
@Assert(grade(100), "优秀")
}
@TestCase
func pass(): Unit {
@Assert(grade(60), "及格")
@Assert(grade(89), "及格")
}
@TestCase
func fail(): Unit {
@Assert(grade(0), "不及格")
@Assert(grade(59), "不及格")
}
@TestCase
func outOfRange(): Unit {
@AssertThrows[IllegalArgumentException](grade(-1))
@AssertThrows[IllegalArgumentException](grade(101))
}
@TestCase[x in [0, 59, 60, 89, 90, 100]]
func boundary(x: Int64): Unit {
// 边界值不抛异常就算胜利
grade(x)
}
}
main(): Int64 {
println(grade(85))
return 0
}
设计思路值得说说:
- 边界值各测两边:59/60、89/90、0/100 这些"刀口"最容易写错(
>=还是>?),所以用例里全是边界; - 异常也进考卷:
outOfRange保证越界输入抛的是预期的异常类型; - 参数化兜底:
boundary一口气喂 6 个边界值,只要有一个抛了不该抛的异常就会暴露; - main 照常保留:
cjpm run运行业务、cjpm test跑测试,一个工程两用。
终端跑 cjpm test(耗时数字每次不同):
--------------------------------------------------------------------------------------------------
TP: gradeapp, time elapsed: 20052400 ns, RESULT:
TCS: GradeTest, time elapsed: 20052400 ns, RESULT:
[ PASSED ] CASE: excellent (1954800 ns)
[ PASSED ] CASE: pass (66100 ns)
[ PASSED ] CASE: fail (115800 ns)
[ PASSED ] CASE: outOfRange (1359400 ns)
[ PASSED ] CASE: boundary (155400 ns)
Summary: TOTAL: 5
PASSED: 5, SKIPPED: 0, ERROR: 0
FAILED: 0
--------------------------------------------------------------------------------------------------
cjpm test success
cjpm run 则正常输出 及格。现在故意使坏:把 score >= 90 改成 score > 90,再 cjpm test——excellent 和 boundary 立刻变红,报告直指 grade(90) 实际返回 "及格"。这就是回归测试的价值:改动一秒内现形。
八、常见问题 FAQ
Q1:只写 import std.unittest.*,为什么报 undeclared identifier 'Test'?
@Test / @TestCase / @Assert 是宏,定义在 std.unittest.testmacro 包里,必须显式 import std.unittest.testmacro.*。两个 import 都写,是仓颉单元测试的固定开头。
Q2:为什么不能写 Assert.eq(a, b)?
仓颉的断言是宏调用 @Assert(a, b),不存在 Assert 这个类。写了 Assert.eq(...) 会报 error: undeclared identifier 'Assert'(1.2.0 实测)。记住:看见 @ 就是宏,宏的语义在编译期展开。
Q3:测试必须和业务代码放一个包吗?
同包最方便(能直接测 internal 成员),也是本课的用法。工程变大后可以建独立测试目录,cjpm test 会对整个模块的所有包执行测试;cjpm test <包路径> 只跑指定包。
Q4:浮点数怎么断言相等?
别直接 @Assert(0.1 + 0.2, 0.3)——二进制浮点的经典坑,这个表达式在几乎所有语言里都不相等。用区间断言代替:
let v = 0.1 + 0.2
@Assert(v > 0.29 && v < 0.31)
Q5:测试用例的执行顺序能保证吗?
不要依赖顺序。每个用例应该是独立的:环境靠 @BeforeEach 准备,不蹭上一个用例的产出。这样哪天框架并行执行(@Parallel 宏)也不会翻车。
Q6:cjpm test 和 cjpm run 会打架吗?
不会。cjpm run 只编译运行业务入口(main),cjpm test 编译并执行所有 @Test 套件。测试类对 cjpm run 来说就是普通 class,不影响。
九、课后练习
- 写一个函数
maxOf(a: Int64, b: Int64): Int64返回较大值,配一个@Test套件覆盖三种情况:a 大、b 大、相等。跑cjpm test确认全绿。 - 写一个
safeDiv(a: Float64, b: Float64): Float64,除数为 0 时抛IllegalArgumentException。用@AssertThrows测除数为 0 的情况,再用区间断言测10.0 / 4.0约等于 2.5。 - 给第 16 课的
Counter(increment()/decrement()/count属性)写测试。用@BeforeEach保证每个用例拿到的都是新计数器,覆盖:初始为 0、increment 后 +1、decrement 后 -1。 - 故意把一个通过的断言改错(比如
@Assert(2 + 2, 4)改成@Assert(2 + 2, 5)),观察失败报告的left/right与退出码;再把@Assert换成@Expect,在它后面加一条println,体会"中止"与"继续"的区别。改回去,恢复全绿。 - 综合:写一个栈
class IntStack(push(v)、pop(): Int64空栈抛异常、size只读属性),配完整测试:push/pop 往返、size 变化、空栈 pop 抛异常,外加一个参数化用例连续 push 0…10 后验证 size。全部通过为止。
下节预告
至此"工程化与标准库"模块收官:cjpm、文件 IO、JSON、网络、测试都齐了。从第 26 课开始进并发编程:仓颉的线程怎么创建、spawn 出来的执行体和主线程什么关系、join 怎么等它跑完——先有线程,后面才谈得上 Channel 通信和并发实战。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
- 打开页面后进入 发行版(Releases),两种包任选其一:
- 安装版:下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable):下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版:下载
- 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
更多推荐




所有评论(0)