《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 收集缺口按当时官方文档口径。