Reading Time: 7 minutes

Schlüssel zum Mitnehmen

  • Pytest-Grundlagen decken Einheitentest ab. Fixtures, Parametrisierung und pytest.approx sind Ihr Ausgangspunkt, nicht die Ziellinie.
  • Eigenschaftenbasierte Tests mit Hypothese fangen Randfälle ab, die manuelle Tests häufig übersehen. Es wird von wichtigen wissenschaftlichen Python-Projekten wie Numpy, Jax und Pytorch verwendet.
  • Numerische Konvergenztests erfordern Solver-spezifische Toleranzstrategien. Standard-Behauptungen erfassen kein Konvergenzverhalten.
  • Integrationstests für Simulations-Workflows erfordern Spottmuster, die externe Abhängigkeiten isolieren und gleichzeitig die numerische Logik beibehalten.
  • Reproduzierbarkeitstests gehen über Unit-Tests hinaus. Sie benötigen ein deterministisches Saatgutmanagement und eine plattformübergreifende Gleitkommaverifizierung.

Was Sie zuerst wissen sollten

Wenn Sie die Grundlagen des Unit-Testings für wissenschaftlichen Code bereits kennen, verstehen Sie wahrscheinlich, wie man eine test_-Funktion schreibt, wann Fixtures verwendet werden und wie pytest.approx Gleitkommavergleiche handhabt. Das ist die Grundlinie.

Der wissenschaftliche Kodex stellt Herausforderungen vor, die die Standard-Einheitentests nicht vollständig angehen. Numerische Algorithmen konvergieren oder konvergieren nicht. Simulationen hängen von externen Lösern und I/O-Systemen ab. Fließkommaarithmetik kann sich über Plattformen hinweg unterschiedlich verhalten.

Randfälle, die in normalen Daten niemals auftreten, wie z.

Dieser Artikel behandelt Testmuster für wissenschaftlichen Python-Code über die Grundlagen hinaus.

Die Testpyramide für wissenschaftlichen Code

Jede Teststrategie hat einen Platz auf der Pyramide. Für den wissenschaftlichen Code sieht die Struktur wie folgt aus:

    ┌─────────────────────────────┐
    │    System / Simulation Tests │  ← Integration-level workflows
    │    (3-5 tests)               │
    ├─────────────────────────────┤
    │  Integration / Workflow      │  ← Multi-step simulations
    │  Tests (10-20 tests)         │  ← Data pipelines
    ├─────────────────────────────┤
    │  Property-Based Tests        │  ← Hypothesis with numerical properties
    │  (50-100 tests)              │  ← Convergence testing
    ├─────────────────────────────┤
    │  Unit Tests                  │  ← Numerical assertions
    │  (50-200 tests)              │  ← Fixtures for solvers and meshes
    └─────────────────────────────┘

Unit-Tests bilden die Grundlage. Über ihnen befinden sich eigenschaftsbasierte Tests, Integrationstests und Simulationstests auf Systemebene. Diese höheren Schichten machen das Testen für echte wissenschaftliche Arbeitsabläufe nützlich.

Eigenschaftenbasiertes Testen mit Hypothese

Warum eigenschaftsbasierte Tests für wissenschaftlichen Code wichtig sind

Hypothese ist nicht nur ein Lehrmittel. Es wird von großen wissenschaftlichen Python-Bibliotheken verwendet, um Fehler in numerischen Kernroutinen zu finden.

Eigenschaftenbasierte Tests verändern das mentale Modell des Tests. Anstatt Tests mit einer bestimmten Eingabe und einer erwarteten Ausgabe zu schreiben, schreiben Sie Tests, die die mathematischen Eigenschaften überprüfen. Die Hypothese generiert dann automatisch viele Eingaben, einschließlich Kantenfällen, die Sie möglicherweise nicht manuell testen möchten.

Konvertieren eines handgeschriebenen Tests in eigenschaftsbasierte

Betrachten Sie diesen Test für einen numerischen Solver-Wrapper:

# Before: handwritten test with single case
def test_solver_preserves_mass():
    """Check mass conservation for one mesh size."""
    result = solver_solve(mass_mesh, dt=0.01)
    mass_before = sum(result[0])
    mass_after = sum(result[-1])
    assert mass_after / mass_before == pytest.approx(1.0, rel=1e-3)

Dieser Test überprüft eine Maschengröße und einen Zeitschritt. Es kann sogar passieren, wenn der Solver für größere Netze oder unterschiedliche Zeitschrittwerte ausfällt.

Hier eine Hypothesenversion:

from hypothesis import given, strategies as st
from hypothesis import settings, health_check, suppress

@given(
    st.integers(min_value=5, max_value=200),
    st.floats(min_value=1e-6, max_value=1.0)
)
@settings(max_examples=100, deadline=None)
@health_check("init_consistency_suppressor")
def test_solver_mass_conservation(n_cells, dt):
    """Mass should be preserved across valid mesh sizes and timesteps."""
    mesh = create_mesh(n_cells)
    result = solver_solve(mesh, dt=dt)
    
    mass_before = float(sum(result[0]))
    mass_after = float(sum(result[-1]))
    
    ratio = mass_after / mass_before
    assert abs(ratio - 1.0) < 1e-2

Dies fängt Probleme auf, die ein handgeschriebener Test übersehen kann:

  • Eckfälle in der Netzgenerierung, einschließlich dünner Domänen und extremer Seitenverhältnisse.
  • Zeitschrittsensitivität, insbesondere wenn große dt -Werte explizite Schemata destabilisieren.
  • Gleitkomma-Kantenfälle, einschließlich Rundungsgrenzen und ungewöhnliche numerische Bereiche.

Die Eigenschafts-Checkliste für wissenschaftliche Funktionen

Identifizieren Sie vor dem Schreiben eines Eigenschaftstests, welche Eigenschaften die Funktion erfüllen soll.

Eigenschaftstyp Beispiel Warum es wichtig ist
Erhaltung Masse, Energie oder Impuls bleiben erhalten Verhindert numerische Drift
Symmetrie f(a, b) == f(b, a) für symmetrische Operatoren Fängt asymmetrische Implementierungsfehler ab
Skalieren f(k*a, k*b) == k^n * f(a, b) für homogene Funktionen Tests die dimensionale Konsistenz
Monotonie Die Temperatur sinkt mit der Entfernung in einem einfachen Hitzeproblem Überprüft die körperliche Korrektheit
Grenzen Werte bleiben in einem gültigen Bereich Verhindert nicht-physische Ergebnisse
Null-Verhalten f(0) Gibt den erwarteten Flanken-Case-Wert zurück Schutz gegen Division durch null und einzelne Fälle

Numerische Konvergenzprüfung

Warum Konvergenztests anders ist

Einheitentests prüfen, ob Code eine bestimmte Antwort erzeugt. Konvergenztests prüfen, ob der Code bei der Verfeinerung der Parameter der richtigen Antwort nähert.

Dies ist entscheidend für iterative Löser, Zeitschrittschemata und Netzverfeinerungsstudien. Standard-Behauptungen wie assert result == expected erfassen kein Konvergenzverhalten. Sie müssen Konvergenzmuster testen.

Testen der Solver-Konvergenz

Verschiedene Solver-Suiten verwenden unterschiedliche Konvergenzkriterien. Der entscheidende Punkt ist, dass die Konvergenzkriterien solverspezifisch sind und die Standardtoleranz möglicherweise nicht für jedes Problem geeignet ist.

import numpy as np

def test_solver_convergence():
    """Test that the solver converges with mesh refinement."""
    residuals = []
    for n in [16, 32, 64, 128, 256]:
        mesh = create_mesh(n)
        result = solve_with_convergence(
            mesh,
            criterion="default",
            tolerance=1e-8
        )
        residuals.append(result.residual)
    
    residuals = np.array(residuals)
    
    # Test that residuals decrease monotonically with tolerance
    for i in range(1, len(residuals)):
        if residuals[i] > residuals[i-1] * 1.01:
            raise AssertionError(
                f"Residual increased at refinement level {i}: "
                f"{residuals[i-1]:.2e} → {residuals[i]:.2e}"
            )
    
    # Test convergence rate
    rate = np.log(residuals[0] / residuals[-1]) / np.log(16)
    assert rate >= 1.8, f"Convergence rate too slow: {rate:.2f}"

Toleranzstrategien

Tests sollten die gängigsten Toleranzkriterien abdecken, die vom Solver-Stack verwendet werden.

@pytest.mark.parametrize("criterion", [
    "default",
    "unscaled",
    "preconditioned",
    "natural",
])
def test_convergence_criteria(criterion):
    """Verify each criterion produces reasonable residuals."""
    mesh = create_mesh(64)
    result = solve_with_convergence(mesh, criterion=criterion)
    
    assert result.status in (
        "convergence",
        "absolute_tolerance_convergence",
        "relative_tolerance_convergence",
        "happy_breakdown"
    ) or result.residual < 1e-2

Divergenzfälle testen

Sie sollten auch testen, dass der Code die Abweichung ordnungsgemäß behandelt.

def test_divergence_handling():
    """Code should not crash on divergent cases."""
    mesh = create_mesh(64)
    result = solve_with_convergence(
        mesh,
        criterion="default",
        divergence_tolerance=100
    )
    
    assert hasattr(result, "status")
    assert hasattr(result, "residual")

Integrationstests für Simulationsworkflows

Warum Integrationstests wichtig sind

Unit-Tests überprüfen einzelne Funktionen. Integrationstests stellen sicher, dass die Funktionen korrekt zusammenarbeiten.

Für den wissenschaftlichen Code bedeutet dies das Testen von mehrstufigen Simulationen, Datenpipelines, Solver-Kopplungsmustern und E / A-Workflows.

Muster 1: Verspotten von externen Abhängigkeiten im wissenschaftlichen Kodex

Beim Testen von Funktionen, die externe APIs, Datenbanken oder Dateisysteme aufrufen, verwenden Sie Spott, um die numerische Logik zu isolieren.

from unittest.mock import patch, MagicMock

def test_simulation_io():
    """Test simulation I/O without actually writing to disk."""
    
    with patch("h5py.File") as mock_file:
        mock_dataset = MagicMock()
        mock_file.return_value = MagicMock()
        mock_file.return_value.__enter__ = MagicMock(return_value=mock_file)
        mock_file.return_value.__exit__ = MagicMock(return_value=None)
        
        data = prepare_simulation_output(mesh_size=(100, 100, 100))
        
        assert "simulation_data" in mock_file.return_value.keys()
        assert data.shape == (100, 100, 100)

Dies isoliert die Datenaufbereitungslogik aus der HDF5-Datei Write. Der Test überprüft, ob Ihre Code-Formate und -pakete korrekt sind, ohne auf E / A zu warten.

Muster 2: Parametrisierte Fixtures für Simulations-Sweeps

Anstatt einige Netzgrößen manuell zu testen, verwenden Sie parametrisierte Vorrichtungen für systematische Sweeps.

@pytest.fixture(params=[10, 20, 40, 80, 160])
def mesh_sizes(request):
    """Systematic mesh refinement series."""
    return request.param

@pytest.fixture(params=[
    (1e-3, "fine"),
    (1e-2, "medium"), 
    (1e-1, "coarse")
])
def timesteps(request):
    """Test different timestep regimes."""
    dt, label = request.param
    return dt, label

def test_convergence_with_sweeps(mesh_sizes, timesteps):
    """Test convergence across mesh and timestep refinement."""
    dt, label = timesteps
    
    result = solve_convergence_study(
        mesh_sizes,
        dt=dt,
        quantity="mass_conservation"
    )
    
    assert len(result.residuals) == len(result.mesh_sizes)
    assert all(r > 0 for r in result.residuals)

Muster 3: Testen von mehrstufigen Workflows

Wissenschaftliche Simulationen verfügen häufig über mehrstufige Pipelines: Generierung, Lösung, Nachbearbeitung und Schreibausgabe.

def test_full_workflow():
    """Test the complete simulation pipeline."""
    # Step 1: Generate mesh
    mesh = generate_mesh(geometry="block", resolution=40)
    
    # Step 2: Solve
    solution = solve(mesh, solver="scipy", tolerance=1e-8)
    
    # Step 3: Post-process
    metrics = compute_metrics(solution, quantities=["mass", "energy"])
    
    # Step 4: Verify outputs
    assert metrics["mass"] > 0
    assert metrics["energy"] < 1e6
    
    # Step 5: Write output
    output_path = write_output(solution, path="/tmp/test_output.h5")
    
    # Verify output is readable
    readback = h5py.File(output_path, "r")
    assert "solution" in readback.keys()

Fortgeschrittenes Verspotten für wissenschaftliche Bibliothek

Herausforderung

Das Testen von wissenschaftlichem Code kann langsam sein, da numpy-, scipy- und fipy-Funktionen komplexe Rückgabetypen und teure Ausführungspfade haben können. Wenn Sie sie verspotten, können Sie die Wrapper-Logik schnell testen.

import numpy as np
from unittest.mock import patch

def test_solver_wrapper():
    """Test that your wrapper calls scipy.sparse.linalg correctly."""
    
    with patch("scipy.sparse.linalg.spsolve") as mock_solve:
        mock_solve.return_value = np.zeros(100)
        
        result = your_solver_wrapper(mesh_size=100)
        
        mock_solve.assert_called_once()
        call_args = mock_solve.call_args
        assert isinstance(call_args[0], np.ndarray)
        assert isinstance(call_args[1], np.ndarray)

Dadurch wird die Wrapper-Logik überprüft, ohne dass der tatsächliche Solver ausgeführt wird. Der Test kann in Millisekunden statt Sekunden beendet werden.

Testen von Hin- und Rückfahrten für wissenschaftliche Daten

Das Testen, dass wissenschaftliche Daten einen Lese-Schreib-Lese-Zyklus überstehen, ist ein leistungsstarkes Muster.

from hypothesis import given, strategies as st

@given(st.integers(min_value=10, max_value=200))
def test_hdf5_roundtrip(n_cells):
    """Writing and reading HDF5 data should preserve values."""
    mesh = create_mesh(n_cells)
    solution = solve(mesh, dt=0.01)
    
    path = write_to_hdf5(solution, path="/tmp/test_roundtrip.h5")
    
    readback = read_from_hdf5(path)
    
    assert np.allclose(readback, solution, rtol=1e-6, atol=1e-8)

Dadurch werden Datenserialisierungsfehler, DType-Fehlanpassungen und HDF5-Chunking-Probleme erfasst, die die Ergebnisse stillschweigend beschädigen könnten.

Reproduzierbarkeit testen

Deterministisches Saatgutmanagement

Gleitkommaoperationen können deterministisch sein, wenn der Seed festgelegt ist, aber die Reproduzierbarkeit erfordert auch konsistente Bibliotheksversionen, Kompilierungsflags und MPI-Dekompositionsmuster.

def test_reproducible_seed():
    """Fixed seed should produce identical results."""
    np.random.seed(42)
    
    mesh1 = generate_mesh(seed=42)
    solution1 = solve(mesh1)
    
    np.random.seed(42)
    mesh2 = generate_mesh(seed=42)
    solution2 = solve(mesh2)
    
    assert np.allclose(solution1, solution2)

Plattformübergreifende Gleitkomma-Tests

Verschiedene Plattformen können aufgrund von Hardware- und Bibliotheksunterschieden leicht unterschiedliche Gleitkommaergebnisse erzeugen.

def test_floating_point_stability():
    """Results should be consistent across platforms within tolerance."""
    mesh = create_mesh(64)
    solution = solve(mesh)
    
    # All values should be finite
    assert np.all(np.isfinite(solution))
    
    # Values should not be suspiciously small
    assert np.all(np.abs(solution) > np.finfo(np.float64).tiny * 1e-3)

Was wir empfehlen: Die Teststrategie

Verwenden Sie für ein produktionswissenschaftliches Python-Projekt eine geschichtete Teststrategie.

Haben müssen

  1. Unit-Tests mit pytest.approx für numerische Behauptungen.
  2. Parametrisierte Geräte für systematische Tests über Parameter hinweg.
  3. Konvergenztests, die das Solververhalten während der Verfeinerung überprüfen.

Sollte haben

  1. Hypotheseneigenschaftsbasierte Tests für Randfälle und numerische Robustheit.
  2. Integrationstests für mehrstufige Simulationsworkflows.
  3. Mocking-Strategien für externe Abhängigkeiten und Solver-Schnittstellen.

Schön zu haben

  1. Plattformübergreifende Reproduzierbarkeitstests.
  2. Daten-Rundtrip-Tests, die wissenschaftliche Daten schreiben, lesen und überprüfen.
  3. Leistungsbenchmarks mit pytest-benchmark.

Häufige Fehler

Fehler 1: Toleranzabhängige Aussagen falsch verwenden

Schreiben Sie keine numerischen Tests wie assert result == expected. Verwenden Sie pytest.approx oder toleranzbewusste Zusicherungen für numerischen Code. Die Toleranz hängt von der Problemskala ab, so dass eine feste Toleranz bei größeren Problemen fehlschlägt.

Fehler 2: Konvergenz ignorieren

Wenn der Code iterative Solver verwendet, überspringen Sie keine Konvergenztests. Ein Solver, der auf einem Netz arbeitet, kann auf einem anderen divergieren. Testen Sie das Konvergenzverhalten, nicht nur das Endergebnis.

Fehler 3: Nur den glücklichen Weg testen

Property-basierte Tests zwingen Sie dazu, Randfälle zu testen, die manuelle Tests häufig übersehen. Fügen Sie mindestens einen Hypothesentest für jede wichtige numerische Funktion hinzu. Es kann Fehler aufdecken, die normale beispielbasierte Tests niemals abfangen.

Zusammenfassung

Unit-Tests sind notwendig, aber sie reichen für den wissenschaftlichen Kodex nicht aus. Property-basierte Tests, Konvergenztests, Integrationsworkflows und Reproduzierbarkeitsprüfungen befassen sich mit den spezifischen Herausforderungen, die numerische Simulationen mit sich bringen.

Die wichtigste Erkenntnis ist, dass es bei wissenschaftlichen Tests nicht nur darum geht, zu überprüfen, ob Code eine richtige Antwort liefert. Es geht darum zu überprüfen, ob sich der Code richtig verhält. Es sollte elegant konvergieren, konservieren, skalieren und bearbeiten.

Beginnen Sie mit den Must-Have-Mustern und fügen Sie dann die Muster hinzu, wenn die Testsuite ausgereift ist. Verwenden Sie die schönen Muster, um die Codebasis im Laufe der Zeit zu härten.

Property-basierte Tests mit Hypothese sind eine der hochwertigsten Ergänzungen, die Sie vornehmen können, da sie Fehler auffangen, die manuelle Tests häufig übersehen.

Verwandte Anleitungen

Was macht man als nächstes

Wenn Sie gerade angefangen haben, Tests für wissenschaftlichen Code zu schreiben, beginnen Sie mit den oben genannten Mustern. Sobald diese vorhanden sind, fügen Sie Hypothesentests für die wichtigsten numerischen Funktionen hinzu, insbesondere Funktionen, die in Simulationen wiederholt aufgerufen werden.

Für Teams, die an produktiven wissenschaftlichen Software arbeiten, kann sich die Investition in objektbasierte Tests schnell auszahlen. Es handelt sich um dieselbe Strategie, die von großen wissenschaftlichen Python-Bibliotheken verwendet wird, und ist eine der effektivsten Möglichkeiten, um Edge-Case-Fehler im numerischen Code zu finden.

Wenn Sie Hilfe beim Entwerfen einer Teststrategie für ein Simulationsprojekt benötigen, sollten Sie eine Beratung buchen. Eine praktische Überprüfung kann Ihre Codestruktur auf Einheitentests, Konvergenztests, Integrationstests und Reproduzierbarkeitsprüfungen abbilden.