12 种 Pandas 测试技巧,让数据处理少踩坑
12 种测试实践 —— fixtures、schemas、property-based tests、snapshots、performance guards —— 每周能省不少排查问题的时间
Pandas 的 bug 有个特点,就是不会在控制台里大喊大叫,而是悄悄藏在 dtype 转换、索引操作、时区处理的某个角落,或者那种跑十万次才能复现一次的边界条件。所以如果你想找到和定位这种隐藏的BUG就需要一套相对简洁的测试手段能把大部分坑提前暴露出来。
下面这 12 个策略是实际项目里反复使用的测试方法,能让数据处理代码变得比较靠谱。
1) 用 Pytest Fixtures 做 DataFrame 工厂
弄几个小巧的 fixture "工厂"来生成样例数据,这样setup 代码会少写很多,测试逻辑反而能写更充分。
# conftest.py
import pandas as pd
import numpy as np
import pytest
@pytest.fixture
def sales_df():
return pd.DataFrame({
"order_id": [1, 2, 3],
"country": ["IN", "US", "IN"],
"amount": [99.0, 149.5, np.nan],
"ts": pd.to_datetime(["2025-09-01", "2025-09-02", "2025-09-02"])
})
然后在哪里都能直接用:
def test_revenue_total(sales_df):
assert sales_df["amount"].sum(skipna=True) == 248.5
一个标准样本反复使用,减少重复代码,测试的样例也更容易读懂。
2) Schema 层来约束数据
Dtype 会漂,列也可能会丢。所以加个 schema 检查,这样违规的数据在边界就会被暴露出来。用 pandera
这类工具也行,或者自己写个轻量检查:
def assert_schema(df, expected):
# expected: dict[column] -> dtype string, e.g. {"order_id": "int64", ...}
assert set(df.columns) == set(expected), "Columns mismatch"
for c, dt in expected.items():
assert str(df[c].dtype) == dt, f"{c} dtype mismatch: {df[c].dtype} != {dt}"
def test_schema(sales_df):
assert_schema(sales_df, {
"order_id": "int64",
"country": "object",
"amount": "float64",
"ts": "datetime64[ns]"
})
数据结构变化能第一时间发现,不会传到转换逻辑深处才暴露。
3) Property-Based Testing 检查不变量
有些规则应该对任意输入都成立,比如归一化之后总和还是 1。所以可以用 Hypothesis 自动生成各种输入来验证:
from hypothesis import given, strategies as st
import pandas as pd
import numpy as np
@given(st.lists(st.floats(allow_nan=False, width=32), min_size=1, max_size=50))
def test_normalize_preserves_sum(xs):
s = pd.Series(xs, dtype="float32")
total = float(s.sum())
if total == 0: # define behavior on zero-sum
return
normalized = s / total
assert np.isclose(float(normalized.sum()), 1.0, atol=1e-6)
一个测试用例能自动覆盖几十种形状、数值范围和边界情况。
4) 参数化测试把边缘 case 都列出来
有一些经典的麻烦场景:空 DataFrame、单行数据、重复索引、全 null 列、混时区。
import pytest
import pandas as pd
import numpy as np
@pytest.mark.parametrize("df", [
pd.DataFrame(columns=["a","b"]),
pd.DataFrame({"a":[1], "b":[np.nan]}),
pd.DataFrame({"a":[1,1], "b":[2,2]}).set_index("a"),
])
def test_transform_handles_edges(df):
out = df.assign(b=df.get("b", pd.Series(dtype=float)).fillna(0.0))
assert "b" in out
一次性把健壮性锁定,以后就不用反复调同样的边界问题了。
5) Golden Snapshot + 校验和来固定输出
复杂输出可以存个"黄金样本",CI 里对比校验和。如果输出变了会直接报错。
import pandas as pd
import hashlib
def df_digest(df: pd.DataFrame) -> str:
b = df.sort_index(axis=1).to_csv(index=False).encode()
return hashlib.md5(b).hexdigest()
def test_output_snapshot(tmp_path, sales_df):
out = (sales_df
.assign(day=sales_df["ts"].dt.date)
.groupby(["country","day"], as_index=False)["amount"].sum())
expected = pd.read_parquet("tests/golden/agg.parquet")
assert df_digest(out) == df_digest(expected)
6) 固定随机数和时间
如果转换依赖随机或者当前时间,得把种子钉死。
import numpy as np
import pandas as pd
from datetime import datetime
def stratified_sample(df, frac, rng):
return df.groupby("country", group_keys=False).apply(lambda g: g.sample(frac=frac, random_state=rng))
def test_sample_is_deterministic(sales_df):
rng = np.random.default_rng(42)
a = stratified_sample(sales_df, 0.5, rng)
rng = np.random.default_rng(42)
b = stratified_sample(sales_df, 0.5, rng)
pd.testing.assert_frame_equal(a.sort_index(), b.sort_index())
这个没什么说的,模型训练的时候也要固定随机种子
7) 明确测试 NA 的语义
NaN
、None
、pd.NA
在不同操作下行为差异挺大的,这时候需要把预期行为显式写出来:
import pandas as pd
import numpy as np
def test_na_logic():
s = pd.Series([1, np.nan, 3])
s2 = s.fillna(0)
assert s2.isna().sum() == 0
assert s2.iloc[1] == 0
NA 相关的 bug 经常藏在 groupby、merge 和数学运算里,得当成一等公民来测。
8) 索引、排序、唯一性约束
函数如果保证"索引稳定"就测索引,依赖排序就断言排序状态。
def is_monotonic(df, column):
return df[column].is_monotonic_increasing
def test_index_and_sort(sales_df):
out = sales_df.sort_values(["ts","order_id"]).set_index("order_id")
assert out.index.is_unique
assert is_monotonic(out.reset_index(), "ts")
很多逻辑错误其实就是顺序错了或者不小心有重复。
9) 双实现交叉验证
聪明的向量化逻辑可以用"慢但一看就懂"的实现来验证:
import pandas as pd
def vectorized_net(df):
return df.assign(net=df["amount"] - df["amount"].mean())
def slow_net(df):
mean = df["amount"].mean()
df = df.copy()
df["net"] = df["amount"].apply(lambda x: x - mean)
return df
def test_equivalence(sales_df):
a = vectorized_net(sales_df)
b = slow_net(sales_df)
pd.testing.assert_series_equal(a["net"], b["net"], check_names=False)
防止向量化实现出现细微错误,同时保持性能优势。
10) 性能预算作为冒烟测试
不需要精确的 benchmark,设个大概的护栏就够了。用小规模代表性数据跑一下,给个时间上限:
import time
import pandas as pd
def test_runs_fast_enough(sales_df):
small = pd.concat([sales_df]*2000, ignore_index=True) # ~6k rows
t0 = time.perf_counter()
_ = small.groupby("country", as_index=False)["amount"].sum()
dt = time.perf_counter() - t0
assert dt < 0.25 # budget for CI environment
11) I/O 往返保证
CSV、Parquet、Arrow 格式往返可能会改类型,测一下关心的部分:
import pandas as pd
import numpy as np
def test_parquet_round_trip(tmp_path, sales_df):
p = tmp_path / "sales.parquet"
sales_df.to_parquet(p, index=False)
back = pd.read_parquet(p)
pd.testing.assert_frame_equal(
sales_df.sort_index(axis=1),
back.sort_index(axis=1),
check_like=True
)
"本地跑得好好的,生产环境因为 I/O 就挂了"这种谜之问题可能就出现在这里
12) Join 操作的基数和覆盖率检查
Merge 是数据质量最容易出问题的地方,基数、重复、覆盖率都得显式断言。
import pandas as pd
def test_merge_cardinality():
left = pd.DataFrame({"id":[1,2,3], "x":[10,20,30]})
right = pd.DataFrame({"id":[1,1,2], "y":[5,6,7]})
out = left.merge(right, on="id", how="left")
# Expect duplicated rows for id=1 because right has two matches
assert (out["id"] == 1).sum() == 2
# Coverage: every left id appears at least once
assert set(left["id"]).issubset(set(out["id"]))
key 不唯一或者行数意外翻倍的时候能立刻发现。
小结
好的 Pandas 代码不光要写得聪明,更重要的是可预测。这 12 个策略能让正确性变成默认状态:fixtures 快速启动、schemas 早期失败、property-based tests 探索各种古怪情况、简单的性能预算阻止慢代码偷偷溜进来。本周先试两三个,接到 CI 里,那些神秘的数据 bug 基本就消失了。
https://avoid.overfit.cn/post/6eca2c51cef244849e52ae02b932efa9
作者:Syntal