欢迎光临
我们一直在努力

【仓颉语言入门 · 第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)
    未经允许不得转载:171主机测评 » 【仓颉语言入门 · 第25课】
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址