← Back to DevBytes

Testing Scipy Applications: Unit Tests to Integration

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:

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:

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:

Best Practices for Testing SciPy Applications

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles