【仓颉语言入门 · 第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~20struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展
五、工程化与标准库21~25cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试
六、并发编程26~28线程、Channel 通道与同步原语、并发实战
七、项目实战29~30命令行小工具、GeoJSON 数据处理实战
  1. 环境搭建与第一个仓颉程序
  2. 变量与常量:let / var 与基本数据类型
  3. 运算符与标准输入输出
  4. 分支结构:if 与 match 表达式
  5. 循环结构:while / for / Range
  6. 字符串详解与字符串插值
  7. 数组 Array 与区间 Range
  8. 集合框架:ArrayList、HashMap、HashSet
  9. 可空类型 ? 与 Option
  10. 错误处理:异常机制与 Result
  11. 函数定义、参数与返回值
  12. Lambda 与高阶函数
  13. 闭包、作用域与函数类型
  14. 迭代器 Iterator 与 Sequence
  15. 结构体 struct 与类 class
  16. 构造函数、属性与方法
  17. 接口 interface 与实现
  18. 枚举 enum、代数数据类型与 match 模式匹配
  19. 泛型编程
  20. 扩展、类型别名与可见性控制
  21. cjpm 包管理与多文件项目组织
  22. 文件与目录 IO
  23. JSON 处理(结合 stdx 扩展库)
  24. 网络编程入门
  25. 单元测试(本文)
  26. 并发基础:线程的创建与等待
  27. Channel 通道与同步原语
  28. 并发实战:多线程任务处理
  29. 实战一:带文件持久化的命令行小工具
  30. 实战二: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,不影响。


九、课后练习

  1. 写一个函数 maxOf(a: Int64, b: Int64): Int64 返回较大值,配一个 @Test 套件覆盖三种情况:a 大、b 大、相等。跑 cjpm test 确认全绿。
  2. 写一个 safeDiv(a: Float64, b: Float64): Float64,除数为 0 时抛 IllegalArgumentException。用 @AssertThrows 测除数为 0 的情况,再用区间断言测 10.0 / 4.0 约等于 2.5。
  3. 给第 16 课的 Counter(increment() / decrement() / count 属性)写测试。用 @BeforeEach 保证每个用例拿到的都是新计数器,覆盖:初始为 0、increment 后 +1、decrement 后 -1。
  4. 故意把一个通过的断言改错(比如 @Assert(2 + 2, 4) 改成 @Assert(2 + 2, 5)),观察失败报告的 left / right 与退出码;再把 @Assert 换成 @Expect,在它后面加一条 println,体会"中止"与"继续"的区别。改回去,恢复全绿。
  5. 综合:写一个栈 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
Logo

一站式 AI 云服务平台

更多推荐