
Creating a Robust Testing Suite with Pytest: Strategies for Effective Unit and Integration Testing in Python
Strengthen your Python projects with a well-designed testing suite using pytest. This post walks intermediate developers through unit and integration testing strategies, practical pytest patterns, fixtures, mocking external systems (Airflow, Selenium), and testing NumPy-based data processing for performance and correctness.
Introduction
Testing is the backbone of reliable software. Whether you're building a web scraper with Selenium, orchestrating data workflows with Apache Airflow, or optimizing array operations with NumPy, a robust test suite ensures correctness, prevents regressions, and builds confidence for safe refactors and deployments.
In this post you'll learn how to design and implement a pragmatic, maintainable testing suite using pytest. We'll cover unit vs. integration testing, useful pytest features (fixtures, parametrization, markers), mocking strategies for external systems (Selenium, Airflow), and testing performance-sensitive NumPy code. Expect practical, real-world examples and explanations line-by-line.
Prerequisites
Before proceeding you should have:
- Python 3.7+ installed.
- Basic pytest familiarity (running
pytest). - Familiarity with Python modules, functions, and virtual environments.
- Optional: basic knowledge of NumPy, Apache Airflow, and Selenium if you will test code interacting with these.
python -m venv .venv
source .venv/bin/activate
pip install pytest pytest-cov pytest-mock numpy
Optional for integration tests:
pip install selenium pytest-selenium apache-airflow
Note: Installing Airflow for full integration tests can be heavyweight. We'll show strategies to test Airflow-related code without spinning up the full scheduler.
Core Concepts: Unit vs Integration Tests
Understand the difference and purpose:
- Unit tests:
- Integration tests:
Balance: Aim for a fast and dense suite of unit tests and a smaller set of integration tests for end-to-end behavior.
Test Project Layout (Recommended)
A clear folder structure improves discoverability and organization:
- myproject/
Example pytest.ini to customize markers:
# pytest.ini
[pytest]
minversion = 6.0
addopts = -ra -q
markers =
integration: marks tests as integration (slow)
Step-by-Step Examples
We'll work through three focused examples:
- Unit testing a NumPy-based function.
- Unit testing code that uses Selenium (mocking).
- Integration testing a simple data pipeline function similar to what you'd schedule in Airflow.
1) Unit testing NumPy data-processing functions
File: mypackage/data_processing.py
import numpy as np
def normalize_columns(arr: np.ndarray) -> np.ndarray:
"""
Normalize columns of a 2D array to zero mean and unit variance.
Returns a new array.
Raises ValueError if arr is not 2D or contains NaNs.
"""
arr = np.asarray(arr)
if arr.ndim != 2:
raise ValueError("Input must be 2D")
if np.isnan(arr).any():
raise ValueError("Input contains NaNs")
mean = arr.mean(axis=0)
std = arr.std(axis=0, ddof=0)
# Avoid division by zero: if std==0, set to 1 to preserve zeros
std_safe = np.where(std == 0, 1.0, std)
return (arr - mean) / std_safe
Explanation line-by-line:
- import numpy: we use NumPy for numeric operations.
- normalize_columns: function docstring clarifies behavior.
- arr = np.asarray(arr): ensures input is array-like.
- Checks for 2D and NaNs explicitly to give deterministic failures.
- mean, std: compute per-column statistics.
- std_safe uses np.where to prevent division by zero (if a column is constant).
- Return the normalized array (broadcasting handles dimensions).
import numpy as np
from mypackage.data_processing import normalize_columns
import pytest
def test_normalize_basic():
arr = np.array([[1., 2.], [3., 4.], [5., 6.]])
out = normalize_columns(arr)
# Each column mean should be ~0
assert np.allclose(out.mean(axis=0), np.zeros(2), atol=1e-8)
# Each column std should be 1 (within tolerance)
assert np.allclose(out.std(axis=0), np.ones(2), atol=1e-8)
def test_constant_column():
arr = np.array([[2., 1.], [2., 3.], [2., 5.]])
out = normalize_columns(arr)
# First column is constant -> zeros after normalization
assert np.allclose(out[:, 0], 0.0)
assert np.allclose(out[:, 1].std(), 1.0)
def test_invalid_inputs():
with pytest.raises(ValueError):
normalize_columns(np.array([1, 2, 3])) # not 2D
with pytest.raises(ValueError):
normalize_columns(np.array([[1., np.nan], [2., 3.]]))
Why these tests matter:
- They verify numerical correctness and edge cases (constant columns, NaNs).
- Use np.allclose to account for floating-point rounding.
- If you have large arrays and want to assert performance, consider using pytest-benchmark or add a performance test separate from functional tests.
2) Unit testing code that uses Selenium (mocking)
Suppose web_automation.py provides a function to fetch page title after clicking a button.
File: mypackage/web_automation.py
from selenium.webdriver.remote.webdriver import WebDriver
def click_and_get_title(driver: WebDriver, button_selector: str) -> str:
"""
Clicks a control found by CSS selector and returns the page title.
driver: a Selenium WebDriver instance.
"""
button = driver.find_element_by_css_selector(button_selector)
button.click()
return driver.title
Testing strategy:
- Don't require a real browser for unit tests — mock the WebDriver and elements.
- Use pytest-mock or unittest.mock to create lightweight fakes.
from mypackage.web_automation import click_and_get_title
from types import SimpleNamespace
def test_click_and_get_title(monkeypatch):
# Create a fake element with click method
fake_button = SimpleNamespace(click=lambda: None)
# Create a fake driver that returns title and element
class FakeDriver:
def __init__(self):
self.title = "Before"
def find_element_by_css_selector(self, selector):
assert selector == ".submit"
# Simulate side-effect that clicking changes title
def click_side_effect():
self.title = "After"
fake_button.click = click_side_effect
return fake_button
driver = FakeDriver()
title = click_and_get_title(driver, ".submit")
assert title == "After"
Line-by-line:
- Use SimpleNamespace and a small FakeDriver to avoid importing Selenium.
- Assert that selector is passed correctly and simulate side-effects on click.
- This keeps the test fast and deterministic.
3) Integration-style test for a data pipeline (Airflow-friendly)
Airflow DAGs often wrap Python functions; it's easier to test the functions than the runtime. Example function saves processed data to disk; the Airflow task simply calls it. We'll test end-to-end behavior using temporary directories.
File: mypackage/airflow_tasks.py
import json
from pathlib import Path
import numpy as np
from .data_processing import normalize_columns
def process_and_save(input_array, out_path: str):
arr = np.asarray(input_array)
normalized = normalize_columns(arr)
out = {
"shape": normalized.shape,
"data": normalized.tolist()
}
Path(out_path).write_text(json.dumps(out))
return out_path
Integration test: tests/integration/test_pipeline_integration.py
import json
import numpy as np
from mypackage.airflow_tasks import process_and_save
import tempfile
def test_process_and_save(tmp_path):
arr = np.array([[1., 2.], [3., 4.]])
out_file = tmp_path / "out.json"
returned = process_and_save(arr, str(out_file))
assert str(out_file) == returned
data = json.loads(out_file.read_text())
assert data["shape"] == [2, 2]
# verify mean is approximately zero
loaded = np.array(data["data"])
assert np.allclose(loaded.mean(axis=0), 0.0)
Notes:
- tmp_path fixture provides an isolated temporary directory.
- This mirrors how an Airflow PythonOperator would invoke process_and_save. To test actual DAG structure, import DAG definitions and assert tasks exist, but don't rely on scheduler in unit tests.
Pytest Features and Patterns
- Fixtures: centralize setup/teardown in conftest.py for reusable resources.
import pytest
@pytest.fixture
def sample_array():
import numpy as np
return np.arange(6).reshape(3, 2).astype(float)
- Parametrization: test multiple scenarios concisely.
@pytest.mark.parametrize("arr,rows", [
([[1,2],[3,4]], 2),
([[5,6],[7,8],[9,10]], 3)
])
def test_shapes(arr, rows):
import numpy as np
from mypackage.data_processing import normalize_columns
out = normalize_columns(np.array(arr))
assert out.shape[0] == rows
- Markers: tag slow integration tests with @pytest.mark.integration and run selectively:
pytest -m "integration". - conftest.py: put shared fixtures and hooks here to keep tests DRY.
Mocking and Monkeypatching Best Practices
- Prefer dependency injection: accept objects (e.g., driver) or factory arguments that tests can replace.
- For external services:
- Example replacing a requests.get in tests:
from unittest.mock import patch
import requests
def fetch_json(url):
r = requests.get(url)
return r.json()
def test_fetch_json(monkeypatch):
class FakeResp:
def json(self): return {"ok": True}
monkeypatch.setattr("requests.get", lambda url: FakeResp())
assert fetch_json("http://example") == {"ok": True}
Testing NumPy Performance and Correctness
- For correctness: use np.allclose with tolerances.
- For performance: keep unit tests focused on correctness; add separate benchmark tests with pytest-benchmark.
- Beware of using default dtype behaviors (ints vs floats) — ensure tests use float arrays when needed.
def test_normalize_perf(benchmark):
import numpy as np
from mypackage.data_processing import normalize_columns
arr = np.random.rand(1000, 100)
result = benchmark(lambda: normalize_columns(arr))
assert result.shape == (1000, 100)
CI, Coverage, and Test Reporting
- Use pytest-cov for coverage:
pytest --cov=mypackage. - In CI (GitHub Actions, GitLab CI):
- Keep fast unit tests on every commit; run slow/flaky tests less frequently.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with: python-version: '3.10'
- name: Install deps
run: pip install -r requirements-dev.txt
- name: Run tests
run: pytest -q
Common Pitfalls and How to Avoid Them
- Flaky tests:
- Over-mocking:
- Slow test suite:
- Testing randomness:
Advanced Tips
- Use tox for testing across Python versions.
- Use pytest-xdist to parallelize tests:
pytest -n auto. - For database-backed integration tests, use Docker Compose or testcontainers to spin up real DBs.
- Testing Airflow DAGs:
DagBag in tests to parse your DAG file and assert task IDs exist:
from airflow.models import DagBag
def test_dag_parses():
dagbag = DagBag()
dag = dagbag.get_dag('my_dag_id')
assert dag is not None
- Avoid requiring Airflow scheduler in unit tests; run full DAG tests in dedicated integration pipelines.
- For Selenium end-to-end tests, prefer headless browsers and manage WebDriver lifecycle in fixtures. Mark these tests as integration and run them in CI with necessary drivers.
Example conftest.py (Shared fixtures)
import pytest
import numpy as np
@pytest.fixture
def sample_array():
return np.array([[1., 2.], [3., 4.]])
@pytest.fixture
def fake_driver():
from types import SimpleNamespace
driver = SimpleNamespace()
driver.title = "Start"
def find_element_by_css_selector(sel):
el = SimpleNamespace()
def click():
driver.title = "Clicked"
el.click = click
return el
driver.find_element_by_css_selector = find_element_by_css_selector
return driver
Conclusion
A robust pytest suite is about strategy as much as code. Prioritize fast, deterministic unit tests, use fixtures and parametrization to reduce duplication, and keep integration tests focused and isolated. Mock external systems like Selenium or network calls for unit tests, and run a smaller set of integration tests that exercise real components (or light-weight test doubles) to validate end-to-end behavior. When dealing with data-intensive code (NumPy) or workflow systems (Airflow), test pure logic thoroughly — those are easiest to validate reliably.
Try it now:
- Clone or create a small project with the sample files above.
- Add tests in tests/unit and tests/integration.
- Run pytest locally: pytest -q and experiment with markers and fixtures.
Further Reading and Resources
- Pytest official docs: https://docs.pytest.org/
- NumPy testing guidelines: https://numpy.org/doc/stable/reference/testing.html
- Selenium with Python: https://www.selenium.dev/documentation/
- Airflow testing patterns: https://airflow.apache.org/docs/apache-airflow/stable/testing.html
- pytest fixtures & parametrization: https://docs.pytest.org/en/stable/fixture.html
- pytest-cov: https://pytest-cov.readthedocs.io/
- pytest-benchmark: https://github.com/ionelmc/pytest-benchmark
Was this article helpful?
Your feedback helps us improve our content. Thank you!