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-finallyrestore_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_size、preserve_order、tensor 格式等),都会自动还原,无需手动维护现场。该 fixture 在 python/ray/data/tests 下被大量测试引用(如 test_dynamic_block_split、test_map、test_join 等),并可作为组合式 fixture 的基础(例如 conftest.py 中的tensor_format_context就组合了restore_data_context与tensor_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_only、ray_start_regular)。
shutdown_only和ray_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.两点注意:
- 魔法数(如
[10, 10, 0, 0, 0])会让后来的维护者困惑"这个数字从哪来",一旦切分策略微调就崩溃; - 只有
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比较? - 是否优先使用了共享集群 fixture
ray_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),仅供参考