marimo 笔记本测试实战:pytest 单元测试、doctest 文档测试与测试执行机制解析
【免费下载链接】marimoA reactive notebook for Python — run reproducible experiments, query with SQL, execute as a script, deploy as an app, and version with git. Stored as pure Python. All in a modern, AI-native editor.项目地址: https://gitcode.com/GitHub_Trending/ma/marimo
本文基于 marimo 仓库中的examples/testing测试示例目录,系统讲解两种在 marimo 笔记本中编写与运行测试的完整方案:用pytest对笔记本做单元测试,以及用 Python 标准库doctest对 docstring 代码片段做文档测试。读完本文,你将掌握测试笔记本的两种写法、测试单元(cell)的识别规则、pytest fixture 在 marimo 中的使用边界,以及 marimo 编辑器内"响应式自动跑测试"这一行为的源码级实现机制。
为什么 marimo 笔记本可以被常规工具测试
marimo 笔记本与普通 Python 脚本的本质区别只在于它使用了marimo.App()与@app.cell/@app.function装饰器来组织代码——它本身仍然是一个纯 Python 文件。这带来一个直接结论:所有面向 Python 的测试工具(pytest、doctest、coverage、lint 工具)都可以原样作用于笔记本文件,不需要额外的测试框架转换层。
examples/testing目录下的 README(examples/testing/README.md)正是围绕这一点给出两个基础示例:
| 方案 | 示例文件 | 运行方式 |
|---|---|---|
| 用 pytest 测试笔记本 | test_with_pytest.py | pytest test_with_pytest.py |
| 用 doctest 测试 docstring 代码片段 | running_doctests.py | 作为笔记本直接运行 |
官方文档 docs/guides/testing/index.md 对这两类测试的总结是:"Because marimo notebooks are stored as Python, test them like any other Python program."(因为 marimo 笔记本以 Python 形式存储,像测试任何 Python 程序一样测试它们即可)。
方案一:用 pytest 测试笔记本
最小可运行示例
完整阅读 examples/testing/test_with_pytest.py,其全部内容如下:
import marimo __generated_with = "0.23.9" app = marimo.App() @app.function def inc(x): return x + 1 @app.cell def test_answer(): assert inc(3) == 5, "This test fails" return @app.cell def test_sanity(): assert inc(3) == 4, "This test passes" return if __name__ == "__main__": app.run()这个文件演示了三个关键写法:
- 被测对象放在
@app.function中。inc是一个由 marimo 管理的纯函数,测试 cell 可以直接引用它,无需关心 import 与依赖; - 测试 cell 以
test_前缀命名 cell 函数。cell 名test_answer与test_sanity使这两个 cell 被识别为测试单元; - 一个 cell 里写一个断言。失败与通过的测试各占一个 cell,便于定位。
在命令行执行:
pytest test_with_pytest.py预期结果:test_sanity通过,test_answer失败(断言inc(3) == 5不成立),pytest 会给出标准的失败报告。这正是 docs/guides/testing/pytest.md 中"Testing at the command-line"一节的用法——对任何笔记本文件直接pytest my_notebook.py,pytest 会自动发现其中所有名字以test_开头的 cell,或仅包含test_函数与Test类的 cell。
测试 cell 的识别规则
marimo 对"哪些 cell 是测试 cell"有明确而保守的判定,来自官方 pytest 指南:
- 名字以
test_开头的cell 函数被视为测试; - cell整体只包含测试代码(名字以
test_开头的函数、名字以Test开头的类、或带@pytest.fixture装饰的函数)时,该 cell 会被测试运行器执行; - 如果 cell 里混入了其他内容(辅助函数、常量、变量、import 等),该 cell 会被跳过——官方建议把辅助函数移到单独的 cell。
test_with_pytest.py中的两个测试 cell 恰好都只含assert语句,因此会被完整执行;而@app.function def inc(x)所在 cell 不是测试,它只负责提供被测函数。
编辑器内的响应式测试(reactive tests)
除了命令行,marimo 在编辑模式下还会自动发现并执行笔记本内的测试。这一行为的开关是配置项runtime.reactive_tests,其默认值为True——见 marimo/_config/config.py 中 runtime 默认配置块:
"reactive_tests": True,该配置项的作用说明(同文件 docstring):
reactive_tests: ifTrue, marimo will automatically run pytest on cells containing only test functions and test classes.
如果不需要编辑器自动跑测试,可在配置文件中关闭runtime.reactive_tests。
从源码结构看,这条"响应式测试"链路的挂载点在 marimo/_runtime/kernel_lifecycle.py 的_build_hooks中:
if is_edit_mode and user_config["runtime"].get("reactive_tests", False): hooks.add_post_execution(attempt_pytest, Priority.LATE)也就是说,只有编辑模式(is_edit_mode)下才会注册attempt_pytest这个"cell 执行后"钩子(定义于 marimo/_runtime/runner/hooks_post_execution.py);marimo run(非编辑模式)不会触发自动测试。当某个测试 cell 重新执行后,marimo 会调用 pytest 对该 cell 内容做一轮测试,并把结果呈现回编辑器界面。
底层实现:marimo 如何把 cell "骗"过 pytest
pytest 通过静态扫描模块来收集测试,它看到的笔记本文件里只有@app.cell装饰的函数,而不是真正的可收集测试对象。marimo 在 marimo/_ast/pytest.py 中用 AST 改写的方式解决了这个问题,核心机制包括:
1. 顶层test_cell 的签名重写(wrap_fn_for_pytest)
当 cell 本身以test_开头时,marimo 解析 cell 函数的源码 AST,生成一个"桩函数"(scaffold):
# 生成结果的示意形态 def name_of_fn_passed(vars_ending_in_fixture, ...) -> Any: return cell(vars_ending_in_fixture=vars_ending_in_fixture)这个桩函数把 cell 的参数中以_fixture结尾的名字视为 pytest fixture 注入点(见 marimo/_ast/pytest.py 的wrap_fn_for_pytest:fixtures = [arg for arg in args if arg.endswith("_fixture")])。这样 pytest 在收集时看到的是一个签名为(fixture_name,)的普通测试函数,调用它时才会真正执行对应 cell 的__call__。
2. 多测试 cell 的类封装(build_test_class)
如果一个测试 cell 内部定义了多个测试函数或测试类,marimo 会在模块层动态构造一个名为MarimoTestBlock_<n>的类(常量MARIMO_TEST_STUB_NAME = "MarimoTestBlock",见 marimo/_ast/pytest.py),把 cell 内的每个test_函数包装为staticmethod、每个Test*类封装为嵌套测试类,并注入到笔记本模块的命名空间中供 pytest 收集。官方文档示例的输出中就出现了这种形态:
test_notebook.py::MarimoTestBlock_0::test_parameterized[3-4] PASSED test_notebook.py::MarimoTestBlock_0::test_parameterized[4-5] PASSEDbuild_test_class的 docstring 特别说明了一个鲁棒性设计:每个测试都是符号化解析的,某个测试定义即使写错了,也不会破坏整个测试套件的定义或阻止套件运行。
3. fixture 的解析方式
marimo 支持@pytest.fixture与@pytest.mark.parametrize等装饰器(is_pytest_decorator、has_fixture_decorator等函数通过 AST 识别@pytest.*装饰器)。但存在一条重要限制(来自官方 pytest 指南的 "Fixture Limitations"):
在一个 cell 中定义的 fixture 不能被另一个 cell 中的测试使用。
原因是 pytest 收集测试是静态的:它只解析笔记本文件、不执行。因此 pytest 只能看到模块级 fixture(来自conftest.py或 import 的模块)以及与被测测试同作用域定义的 fixture。marimo 的 cell 执行顺序由运行时依赖图决定,静态分析无法得知哪些 fixture 会可用;而且仅为发现 fixture 就完整运行一遍笔记本代价过高。官方推荐的两种写法:
- 在笔记本的setup cell(
with app.setup:)中 import fixture,使其进入模块级作用域; - 或者把 fixture 与被测测试放在同一个 cell中(如 class-scope fixture 与
test_方法定义在同一 cell 内); - 最通用的做法仍是使用标准的
conftest.py。
如果 fixture 装饰器在求值时失败,marimo 会让对应测试抛出ValueError,并提示 "Consider exposing relevant variables in app.setup"(见 marimo/_ast/pytest.py 的_make_fails)。
命令行 pytest 与响应式测试的分工总结
| 维度 | 命令行pytest my_notebook.py | 编辑器响应式测试 |
|---|---|---|
| 触发方式 | 手动执行 pytest | cell 执行后自动触发(编辑模式 +reactive_tests=True) |
| 覆盖范围 | 全部test_cell 与纯测试 cell | 最近变更/执行的测试 cell |
| 适用场景 | CI、完整回归 | 开发时即时反馈 |
方案二:用 doctest 测试 docstring 代码片段
对于带文档字符串的函数,Python 标准库 doctest 指向的完整示例是 examples/testing/running_doctests.py:
import marimo __generated_with = "0.19.7" app = marimo.App(width="medium") @app.cell def _(): import marimo as mo return (mo,) @app.function def euclid_mcd(a: int, b: int) -> int: """Return the MCD between positive a, b. >>> euclid_mcd(42, 24) 6 >>> euclid_mcd(24, 42) 6 >>> euclid_mcd(42, 42) 42 """ assert a > 0 assert b > 0 if a < b: a, b = b, a if (a != b): r = a - b return euclid_mcd(b, r) return a @app.cell def _(mo): # Include a reference to each function to test euclid_mcd import doctest failures, success = doctest.testmod(verbose=True) mo.md(f"Success: {success}, Failures: {failures}") return if __name__ == "__main__": app.run()这个示例有三个值得注意的技术点:
- 被测函数放在
@app.function中,docstring 内写>>>交互示例。euclid_mcd(欧几里得求最大公约数)的 docstring 包含 3 组输入/输出对,覆盖非对称参数(42, 24)与(24, 42)以及相等情况(42, 42); - 运行 doctest 的 cell 必须显式引用被测函数。注意
# Include a reference to each function to test注释下单独一行写着euclid_mcd——marimo 的 cell 依赖分析基于源码中出现的名字,若 cell 不引用euclid_mcd,该 cell 与函数之间就没有依赖边,doctest 运行时可能拿不到这个函数。把对函数的显式引用作为依赖锚点,是 marimo 笔记本中"使用跨 cell 定义对象"的通用技巧; - 测试结果回显为 Markdown。
doctest.testmod(verbose=True)返回(failures, success)计数,示例通过mo.md(...)把Success: N, Failures: M渲染到笔记本界面,失败的片段会以**test**形式逐条打印在标准错误流中。
由于笔记本就是 Python 程序,你也可以在外部直接对该文件跑python -m doctest风格的检查,或者把doctest.testmod放进一个test_cell 里交给 pytest 收集——两种方式都成立。
小结与延伸阅读
marimo 的测试策略可以概括为一句话:不造新框架,复用 Python 生态既有工具。
- 单元/集成测试:给 cell 命名
test_*,用 pytest 从命令行或编辑器响应式钩子执行;底层由 marimo/_ast/pytest.py 的 AST 桩函数与MarimoTestBlock类封装完成对 pytest 收集器的适配; - 文档测试:用
doctest.testmod直接验证@app.function的 docstring 示例; - 开关与默认值:
runtime.reactive_tests默认True,可在配置中关闭编辑器的自动测试行为; - 注意事项:纯测试 cell 与混合 cell 的识别差异、跨 cell fixture 不可见的静态收集限制。
相关仓库路径索引:
| 路径 | 内容 |
|---|---|
| examples/testing/README.md | 本文主参考:两个测试示例的说明 |
| examples/testing/test_with_pytest.py | pytest 测试笔记本示例 |
| examples/testing/running_doctests.py | doctest 文档测试示例 |
| docs/guides/testing/pytest.md | pytest 指南:识别规则、fixture 限制、完整输出示例 |
| docs/guides/testing/doctest.md | doctest 指南 |
| marimo/_ast/pytest.py | 测试 cell → pytest 收集对象的 AST 转换实现 |
| marimo/_runtime/kernel_lifecycle.py | 响应式测试钩子attempt_pytest的注册点 |
| marimo/_config/config.py | reactive_tests默认值 |
【免费下载链接】marimoA reactive notebook for Python — run reproducible experiments, query with SQL, execute as a script, deploy as an app, and version with git. Stored as pure Python. All in a modern, AI-native editor.项目地址: https://gitcode.com/GitHub_Trending/ma/marimo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考