Mojo 测试起步:testing 模块与用例组织

《Mojo 测试起步:testing 模块与用例组织》

要点

Mojo 的测试生态还很年轻:没有 pytest 式的框架,官方给的是标准库里的 testing 模块——一组会抛 Error 的断言函数,配上命令行一个 mojo test。工具少反而是把基本功学全的机会:断言怎么选、用例怎么组织、SIMD 和浮点这类数值场景怎么断言,这篇都走一遍,最后给实战篇的矩阵乘法补一组数值断言。

本文不做:性能基准(那是实战篇计时骨架的事,测试管”对不对”,不管”多快”)、doctest 的展开用法(一句带过)、GPU 代码的数值验证(等 GPU 那篇再说)。口径:Mojo 25.5。

第一条用例

testing 模块的断言全是普通函数:assert_equal、assert_not_equal、assert_true、assert_false,失败时抛 Error。测试就是一个以 test_ 开头的普通函数,标记 raises:

from testing import assert_equal, assert_raises

fn divide(a: Float64, b: Float64) raises -> Float64:
    if b == 0:
        raise Error("除零")
    return a / b

fn test_divide() raises:
    assert_equal(divide(10.0, 4.0), 2.5)

fn test_divide_by_zero() raises:
    with assert_raises():
        _ = divide(1.0, 0.0)

存成 test_divide.mojo,用 mojo run test_divide.mojo 跑。预期输出:什么都没有——断言通过时是安静的,命令退出码为 0。这一点和多数框架一样,”没有消息就是好消息”。想看”真的跑了”,在用例末尾加一行 print 兜底,或者直接进入下一步:故意弄红。

故意弄红一次

把 assert_equal(divide(10.0, 4.0), 2.5) 改成 2.4 再跑。预期:终端打出 Error,信息里带两侧的值与断言位置,进程以非零码退出——具体文案随版本变,见红就行。更重要的行为要记牢:assert 系是快败语义,第一处失败抛 Error 就终结整个进程,同一函数里后面的断言不会执行。所以 Mojo 的用例惯例是一条用例管一件事,宁可拆细,让每次失败都指向唯一原因。

组织与运行

单文件 mojo run 适合起步,正式一点用 mojo test:它按给定路径收集 test_ 开头的函数并批量执行。项目里我留一个 tests/ 目录放 *_test.mojo,跑 mojo test tests/。两个组织层面的坑提前说:

  • 包的公开面只有 __init__.mojo:测试文件 import 包的内部模块,内部模块必须先在 __init__.mojo 里 re-export,否则 import 直接报”找不到”。给包补测试前先检查这一步,很多”测试跑不起来”其实卡在可见性上。
  • mojo test 的收集有已知缺口:比如包内 doctest 的收集在当时的版本里不完整。我的分工是:断言测试自己写、文档示例单独管,不指望工具替我收齐。

所以我的最小结构是三件套:src/ 放实现,tests/ 放 *_test.mojo,跑法固定 mojo test tests/。能跑 green 的最低标准是:tests/ 下每条 test_ 函数都被执行过——故意在某条里留一个失败断言跑一遍红,确认收集链路是通的,再改回来。命名跟随函数语义而不是编号:test_divide_by_zero 一眼看懂要测什么,test_case_003 这种名字在失败信息里一文不值——快败体系里,名字就是你排错时的第一手线索。

SIMD 与参数化场景

Mojo 的招牌是 SIMD,测试就得回答”向量怎么断言”。核心原则一条:浮点永远不进 assert_equal,进 assert_almost_equal——它按容差比较,浮点的重排序误差、编译器融合都是物理规律,别拿精确相等跟它们较劲。向量常用两种写法:

from testing import assert_almost_equal, assert_true

fn test_vec_sum() raises:
    var v = SIMD[DType.float64, 4](1.0, 2.0, 3.0, 4.0)
    assert_almost_equal(v.reduce_add(), 10.0)

fn test_vec_lanes() raises:
    var v = SIMD[DType.float64, 4](1.0, 2.0, 3.0, 4.0)
    @parameter
    for i in range(4):
        assert_almost_equal(v[i], Float64(i) + 1.0)

reduce_add 把向量归约成标量再断言,适合总量校验;逐 lane 断言用 v[i] 拆开,定位哪一路算错时更好用。@parameter for 是编译期展开——循环体按 lane 复制,i 是编译期常量;只有循环体需要编译期值(比如按宽度换 SIMD 类型)时才必须用它,普通数据表用普通 for 就够。Mojo 没有 pytest 式 parametrize,数据驱动就两种写法:普通 for 遍历数据表,或 @parameter for 展开编译期参数,别再找第三种。

跑测写进工程脚本

实战篇的实验环境走 pixi/magic 工作流,测试别游离在外:工程脚本里固定一条 pixi run mojo test tests/,README 的”怎么验证”一节照抄这条命令。CI 一段带过:GitHub Actions 里装好环境后跑同一条命令,退出码非零即失败——Mojo 测试的 CI 就这么朴素,没有报告插件也够用,反正断言快败,失败信息会原样打在日志里。

失败输出怎么读

快败语义下,读失败输出的顺序是固定的:Error 信息里先找测试文件名与行号,定位到 assert 那一行;再比对两侧的值——assert 系会把参与比较的值带出来,差的量级一眼可见;最后看这是第几个用例,快败意味着它之前的一定全过、之后的没跑,评估波及面时别把”没跑”当成”也挂了”。一个防呆习惯:新用例先跑红再跑绿,确认断言的方向没有写反——assert 系写反了往往表现为”永远绿”,比报错难发现得多。

给 matmul 补数值断言

实战篇的四步演进(朴素、重排、并行、SIMD)每步都声称”结果一致”,验证靠的是主程序里的 checksum 与 max_abs_err 打印。现在把这套验证固化成用例。实战篇的 Matrix 用 fill 灌随机值、没有单元素 setter——补测试时我给 Matrix 加了一个 set(r, c, v):UnsafePointer 上 store 一行的事,写法照实战篇的 alloc/load/store。这是测试倒逼 API 的典型一步:公开面多一个小口子,换来可断言的输入。第一个用例,手算已知解:

from testing import assert_almost_equal

fn test_matmul_known_case() raises:
    var a = Matrix(2, 2)
    var b = Matrix(2, 2)
    a.set(0, 0, 1.0)
    a.set(0, 1, 2.0)
    a.set(1, 0, 3.0)
    a.set(1, 1, 4.0)
    b.set(0, 0, 5.0)
    b.set(0, 1, 6.0)
    b.set(1, 0, 7.0)
    b.set(1, 1, 8.0)

    var out = Matrix(2, 2)
    zero(out)
    matmul_simd(a, b, out)

    1. 手算:[19 22; 43 50]
    assert_almost_equal(out.get(0, 0), 19.0)
    assert_almost_equal(out.get(0, 1), 22.0)
    assert_almost_equal(out.get(1, 0), 43.0)
    assert_almost_equal(out.get(1, 1), 50.0)

第二个用例,把加速版对齐基准版——这是数值优化最该写的一条测试:

fn test_simd_matches_naive() raises:
    var N = 128
    var a = Matrix(N, N)
    var b = Matrix(N, N)
    fill(a)
    fill(b)

    var expect = Matrix(N, N)
    zero(expect)
    matmul_naive(a, b, expect)

    var out = Matrix(N, N)
    zero(out)
    matmul_simd(a, b, out)

    var max_err = 0.0
    for r in range(N):
        for c in range(N):
            var e = abs(out.get(r, c) - expect.get(r, c))
            if e > max_err:
                max_err = e
    assert_true(max_err < 1e-5)

容差按 dtype 收:Float32 口径 1e-5 量级起步,改成 Float64 后可以收紧到 1e-9 左右再观察。矩阵规模别一上来就 256 以上——测试要的是几秒内跑完的反馈,大矩阵留给性能实验。

换 dtype 复测一遍

同一份算法换精度是数值代码的常规复测项。做法是把”灌数据—双版本对算—取 max_err”抽成一个流程函数,两个用例各调一次,精度差异全落在容差常量上:

fn simd_vs_naive_max_err[N: Int]() raises -> Float64:
    ...  # 主体就是 test_simd_matches_naive:灌数据、双版本对算、返回 max_err

fn test_f32_baseline() raises:
    assert_true(simd_vs_naive_max_err[128]() < 1e-5)

fn test_f64_baseline() raises:
    assert_true(simd_vs_naive_max_err[128]() < 1e-9)

如实说一步坑:实战篇的 Matrix 是按单一浮点类型写死的,真要做双精度复测,得先把 Matrix 的元素类型提成编译期参数——这是一次真实的重构,工作量不大但动手顺序有讲究:先把 dtype 提参数、跑通 Float32 用例,再开 Float64。Float32 通过、Float64 失败,多半说明算法里有中途精度损失(比如先用 32 位累加再写回 64 位),这类问题手测很难发现,容差分层一测一个准。容差常量直接写在用例里,不搞配置化。

验收清单

  • 至少 3 条用例:一个正常路径、一个 assert_raises 的错误路径、一个浮点 assert_almost_equal,mojo test tests/ 全绿。
  • 故意弄红过一次,见过 Error 输出与非零退出码,并确认同一函数内后续断言不再执行。
  • tests/ 里每条 test_ 函数都被收集执行过(用一条临时失败断言验证过收集链路)。
  • SIMD 用例至少两种断言各一条:reduce_add 归约、逐 lane 拆开。
  • matmul 补上已知解与 simd-对齐-朴素两条数值用例,容差按 dtype 明确写出。

测试在系列里的位置:实战篇给的是实验纪律与计时骨架,这篇把”结果一致”从人肉打印升级为自动断言;整个 Mojo 栈的排布与下一步(GPU 编程),看学习路线对应阶段。

本文数据核对时间:2025-08-06:Mojo 按 25.5 口径书写(25.5.0 于 2025-08 上旬发布,上一版 25.4 为 2025-06-18);testing 模块 assert_* 断言族(失败即抛 Error 并终止)为官方标准库口径;mojo test 的用例收集、包可见性(__init__.mojo re-export)与包内 doctest 收集缺口按当时官方文档口径。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇