pytest for pandas & numpy: Testing Data Transformations That Actually Work
Data transformation bugs are silent killers. A shape mismatch, an off-by-one in a rolling window, a dtype coercion you didn't notice — these don't raise exceptions. They silently corrupt downstream models, dashboards, and decisions.
This guide focuses on pytest patterns specifically for pandas and numpy code: fixtures for test data, parametrize for edge cases, and assertion helpers that give you readable failures instead of cryptic stack traces.
Why Standard pytest Assertions Fall Short
The default assert df1 == df2 doesn't work for DataFrames — it raises an ambiguity error. And assert (df1 == df2).all().all() fails silently when shapes mismatch. You need specialized tools.
# DON'T do this
def test_transform():
result = clean_dataframe(raw)
assert result == expected # ValueError: ambiguous truth value
# DO this
import pandas.testing as tm
def test_transform():
result = clean_dataframe(raw)
tm.assert_frame_equal(result, expected)pandas.testing.assert_frame_equal gives you column-by-column diffs, dtype mismatches, and shape errors in one readable message.
Setting Up Fixtures for Reusable Test Data
Instead of defining DataFrames in every test, use fixtures:
# conftest.py
import pytest
import pandas as pd
import numpy as np
@pytest.fixture
def sample_sales_df():
"""Minimal sales DataFrame with known properties."""
return pd.DataFrame({
"date": pd.date_range("2024-01-01", periods=5),
"product": ["A", "B", "A", "C", "B"],
"revenue": [100.0, 200.0, 150.0, 75.0, 300.0],
"quantity": [2, 4, 3, 1, 6],
})
@pytest.fixture
def sales_with_nulls(sample_sales_df):
"""Sales DataFrame with injected nulls for null-handling tests."""
df = sample_sales_df.copy()
df.loc[1, "revenue"] = np.nan
df.loc[3, "quantity"] = np.nan
return dfFixture composition (sales_with_nulls builds on sample_sales_df) keeps your test data DRY and clearly expresses what each test needs.
Testing DataFrame Transformations
Shape and Column Assertions
Always verify the structural contract of your transformation:
def test_add_revenue_per_unit(sample_sales_df):
result = add_revenue_per_unit(sample_sales_df)
# Shape preserved (rows unchanged)
assert result.shape[0] == sample_sales_df.shape[0]
# New column added
assert "revenue_per_unit" in result.columns
# Original columns preserved
for col in sample_sales_df.columns:
assert col in result.columnsValue-Level Assertions
For computed columns, test the math directly:
def test_revenue_per_unit_calculation(sample_sales_df):
result = add_revenue_per_unit(sample_sales_df)
# Vectorized check
expected = sample_sales_df["revenue"] / sample_sales_df["quantity"]
pd.testing.assert_series_equal(
result["revenue_per_unit"],
expected,
check_names=False, # ignore Series name differences
rtol=1e-5, # relative tolerance for floats
)Dtype Preservation
Type coercions are a common source of subtle bugs:
def test_dtypes_preserved(sample_sales_df):
result = normalize_revenues(sample_sales_df)
assert result["date"].dtype == "datetime64[ns]"
assert result["product"].dtype == object # string
assert result["revenue"].dtype == np.float64Parametrize for Edge Cases
Use @pytest.mark.parametrize to test the same logic against multiple scenarios without code duplication:
@pytest.mark.parametrize("revenue,quantity,expected", [
(100.0, 2, 50.0), # normal case
(0.0, 5, 0.0), # zero revenue
(100.0, 1, 100.0), # single unit
(1e6, 1000, 1000.0), # large values
])
def test_revenue_per_unit_values(revenue, quantity, expected):
df = pd.DataFrame({"revenue": [revenue], "quantity": [quantity]})
result = add_revenue_per_unit(df)
assert abs(result["revenue_per_unit"].iloc[0] - expected) < 1e-9This approach makes it trivial to add new test cases when you encounter a bug — just add a row to the parametrize list.
Testing numpy Array Transformations
For numpy-heavy code (feature engineering, signal processing, numerical methods):
import numpy as np
def normalize(arr: np.ndarray) -> np.ndarray:
"""Min-max normalize to [0, 1]."""
return (arr - arr.min()) / (arr.max() - arr.min())
def test_normalize_range():
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
result = normalize(arr)
assert result.min() == pytest.approx(0.0)
assert result.max() == pytest.approx(1.0)
def test_normalize_shape():
arr = np.random.rand(100, 10)
result = normalize(arr)
assert result.shape == arr.shape
def test_normalize_dtype():
arr = np.array([1, 2, 3], dtype=np.int32)
result = normalize(arr)
assert result.dtype == np.float64 # should upcastpytest.approx handles float comparison correctly — it applies a relative tolerance of 1e-6 by default, which avoids false failures from floating-point arithmetic.
Testing with numpy.testing
For array comparisons, numpy.testing mirrors the pandas testing API:
import numpy.testing as npt
def test_rolling_mean():
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
result = rolling_mean(arr, window=3)
expected = np.array([np.nan, np.nan, 2.0, 3.0, 4.0])
npt.assert_array_almost_equal(result, expected)
# or for strict equality:
npt.assert_array_equal(result[2:], expected[2:])Handling NaN and Missing Data
Missing data tests deserve their own category — they reveal null-propagation bugs:
def test_aggregate_skips_nulls(sales_with_nulls):
result = compute_total_revenue(sales_with_nulls)
# Should sum non-null values only
expected = sales_with_nulls["revenue"].sum() # pandas sum() skips NaN
assert result == pytest.approx(expected)
def test_null_rows_dropped(sales_with_nulls):
result = drop_incomplete_rows(sales_with_nulls)
# Original has 2 nulls, result should have 3 rows
assert result.shape[0] == 3
assert result.isnull().sum().sum() == 0
def test_null_rows_filled(sales_with_nulls):
result = fill_nulls_with_median(sales_with_nulls)
assert result.isnull().sum().sum() == 0
# Median of [100, 150, 75, 300] = 125
assert result["revenue"].median() == pytest.approx(125.0)Groupby and Aggregation Testing
GroupBy operations have a common failure mode: you aggregate the wrong column or use the wrong function. Test the resulting shape AND values:
def test_revenue_by_product(sample_sales_df):
result = total_revenue_by_product(sample_sales_df)
# One row per unique product
assert result.shape[0] == sample_sales_df["product"].nunique()
# Correct totals
assert result.loc["A", "revenue"] == pytest.approx(250.0) # 100 + 150
assert result.loc["B", "revenue"] == pytest.approx(500.0) # 200 + 300
assert result.loc["C", "revenue"] == pytest.approx(75.0)Testing Time Series Transformations
Date-based logic (resampling, lag features, rolling windows) has many edge cases around period boundaries:
@pytest.fixture
def daily_timeseries():
return pd.DataFrame({
"date": pd.date_range("2024-01-01", periods=10, freq="D"),
"value": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
}).set_index("date")
def test_weekly_resample(daily_timeseries):
result = resample_weekly(daily_timeseries)
# 10 days → 2 full weeks
assert result.shape[0] == 2
# First week sum: 1+2+3+4+5+6+7 = 28
assert result["value"].iloc[0] == 28
def test_lag_feature(daily_timeseries):
result = add_lag(daily_timeseries, lag=1)
# First row has NaN (no previous value)
assert pd.isna(result["value_lag1"].iloc[0])
# Second row has value of first row
assert result["value_lag1"].iloc[1] == 1.0Snapshot Testing for Complex Transformations
When a transformation is complex and you trust the current output, snapshot it:
# Save a snapshot during development:
# df.to_csv("tests/fixtures/expected_output.csv", index=False)
def test_full_pipeline_snapshot():
result = full_feature_engineering_pipeline(raw_df)
expected = pd.read_csv("tests/fixtures/expected_output.csv")
pd.testing.assert_frame_equal(
result.reset_index(drop=True),
expected,
check_dtype=False, # allow int64/float64 flexibility
atol=1e-4,
)Update the snapshot when you intentionally change the transformation, and treat unexpected changes as test failures.
Marking Slow Tests
Large DataFrame tests can be slow. Mark them so you can skip them during rapid iteration:
@pytest.mark.slow
def test_pipeline_on_1m_rows():
large_df = generate_synthetic_data(n=1_000_000)
result = full_pipeline(large_df)
assert result.shape[0] == 1_000_000
# Run fast tests only:
# pytest -m "not slow"
# Run everything:
# pytestRegister the mark in pyproject.toml:
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
]CI Configuration
Keep data transformation tests in your CI pipeline with a reasonable timeout:
# .github/workflows/test.yml
- name: Run data tests
run: |
pytest tests/data/ -v --timeout=60 -m "not slow"Run slow tests nightly or on release branches:
- name: Run full data test suite
if: github.ref == 'refs/heads/main'
run: pytest tests/data/ -v --timeout=300What to Actually Test
Focus your test budget on:
- Transformations that change shape — groupby, merge, pivot, resample
- Null handling — how does each step handle missing data?
- Type contracts — do outputs have the dtypes callers expect?
- Boundary conditions — empty DataFrames, single-row DataFrames, all-null columns
- Mathematical correctness — for derived features, verify the formula
Don't test pandas/numpy internals — they're already tested upstream. Test your code.
Conclusion
Solid pytest patterns for pandas and numpy are about three things: fixtures that create controlled test data, parametrize that covers edge cases without repetition, and specialized assertion helpers that give you readable failures. Apply these patterns consistently and you'll catch data transformation bugs at the source — before they corrupt your models or mislead your stakeholders.