news 2026/9/19 11:28:59

Ray Data 测试编写指南:如何写出稳定、不脆弱的测试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Ray Data 测试编写指南:如何写出稳定、不脆弱的测试

Ray Data 测试编写指南:如何写出稳定、不脆弱的测试

【免费下载链接】rayRay is an AI compute engine. Ray consists of a core distributed runtime and a set of AI Libraries for accelerating ML workloads.项目地址: https://gitcode.com/gh_mirrors/ra/ray

导读

Ray Data 是 Ray 分布式运行时之上构建的弹性数据集与数据预处理层(核心代码位于 python/ray/data)。随着算子、执行计划与块(Block)切分逻辑持续演进,测试一旦把实现细节"焊死"(如假设输出顺序、断言 repr 字符串、锁定 block 数量),就会在无关改动出现时莫名失败,拖慢每一个 PR。本篇指南基于 Ray Data 官方测试编写文档,系统讲解如何为 Ray Data 编写稳定、可维护、非脆弱(non-flaky)的测试:从单元测试与 fixture 的选择,到共享集群 fixture、rows_same对比工具、schema 断言等一系列 Ray 特定实践。读完你将能写出既快速反馈又不容易被实现细节变化击碎的测试用例,并理解每条建议背后的源码依据。

::: {note} 软件工程中没有放之四海而皆准的硬性规则,下面这些建议需要结合具体场景自行判断(原文亦作此声明)。它们的目标是让测试支撑Ray Data 项目,而不是拖慢它。 :::

为什么脆弱测试会拖慢开发

脆弱(Flaky/Brittle)测试的本质是:当一些本不该被测试关心的假设发生偏移时,测试就失败。例如数据集的行序发生了重排、repr 格式调整、block 数量随集群资源配置变化——这些变化对业务逻辑毫无影响,却会让测试红灯,导致开发者被与自身改动无关的失败卡住。

Ray Data 的测试量级很大(仅 python/ray/data/tests 下就有数百个测试文件,加上参数化组合),任何一处因实现细节而脆弱的断言都会放大维护成本。因此编写测试时应当优先断言接口契约(schema、行内容、行数、语义结果),而不是断言实现形态(repr、block 数量、算子名称、执行计划结构)。

通用良好实践

优先写单元测试,而非集成测试

单元测试反馈更快、定位问题更容易:

  • 运行耗时是毫秒级而不是秒级;
  • 不依赖 Ray 集群、外部系统或时间/时序;
  • 让整个测试套件保持快速、可靠、易维护。

Ray Data 项目为单元测试单独建立了目录 python/ray/data/tests/unit,其中已包含大量针对纯逻辑模块的测试,例如:

  • test_block.py、test_block_boundaries.py:block 切分与边界逻辑;
  • test_auto_batch_size.py、test_throughput_solver.py:批大小推算与吞吐求解器;
  • test_logical_plan.py、test_ruleset.py:逻辑计划与优化规则;
  • test_fifo_bundle_queue.py、test_reordering_bundle_queue.py:执行调度队列。

这些测试不启动集群即可验证核心算法,正是"单元测试优先"的实践样板。

使用 Fixture,而不是 try-finally

Fixture 让测试更干净、可复用、隔离性更好,尤其适合处理monkeypatch这类需要还原的场景。try-finally虽然可行,但 fixture 意图更清晰,还能消除样板代码。

DataContext配置的还原为例。DataContext是 Ray Data 的全局执行上下文(定义于 python/ray/data/context.py 附近的 context 模块),测试中常需要临时修改其配置,测试结束后再恢复。

原始写法(手动 try-finally 还原):

def test_dynamic_block_split(ray_start_regular_shared): ctx = ray.data.context.DataContext.get_current() original_target_max_block_size = ctx.target_max_block_size ctx.target_max_block_size = 1 try: ... finally: ctx.target_max_block_size = original_target_max_block_size

推荐写法(使用restore_data_contextfixture):

def test_dynamic_block_split(ray_start_regular_shared, restore_data_context): ctx = ray.data.context.DataContext.get_current() target_max_block_size = ctx.target_max_block_size ... # 无需再写 try-finally

restore_data_context是 Ray Data 测试套件提供的标准 fixture,定义在 python/ray/data/tests/conftest.py:

@pytest.fixture def restore_data_context(request): """Restore any DataContext changes after the test runs""" ctx = ray.data.context.DataContext.get_current() original = copy.deepcopy(ctx) yield ctx ray.data.context.DataContext._set_current(original)

它的做法是:在测试开始前deepcopy当前DataContext,测试结束后把副本设回全局上下文——无论测试过程中改了多少配置(如target_max_block_sizepreserve_order、tensor 格式等),都会自动还原,无需手动维护现场。该 fixture 在 python/ray/data/tests 下被大量测试引用(如 test_dynamic_block_split、test_map、test_join 等),并可作为组合式 fixture 的基础(例如 conftest.py 中的tensor_format_context就组合了restore_data_contexttensor_format)。

Ray 特定实践

不要假设 Dataset 的输出顺序

除非在DataContext中显式设置preserve_order=True,否则 Ray Data不保证输出顺序。从源码结构看,preserve_order会影响执行计划的算子融合、分块与合并方式(相关字段出现在 execution_options.py、map_operator.py、output_splitter.py 等执行层模块中),并行执行下行的顺序天然是随机的。因此,依赖顺序的测试如果不显式要求preserve_order,就是在为脆弱失败埋雷。

原始写法(假设多个 parquet 分片拼接后与期望顺序一致):

ds_dfs = [] for path in os.listdir(out_path): assert path.startswith("data_") and path.endswith(".parquet") ds_dfs.append(pd.read_parquet(os.path.join(out_path, path))) ds_df = pd.concat(ds_dfs).reset_index(drop=True) df = pd.concat([df1, df2]).reset_index(drop=True) assert ds_df.equals(df)

推荐写法(用rows_same忽略顺序与索引地比较):

from ray.data._internal.util import rows_same actual_data = pd.read_parquet(out_path) expected_data = pd.concat([df1, df2]) assert rows_same(actual_data, expected_data)

::: {tip} 优先使用 python/ray/data/_internal/util.py 提供的ray.data._internal.util.rows_same工具函数,它专门用于"忽略索引与行顺序地比较两个 pandas DataFrame 是否包含相同行",正是为 Ray Data 这种不保证行序的接口设计的。 :::

rows_same的源码实现也值得一看,其核心逻辑是:

def rows_same(actual: pd.DataFrame, expected: pd.DataFrame) -> bool: """Check if two DataFrames have the same rows. Unlike the built-in pandas equals method, this function ignores indices and the order of rows. This is useful for testing Ray Data because its interface doesn't usually guarantee the order of rows. """ if len(actual) != len(expected): return False if len(actual) == 0: return True pd.testing.assert_frame_equal( _sort_df(actual).reset_index(drop=True), _sort_df(expected).reset_index(drop=True), check_dtype=False, ) return True

即:先比行数(不相等直接返回False),再对两表按列排序(_sort_df还处理了不可哈希类型的排序代理列)并重置索引后,用pd.testing.assert_frame_equal对比,从而把"行顺序与索引"这两个 Ray Data 不承诺的维度完全消除。

优先使用共享集群 Fixture

优先使用共享集群 fixture(如ray_start_regular_shared),而不是隔离集群 fixture(如shutdown_onlyray_start_regular)。

shutdown_onlyray_start_regular会在每个测试结束后重启 Ray 集群。启动/关闭 Ray 可能耗时 1 秒以上——单看很小,但在数千个测试(叠加参数化)的规模下,累加效应非常可观。

在 python/ray/tests/conftest.py 中可以清楚看到两者的 scope 差异:

  • shutdown_only(L557-L563):普通函数级 fixture,teardown 时执行ray.shutdown()
  • ray_start_regular(L651-L655):普通函数级 fixture,每个测试独立ray.init/ray.shutdown
  • ray_start_regular_shared(L668-L672):scope="module"整个测试模块只启动一次Ray,全部测试共享。

原始写法(每个参数化组合都重启集群):

@pytest.mark.parametrize("concurrency", [-1, 1.5], ids=["negative", "float"]) def test_invalid_concurrency_raises(shutdown_only, concurrency): ds = ray.data.range(1) # Each parametrization restarts the Ray cluster! with pytest.raises(ValueError): ds.map(lambda row: row, concurrency=concurrency)

推荐写法(参数化组合复用同一个集群):

@pytest.mark.parametrize("concurrency", [-1, 1.5], ids=["negative", "float"]) def test_invalid_concurrency_raises(ray_start_regular_shared, concurrency): ds = ray.data.range(1) # Each parametrization reuses the same Ray cluster. with pytest.raises(ValueError): ds.map(lambda row: row, concurrency=concurrency)

::: {note} 这里存在隔离性与速度之间的固有取舍。对 Ray Data 的绝大多数测试而言,项目选择了优先保证速度:只有当你的测试确实需要一个全新集群时(例如验证集群启动行为、GCS 故障恢复、集群级资源分配等场景),才使用隔离集群 fixture。此外ray_start_regular_shared还支持通过request.param注入集群参数,需要更多资源时可参考ray_start_regular_shared_2_cpus(python/ray/tests/conftest.py#L675-L679)这类变体。 :::

避免用 repr 输出验证具体数据

repr输出不属于任何接口契约,随时可能改变。而且针对 repr 的断言常常掩盖了真实意图:你真正想检查的是数据本身,还是"它恰好打印成了什么样"?请对自己关心的内容做显式断言。

原始写法(断言整个 Dataset 的字符串表示):

assert str(ds) == "Dataset(num_rows=6, schema={one: int64, two: string})", ds

推荐写法(分别断言 schema 与行数):

assert ds.schema() == Schema(pa.schema({"one": pa.int64(), "two": pa.string()})) assert ds.count() == 6

这样即使将来 Dataset 的 repr 格式调整(比如增加进度信息、改变字段排版),测试依然稳定,同时意图也更明确:schema 是接口契约的一部分,行数是可以直接验证的语义属性。

避免假设 block 的数量或大小

除非你在测试repartition这类 API,否则不要把测试锁定在特定的 block 数量或大小上——这两者会随实现或集群配置变化,而这种变化通常是合理的(例如动态块切分target_max_block_size调整后,block 数量自然会变)。

原始写法(断言初始 block 数量):

ds = ray.data.read_parquet(paths + [txt_path], filesystem=fs) assert ds._plan.initial_num_blocks() == 2 # Where does 2 come from? assert rows_same(ds.to_pandas(), expected_data)

推荐写法(删除对 block 数量的魔法数断言,只断言语义结果):

ds = ray.data.read_parquet(paths + [txt_path], filesystem=fs) # Assertion about number of blocks has been removed. assert rows_same(ds.to_pandas(), expected_data)

原始写法(同时断言每个 block 的行数分布):

ds2 = ds.repartition(5) assert ds2._plan.initial_num_blocks() == 5 assert ds2._block_num_rows() == [10, 10, 0, 0, 0] # Magic numbers?

推荐写法(只验证 repartition 的目标块数这一契约):

ds2 = ds.repartition(5) assert sum(len(bundle.blocks) for bundle in ds.iter_internal_ref_bundles()) == 5 # Assertion about the number of rows in each block has been removed.

两点注意:

  1. 魔法数(如[10, 10, 0, 0, 0])会让后来的维护者困惑"这个数字从哪来",一旦切分策略微调就崩溃;
  2. 只有repartition这种以目标块数为语义契约的 API,才值得断言块数本身;iter_internal_ref_bundles()遍历的是执行期实际的 RefBundle 集合,块数对它而言是可观测的真实状态。

避免断言执行计划(DAG)长成特定样子

执行计划中的算子会随实现演进而调整。除非你专门在测试优化规则或工作在算子层,否则测试不应期望特定的 DAG 结构(例如某个算子名叫FromArrow)。

原始写法(通过 stats 字符串和逻辑计划算子名间接断言实现细节):

# Check that metadata fetch is included in stats. assert "FromArrow" in ds.stats() # Underlying implementation uses `FromArrow` operator assert ds._plan._logical_plan.dag.name == "FromArrow"

推荐写法(删除这类断言):

# (Assertions removed).

背后的原因:ds.stats()的字符串内容、逻辑计划中算子节点的名称与层级,都属于内部实现(对应 python/ray/data/_internal/logical 与 python/ray/data/_internal/execution 下的算子和执行器实现)。Ray Data 的算子融合、算子重命名、调度器改动都可能让这类断言失守。如果确实需要验证"元数据是否被拉取"这类行为,应改用可观察的外部契约(如最终数据的正确性、执行时间统计的数值语义),而不是算子名。

综合示例:把实践落到一个真实测试上

将上述实践组合起来,一个"稳定优先"的 Ray Data 测试应当同时满足:

  • 能用单元测试覆盖的纯逻辑就放 python/ray/data/tests/unit,不启动集群;
  • 必须用到 Ray 时,优先ray_start_regular_shared共享集群,并叠加restore_data_context等 fixture 自动还原上下文;
  • 数据对比一律用rows_same(忽略顺序与索引),schema 用Schema(...)显式断言,行数用count()
  • 不写对 repr、block 数量/大小、算子名与 DAG 结构的断言。
import pytest import pyarrow as pa import ray from ray.data._internal.util import rows_same def test_map_preserves_rows(ray_start_regular_shared, restore_data_context): ds = ray.data.range(6).map(lambda row: {"value": row["id"] * 2}) assert ds.schema() == Schema(pa.schema({"id": pa.int64(), "value": pa.int64()})) assert ds.count() == 6 assert rows_same(ds.to_pandas(), expected)

检查清单

写完后可以用下面这份清单自查(同时对应原文的元描述:"避免假设输出顺序,不要依赖 block 数量或 repr 输出"):

  • 能用单元测试覆盖的,是否放进了 python/ray/data/tests/unit,且不依赖 Ray 集群?
  • 是否用 fixture 管理 setup/teardown(如restore_data_context),而不是手写 try-finally?
  • 没有preserve_order=True时,是否避免了对输出顺序的假设,并改用rows_same比较?
  • 是否优先使用了共享集群 fixtureray_start_regular_shared,而不是每个参数化都重启集群?
  • 是否没有用str(ds)/ repr 断言数据内容,而是显式断言schema()count()等契约?
  • 是否没有对 block 数量/大小(initial_num_blocks_block_num_rows等)做魔法数断言,除非测试对象就是repartition
  • 是否没有通过ds.stats()字符串或_plan._logical_plan断言 DAG/算子的具体形态?

遵循这些实践,你的测试将与 Ray Data 的接口契约同频,而不是与实现细节捆绑——这样测试套件才能长期保持快速、稳定,真正支撑项目而非拖慢它。更完整的官方说明可继续阅读 Ray Data 测试编写文档 及 python/ray/data/tests 下的真实测试用例。

【免费下载链接】rayRay is an AI compute engine. Ray consists of a core distributed runtime and a set of AI Libraries for accelerating ML workloads.项目地址: https://gitcode.com/gh_mirrors/ra/ray

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 11:27:40

BrewUI:给Homebrew套上可视化外衣,让软件管理更轻松

如果你手里有一台 Mac,并且习惯用终端装软件,那你大概率知道 Homebrew 这个名字。但如果你刚接触 macOS,或者平时不太爱碰命令行,那一串brew install、brew update、brew cleanup敲下来,确实容易劝退。BrewUI 就是冲着…

作者头像 李华
网站建设 2026/9/19 11:26:17

SUMO智能网联车仿真:Python驱动的确定性交通流建模

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 11:24:18

区块链扩容技术:Rollup原理与Rust实践

1. 为什么我们需要区块链扩容?区块链技术发展到今天,性能瓶颈已经成为制约其大规模应用的主要障碍。以太坊主网每秒只能处理15-45笔交易,这个数字在传统金融系统面前简直微不足道。我去年参与的一个DeFi项目就因为网络拥堵导致用户支付了高达…

作者头像 李华
网站建设 2026/9/19 11:21:45

iPhone零App投屏电脑:Mac原生与Windows接收端设置详解

想把手里的iPhone画面投到电脑上,很多人第一反应就是去App Store翻投屏软件。其实大多数场景下,手机端完全可以保持零安装,真正卡住你的反而是电脑端——你的电脑到底站在哪条协议阵营里、有没有开启对应的接收端。这句话我放到最前面&#x…

作者头像 李华
网站建设 2026/9/19 11:19:34

TP4056锂电池充电管理芯片从原理到STM32工程实践全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华