ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

marimo 笔记本测试实战:编辑器内响应式测试与 pytest 命令行全集成

2026/9/13 17:09:18 拓冰建站 浏览量
marimo 笔记本测试实战:编辑器内响应式测试与 pytest 命令行全集成 marimo 笔记本测试实战编辑器内响应式测试与 pytest 命令行全集成【免费下载链接】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一个以纯 Python 文件存储的响应式笔记本的两大测试路径在编辑器内响应式运行测试依赖 pytest自动发现并执行测试单元格与在命令行用标准 pytest 跑整个笔记本。文中完整保留原文档的测试单元格写法、可运行的示例笔记本与 pytest 输出并结合 marimo/_ast/pytest.py、marimo/_runtime/pytest.py 等源码说明测试如何被识别、被注入、被执行的底层机制。读完后你可以在 marimo 中编写可响应式执行的测试单元格、将其纳入标准 pytest 测试套件并正确处理 fixture 的作用域限制。marimo 如何识别测试单元格在展开两种测试方式之前先明确 marimo 的测试发现规则这是两类能力共同的基础当环境中安装了可选依赖pytest时marimo 会自动发现并执行笔记本内部的测试判定对象是以test_开头的函数、以Test开头的类、带pytest.fixture装饰器的函数如果一个单元格混杂了其他内容辅助函数、常量、变量、import 等该单元格会被测试运行器跳过——官方建议把辅助函数移到其他单元格。从源码结构看这套约定落在两个层面编译期打标。marimo 编译器在构造单元格时写入_test_allowed标志且以test_开头本身就是判据之一见 marimo/_ast/compiler.pyreturn Cell( _namef.__name__, _cellcell, _test_allowedcell._test or f.__name__.startswith(test_), ... )收集期伪装。Cell类暴露了一个__test__属性——这正是 pytest/nose 等框架约定用于模块级测试发现的钩子见 marimo/_ast/cell.py# The property __test__ is picked up by nose and pytest. # We have the compiler mark if the cell name starts with test_ # _or_, is comprised of only tests; allowing for test suites to # collect this cell. property def __test__(self) - bool: return self._test_allowed而单元格内含多个测试函数/测试类的复杂情况则由 marimo/_ast/pytest.py 中的process_for_pytest处理若单元格名以test_开头则原地重写该函数签名供 pytest 收集否则调用build_test_class构造一个名为MarimoTestBlock_N的桩类常量MARIMO_TEST_STUB_NAME MarimoTestBlock见 marimo/_ast/pytest.py并把桩类注入到笔记本模块自身的命名空间中让 pytest 的模块级收集能找到它。后文命令行示例输出中的MarimoTestBlock_0::test_parameterized[3-4]正是这个桩类机制的产物。在笔记本内响应式运行测试基础用法只要pytest已安装编辑模式下执行一个纯测试单元格marimo 就会自动对其中定义的测试跑一遍 pytest 并把结果输出到控制台。例如app.cell def __(): import pytest def inc(x): return x 1 return inc, pytest app.cell def __(inc, pytest): class TestBlock: staticmethod def test_fails(): assert inc(3) 5, This test fails staticmethod def test_sanity(): assert inc(3) 4, This test passes pytest.mark.parametrize((x, y), [(3, 4), (4, 5)]) def test_parameterized(x, y): assert inc(x) y return注意第二个单元格中inc是跨单元格依赖响应式测试会在单元格执行后基于当前数据流运行 pytest因此依赖上游单元格的测试天然可行。仓库中也提供了一个可直接运行的示例笔记本 examples/testing/test_with_pytest.py其中test_answer、test_sanity两个命名单元格分别演示失败与通过两种断言。关闭响应式测试该行为由配置项runtime.reactive_tests控制marimo 配置文件中runtime段下的reactive_tests布尔键默认值为True见 marimo/_config/config.pyTrue默认marimo 自动对仅含测试函数与测试类的单元格运行 pytestFalse单元格照常执行但不再自动触发 pytest。从源码看这一开关在 kernel 启动时决定是否挂接测试钩子见 marimo/_runtime/kernel_lifecycle.pyhooks create_default_hooks() if is_edit_mode and user_config[runtime].get(reactive_tests, False): hooks.add_post_execution(attempt_pytest, Priority.LATE)即响应式测试仅在编辑模式下生效且作为一个后置执行钩子挂接。钩子本体 marimo/_runtime/runner/hooks_post_execution.py 中kernel_tracer.start_as_current_span(run_pytest) def attempt_pytest(cell, ctx, run_result): if cell._test: try: import marimo._runtime.pytest as marimo_pytest if ctx.execution_context is not None: with ctx.execution_context(cell.cell_id): result marimo_pytest.run_pytest(cell.defs, ctx.glbls) if result.output: sys.stdout.write(result.output) except ImportError: pass这里有两个实现细节值得注意cell._test复用了上文编译期的打标结果ImportError被静默吞掉印证了文档pytest 为可选依赖的说法——未安装 pytest 时单元格照常执行只是不会有测试输出。真正干活的run_pytestmarimo/_runtime/pytest.py会在调用前用DependencyManager.pytest.require(...)强制校验 pytest 可用。在命令行使用 pytest 测试笔记本由于 marimo 笔记本本身就是合法的 Python 程序app marimo.App() 一组app.cell装饰的函数可以直接用 pytest 测试框架对其进行测试pytest test_notebook.py这会执行并测试所有名称以test_开头的命名单元格以及只包含test_函数和Test类的单元格规则与笔记本内测试一致。完整示例对如下的test_notebook.py运行pytest# content of test_notebook.py import marimo __generated_with 0.10.6 app marimo.App() app.cell def _(): def inc(x): return x 1 return (inc,) app.cell def test_fails(inc): assert inc(3) 5, This test fails app.cell def test_sanity(inc): assert inc(3) 4, This test passes app.cell def collection_of_tests(inc, pytest): pytest.mark.parametrize((x, y), [(3, 4), (4, 5)]) def test_answer(x, y): assert inc(x) y, These tests should pass. app.cell def imports(): import pytest return pytest会输出 test session starts platform linux -- Python 3.12.9, pytest-8.3.5, pluggy-1.5.0 rootdir: /notebooks configfile: pyproject.toml collected 4 items test_notebook.py::test_fails FAILED [ 25%] test_notebook.py::test_sanity PASSED [ 50%] test_notebook.py::MarimoTestBlock_0::test_parameterized[3-4] PASSED [ 75%] test_notebook.py::MarimoTestBlock_0::test_parameterized[4-5] PASSED [100%] FAILURES __________________________________ test_fails __________________________________ # content of test_notebook.py import marimo __generated_with 0.10.6 app marimo.App() app.cell def _(): def inc(x): return x 1 return (inc,) app.cell def test_fails(inc): assert inc(3) 5, This test fails E AssertionError: This test fails E assert 4 5 E where 4 function inc(3) test_notebook.py:17: AssertionError short test summary info FAILED test_notebook.py::test_fails - AssertionError: This test fails 1 failed, 3 passed in 0.82s 三个值得关注的收集结果test_fails、test_sanity以单元格即测试的方式被直接收集——它们本身就是顶层test_函数collection_of_tests这个混合命名的单元格其内部的参数化测试test_answer被包装进桩类MarimoTestBlock_0后以两条参数用例收集imports单元格既不以test_开头、也不只含测试代码因此完全被跳过。单元格命名与套件集成给单元格命名在笔记本文件中把单元格函数命名如def test_sanity(inc):或在笔记本编辑器的单元格操作菜单中命名纳入标准测试套件把测试笔记本命名为test_*.py放进你的常规测试目录pytest 会像发现普通测试模块一样自动发现它们。此外也可以编写自包含自带单元测试的普通笔记本直接pytest my_notebook.py运行。从源码看单元格即测试能成立的关键是签名重写。wrap_fn_for_pytestmarimo/_ast/pytest.py会把单元格函数的输入参数从 pytest 视角抹掉def wrap_fn_for_pytest(func: Fn, cell: Cell) - Callable[..., Any]: func_ast ast_parse(inspect.getsource(func)) ... args {arg.arg: arg for arg in func_body.args.args} fixtures [arg for arg in args if arg.endswith(_fixture)] reserved set(args.keys()) - set(fixtures) # The remaining expected attributes are needed to ensure attribute count # matches. cell._pytest_reserved reserved return build_stub_fn(func_body, inspect.getfile(func), cell.__call__, fixtures)build_stub_fnmarimo/_ast/pytest.py基于一个 AST 模板动态生成桩函数其签名保留参数名让 pytest 不把它们当 fixture函数体则变成return cell(varsvars)形式的调用——按源码注释的原话这是足够骗过 pytestsufficient to fool pytest的做法pytest 收集并调用桩函数时实际上是触发了对应单元格连同其依赖闭包的运行。这也是为什么失败断言的 traceback 仍能指回笔记本源文件行号。使用 Pytest Fixturesmarimo 支持 pytest fixtures但有一个重要限制在某个单元格中定义的 fixture不能在另一个单元格的测试中使用——除非该 fixture 定义在setup 单元格中。因此官方建议在笔记本的 setup 单元格中定义或 importfixture或把 fixture 放进 pytest 的conftest.py。示例一setup 单元格中定义或导入fixture# test_notebook.py import marimo app marimo.App() with app.setup: from fixtures import db_connection, sample_data app.cell def _(sample_data): def test_data_loaded(sample_data): assert len(sample_data) 0示例二fixture 与测试同处一个单元格app.cell def _(): import pytest return pytest app.cell def _(pytest): pytest.fixture def temp_file(): import tempfile with tempfile.NamedTemporaryFile() as f: yield f def test_writes_to_file(temp_file): temp_file.write(bhello) temp_file.seek(0) assert temp_file.read() bhello示例三类 fixtureapp.cell def _(): import pytest return pytest app.cell def _(pytest): class TestDatabase: pytest.fixture(scopeclass) def connection(self): return create_connection() def test_query(self, connection): result connection.query(SELECT 1) assert result 1conftest.py中的 fixture 则完全按 pytest 的常规行为工作——pytest 会自动发现它们。为什么 fixture 不能跨单元格这一限制源于 pytest 的静态收集机制pytest 在收集阶段解析笔记本文件但不执行它。收集期间pytest 能看到两类 fixture——conftest.py或已导入模块提供的模块级 fixture以及与测试处于同一作用域内定义的 fixture但其他单元格中定义的 fixture 在收集时根本不存在于命名空间。为什么不为收集而执行整个笔记本原因有二其一仅为发现 fixture 就运行全部单元格代价高昂其二静态分析无法确定单元格执行后哪些变量会可用因为单元格的执行顺序是由 marimo 的依赖图在运行时决定的而非文件中书写的顺序。从源码可以印证同一单元格内可用是如何实现的_build_hookmarimo/_ast/pytest.py会为同单元格内的 fixture 函数构造一个延迟执行钩子——钩子被 pytest 调用时才触发对应单元格的run()取出单元格定义后再调用真正的 fixture/测试体同时_eval_fixture_decoratormarimo/_ast/pytest.py会重新求值并套用pytest.fixture装饰器含scope等参数从而支持上文示例三中的scopeclass等语义。装饰器求值失败时不会中断套件收集而是返回一个显式报错的钩子提示考虑把相关变量暴露在app.setup中见 marimo/_ast/pytest.py 的_make_fails。行为验证仓库内的配套测试marimo 自身用测试保障了上述机制的正确性可作为行为依据进一步深入tests/_ast/test_pytest.py验证单元格到 pytest 测试/桩类的编译转换即process_for_pytest、build_test_class的行为tests/_ast/test_pytest_scoped.py 与 tests/_ast/test_pytest_toplevel.py分别覆盖作用域内测试与顶层test_*函数的收集场景tests/_runtime/test_pytest_runtime.py验证运行时编辑模式下响应式 pytest 钩子的行为。结合 examples/testing/test_with_pytest.py 这份可运行的示例笔记本可以在本地完整复现本文的命令线路径pytest examples/testing/test_with_pytest.py预期得到test_sanity通过、test_answer失败的结果与本文命令行示例的输出形态一致。小结marimo 把笔记本与测试统一在同一份 Python 文件上提供了两套互补的能力能力触发方式判定规则实现入口编辑器内响应式测试单元格执行后自动触发编辑模式纯测试内容的单元格runtime.reactive_tests true默认kernel_lifecycle.py → hooks_post_execution.py命令行 pytestpytest test_notebook.py或纳入标准套件命名test_*的单元格 仅含test_/Test/fixture 的单元格marimo/_ast/pytest.py实践要点测试与辅助代码分单元格存放fixture 放在 setup 单元格或conftest.py给测试单元格起test_前缀的名字以便在 CI 中以标准 pytest 方式运行。【免费下载链接】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),仅供参考