Testing SciPy Applications: Unit Tests to Integration
SciPy is a cornerstone of the scientific Python ecosystem, powering everything from numerical optimization to signal processing and statistical modeling. But when you build applications on top of SciPy, correctness is not optional — a subtle numerical bug can quietly corrupt research results, financial models, or engineering simulations. This tutorial walks you through a complete testing strategy for SciPy-based applications, moving from isolated unit tests up to full integration tests.
What Is Testing in the Context of SciPy?
Testing SciPy applications means verifying that your code — which wraps, extends, or combines SciPy functions — behaves as expected. Because SciPy deals heavily with floating-point arithmetic, optimization convergence, and stochastic algorithms, testing here differs from typical web or CRUD application testing. You must account for numerical tolerance, algorithmic determinism, edge cases like singular matrices, and performance under large inputs.
There are three main layers of testing relevant here:
- Unit tests — verify individual functions or small modules in isolation, often mocking out SciPy calls or testing pure wrappers.
- Component tests — verify that a group of related functions work together, such as a preprocessing pipeline that uses several
scipy.signaltransforms. - Integration tests — verify the entire application end-to-end, including data loading, SciPy computation, and output generation.
Why Testing SciPy Applications Matters
Numerical code is deceptive. A function may return a result that "looks right" but is off by a factor of a thousand due to a unit mismatch, or it may converge to a local minimum instead of the global one. Without tests, these bugs hide until they cause real-world damage. Testing matters because:
- Floating-point errors accumulate silently. Small mistakes in matrix operations can amplify dramatically.
- SciPy functions have many optional parameters. Defaults may not suit your use case, and only tests catch regressions when you upgrade SciPy versions.
- Scientific reproducibility depends on correctness. If your pipeline is wrong, every downstream paper or report is wrong.
- Refactoring numerical code is risky. Tests give you confidence to optimize without breaking results.
Setting Up Your Test Environment
Use pytest as your test runner. It is the de facto standard in the scientific Python community and integrates well with NumPy and SciPy through numpy.testing. Install the essentials:
pip install pytest pytest-cov scipy numpy
Organize your project like this:
my_scipy_app/
├── my_scipy_app/
│ ├── __init__.py
│ ├── optimize.py
│ ├── signal_utils.py
│ └── stats_utils.py
├── tests/
│ ├── __init__.py
│ ├── test_optimize.py
│ ├── test_signal_utils.py
│ ├── test_stats_utils.py
│ └── test_integration.py
├── requirements.txt
└── pytest.ini
In pytest.ini, configure sensible defaults for numerical projects:
[pytest]
testpaths = tests
addopts = -v --tb=short --cov=my_scipy_app
Writing Unit Tests for SciPy Functions
Unit tests should isolate the function under test. When your function wraps a SciPy routine, you have two choices: test against known analytical results, or test against precomputed reference values. The first is preferable because it does not break when SciPy changes internally.
Consider a simple optimization wrapper in my_scipy_app/optimize.py:
import numpy as np
from scipy.optimize import minimize
def minimize_quadratic(a, b, c, x0=0.0):
"""Minimize f(x) = a*x^2 + b*x + c starting from x0."""
if a == 0:
raise ValueError("Coefficient 'a' must be non-zero for a quadratic.")
def objective(x):
return a * x[0] ** 2 + b * x[0] + c
result = minimize(objective, x0=[x0], method="BFGS")
return result.x[0], result.fun
The analytical minimum of a*x^2 + b*x + c is at x = -b / (2a). Use this in your unit test:
import numpy as np
import pytest
from numpy.testing import assert_allclose
from my_scipy_app.optimize import minimize_quadratic
def test_minimize_quadratic_basic():
x_min, f_min = minimize_quadratic(2.0, -4.0, 1.0)
expected_x = -(-4.0) / (2 * 2.0)
expected_f = 2.0 * expected_x ** 2 - 4.0 * expected_x + 1.0
assert_allclose(x_min, expected_x, rtol=1e-5)
assert_allclose(f_min, expected_f, rtol=1e-5)
def test_minimize_quadratic_raises_on_linear():
with pytest.raises(ValueError):
minimize_quadratic(0.0, 3.0, 1.0)
@pytest.mark.parametrize("a,b,c", [
(1.0, 0.0, 0.0),
(3.5, 2.1, -1.0),
(0.01, 10.0, 100.0),
])
def test_minimize_quadratic_parametrized(a, b, c):
x_min, f_min = minimize_quadratic(a, b, c)
expected_x = -b / (2 * a)
assert_allclose(x_min, expected_x, rtol=1e-4)
Notice the use of numpy.testing.assert_allclose instead of assert x == y. Floating-point equality is fragile; relative tolerance checks are the correct approach for numerical results.
Testing Signal Processing Utilities
Signal processing code often chains multiple SciPy transforms. Here is a utility that applies a low-pass Butterworth filter and then downsamples:
import numpy as np
from scipy.signal import butter, filtfilt, resample
def filter_and_downsample(signal, sample_rate, cutoff_hz, target_rate):
nyq = 0.5 * sample_rate
normal_cutoff = cutoff_hz / nyq
b, a = butter(4, normal_cutoff, btype="low", analog=False)
filtered = filtfilt(b, a, signal)
num_samples = int(len(filtered) * target_rate / sample_rate)
return resample(filtered, num_samples)
For the test, generate a synthetic signal with a known frequency content. A 50 Hz sine wave plus 200 Hz noise should, after low-pass filtering at 100 Hz, retain mostly the 50 Hz component:
import numpy as np
from numpy.testing import assert_allclose
from my_scipy_app.signal_utils import filter_and_downsample
def test_filter_and_downsample_removes_high_frequency():
sample_rate = 1000
t = np.arange(0, 1.0, 1 / sample_rate)
low_freq = np.sin(2 * np.pi * 50 * t)
high_freq = 0.5 * np.sin(2 * np.pi * 200 * t)
signal = low_freq + high_freq
result = filter_and_downsample(signal, sample_rate, cutoff_hz=100, target_rate=500)
# The result should be close to the pure 50 Hz component, downsampled
t_down = np.arange(0, 1.0, 1 / 500)
expected = np.sin(2 * np.pi * 50 * t_down)
# Allow tolerance because filtering introduces phase/amplitude effects
assert result.shape[0] == 500
assert np.std(result - expected[:len(result)]) < 0.2
def test_filter_and_downsample_output_length():
signal = np.random.randn(1000)
result = filter_and_downsample(signal, 1000, cutoff_hz=100, target_rate=250)
assert len(result) == 250
Testing Statistical Functions
Statistical tests in SciPy often involve p-values and distributions. Use fixed random seeds and large sample sizes to keep tests deterministic and stable:
import numpy as np
from scipy import stats
def compare_distributions(sample1, sample2):
"""Return t-statistic and p-value from an independent t-test."""
t_stat, p_value = stats.ttest_ind(sample1, sample2)
return t_stat, p_value
Test it with samples drawn from distributions with known relationships:
import numpy as np
import pytest
from my_scipy_app.stats_utils import compare_distributions
def test_compare_distributions_same_distribution():
rng = np.random.default_rng(seed=42)
s1 = rng.normal(0, 1, 1000)
s2 = rng.normal(0, 1, 1000)
t_stat, p_value = compare_distributions(s1, s2)
assert p_value > 0.05 # Cannot reject null hypothesis
def test_compare_distributions_different_means():
rng = np.random.default_rng(seed=42)
s1 = rng.normal(0, 1, 1000)
s2 = rng.normal(5, 1, 1000)
t_stat, p_value = compare_distributions(s1, s2)
assert p_value < 0.001 # Strongly reject null hypothesis
assert t_stat < 0 # Mean of s1 is less than s2
Mocking SciPy Calls When Appropriate
Sometimes you want to test the logic around a SciPy call without actually running the (potentially slow) computation. Use unittest.mock to patch SciPy functions. This is especially useful for testing error handling and branching logic:
from unittest.mock import patch, MagicMock
from my_scipy_app.optimize import minimize_quadratic
def test_minimize_quadratic_calls_scipy():
with patch("my_scipy_app.optimize.minimize") as mock_minimize:
mock_result = MagicMock()
mock_result.x = [1.5]
mock_result.fun = -2.25
mock_minimize.return_value = mock_result
x_min, f_min = minimize_quadratic(1.0, -3.0, 0.0)
assert x_min == 1.5
assert f_min == -2.25
mock_minimize.assert_called_once()
args, kwargs = mock_minimize.call_args
assert kwargs["method"] == "BFGS"
Use mocking sparingly. Over-mocking leads to tests that verify implementation details rather than behavior, and they break frequently during refactoring.
Writing Integration Tests
Integration tests verify that the full pipeline works together. Suppose your application loads a dataset, filters it, runs a statistical comparison, and writes results. The integration test exercises all of these steps with a realistic (but synthetic) input:
import numpy as np
import json
import tempfile
import os
from my_scipy_app.signal_utils import filter_and_downsample
from my_scipy_app.stats_utils import compare_distributions
def run_analysis(signal_a, signal_b, sample_rate):
"""Full pipeline: filter, downsample, compare."""
filtered_a = filter_and_downsample(signal_a, sample_rate, cutoff_hz=100, target_rate=500)
filtered_b = filter_and_downsample(signal_b, sample_rate, cutoff_hz=100, target_rate=500)
t_stat, p_value = compare_distributions(filtered_a, filtered_b)
return {"t_stat": float(t_stat), "p_value": float(p_value)}
def test_full_pipeline_integration():
rng = np.random.default_rng(seed=123)
sample_rate = 1000
t = np.arange(0, 2.0, 1 / sample_rate)
signal_a = np.sin(2 * np.pi * 50 * t) + rng.normal(0, 0.1, len(t))
signal_b = np.sin(2 * np.pi * 50 * t) + rng.normal(0.5, 0.1, len(t))
result = run_analysis(signal_a, signal_b, sample_rate)
assert "t_stat" in result
assert "p_value" in result
assert isinstance(result["t_stat"], float)
assert isinstance(result["p_value"], float)
assert result["p_value"] < 0.05 # The means differ
def test_pipeline_writes_valid_json(tmp_path):
rng = np.random.default_rng(seed=456)
sample_rate = 1000
t = np.arange(0, 1.0, 1 / sample_rate)
signal_a = np.sin(2 * np.pi * 50 * t)
signal_b = np.sin(2 * np.pi * 50 * t) + 0.01
result = run_analysis(signal_a, signal_b, sample_rate)
output_file = tmp_path / "result.json"
output_file.write_text(json.dumps(result))
loaded = json.loads(output_file.read_text())
assert loaded["p_value"] == result["p_value"]
The tmp_path fixture provided by pytest creates a temporary directory that is automatically cleaned up, making file-based integration tests safe and isolated.
Handling Numerical Non-Determinism
Some SciPy functions are non-deterministic or sensitive to initial conditions. Optimization routines may converge to different solutions depending on the starting point. To keep tests stable:
- Always seed your random number generators with
np.random.default_rng(seed=...). - Use generous tolerances in
assert_allclose—rtol=1e-4is often more appropriate thanrtol=1e-10. - Test properties rather than exact values when possible. For example, assert that a covariance matrix is positive semi-definite rather than checking every entry.
- Mark inherently flaky tests with
@pytest.mark.flaky(requires thepytest-rerunfailuresplugin) and configure reruns.
Best Practices for Testing SciPy Applications
- Prefer analytical solutions as test oracles. If you know the closed-form answer, compare against it rather than against a previous run's output.
- Use property-based testing. Libraries like Hypothesis can generate random valid inputs and check invariants, which is powerful for numerical code.
- Test edge cases explicitly. Empty arrays, NaN values, singular matrices, and ill-conditioned problems all deserve dedicated tests.
- Keep fast and slow tests separate. Mark expensive integration tests with
@pytest.mark.slowso you can run only fast tests during development. - Pin your SciPy version in CI. Numerical behavior can change between versions, so record which version your tests were validated against.
- Measure coverage but do not chase 100%. Focus coverage on the numerical core where bugs are most damaging.
- Document expected tolerances. A comment explaining why
rtol=1e-3is acceptable prevents future developers from "fixing" it incorrectly.
Adding Property-Based Tests with Hypothesis
Property-based testing is especially valuable for numerical code because it explores a wide input space automatically. Install Hypothesis and write a property test for the quadratic minimizer:
pip install hypothesis
import numpy as np
from hypothesis import given, strategies as st
from numpy.testing import assert_allclose
from my_scipy_app.optimize import minimize_quadratic
@given(
a=st.floats(min_value=0.1, max_value=10.0, allow_nan=False),
b=st.floats(min_value=-10.0, max_value=10.0, allow_nan=False),
c=st.floats(min_value=-10.0, max_value=10.0, allow_nan=False),
)
def test_minimize_quadratic_property(a, b, c):
x_min, f_min = minimize_quadratic(a, b, c)
expected_x = -b / (2 * a)
assert_allclose(x_min, expected_x, rtol=1e-3, atol=1e-3)
# The minimum value should be less than or equal to f(0) = c
assert f_min <= c + 1e-6
Hypothesis will run this test with hundreds of generated inputs, catching edge cases you might never think to write manually.
Conclusion
Testing SciPy applications requires a mindset shift from traditional software testing. You are not just checking that code runs without throwing exceptions; you are verifying numerical correctness within acceptable tolerances, guarding against floating-point pitfalls, and ensuring that complex pipelines of transforms and optimizations produce reproducible results. By combining unit tests with analytical oracles, property-based tests for broad input coverage, and integration tests for end-to-end validation, you build a safety net that catches numerical regressions early. The investment pays off every time you upgrade SciPy, refactor a hot loop, or hand the code to a new team member — the tests document expected behavior and prevent silent corruption of your scientific results.