Points à retenir clés
- Les bases de Pytest couvrent les tests unitaires. Les luminaires, la paramétrisation et
pytest.approxsont votre point de départ, pas la ligne d’arrivée. - Les tests basés sur la propriété avec une hypothèse attrapent des cas de bord que les tests manuels manquent souvent. Il est utilisé par les principaux projets scientifiques de Python tels que NumPy, Jax et Pytorch.
- Les tests de convergence numérique nécessitent des stratégies de tolérance spécifiques au solveur. Les assertions standard ne capturent pas le comportement de convergence.
- Les tests d’intégration pour les flux de travail de simulation nécessitent des modèles de moquerie qui isolent les dépendances externes tout en préservant la logique numérique.
- Les tests de reproductibilité vont au-delà des tests unitaires. Vous avez besoin d’une gestion déterministe des semences et d’une vérification à virgule flottante multiplateforme.
Ce qu’il faut savoir d’abord
Si vous connaissez déjà les bases des tests unitaires de code scientifique, vous comprenez probablement comment écrire une fonction test_, quand utiliser les appareils et comment pytest.approx gère les comparaisons à virgule flottante. C’est la ligne de base.
Le code scientifique présente des défis auxquels les tests unitaires standard ne sont pas entièrement résolus. Les algorithmes numériques convergent ou ne parviennent pas à converger. Les simulations dépendent de solveurs externes et de systèmes d’E/S. L’arithmétique à virgule flottante peut se comporter différemment sur les plates-formes.
Les cas de périphérie qui ne se produisent jamais dans les données normales, telles que les diviseurs zéro, les matrices singulières et les rapports d’aspect extrêmes, sont souvent là où les bogues les plus graves se cachent.
Cet article couvre les modèles de test du code Python scientifique au-delà des bases.
La pyramide de test pour le code scientifique
Chaque stratégie de test a sa place sur la pyramide. Pour le code scientifique, la structure ressemble à ceci :
┌─────────────────────────────┐
│ 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
└─────────────────────────────┘
Les tests unitaires forment la base. Au-dessus d’eux se trouvent des tests basés sur des propriétés, des tests d’intégration et des tests de simulation au niveau du système. Ces couches supérieures rendent les tests utiles pour de véritables flux de travail scientifiques.
Tests basés sur la propriété avec hypothèse
Pourquoi les tests basés sur la propriété sont importants pour le code scientifique
L’hypothèse n’est pas seulement un outil pédagogique. Il est utilisé par les principales bibliothèques scientifiques de Python pour trouver des bogues dans les routines numériques de base.
Les tests basés sur la propriété modifient le modèle mental du test. Au lieu d’écrire des tests avec une entrée spécifique et une sortie attendue, vous écrivez des tests qui vérifient les propriétés mathématiques. L’hypothèse génère ensuite de nombreuses entrées automatiquement, y compris les cas de bord que vous ne pensez peut-être pas tester manuellement.
Conversion d’un test manuscrit en propriété
Considérez ce test pour un encapsuleur numérique de solveur :
# 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)
Ce test vérifie une taille de maillage et un pas de temps. Il peut passer même si le solveur échoue pour des maillages plus grands ou des valeurs de pas de temps différentes.
Voici une version d’hypothèse :
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
Cela attrape les problèmes qu’un test manuscrit peut manquer :
- Cas d’angle dans la génération de maillage, y compris les domaines fins et les rapports d’aspect extrêmes.
- Sensibilité au pas de temps, en particulier lorsque les valeurs volumineuses
dtdéstabilisent les schémas explicites. - Cas d’arête à virgule flottante, y compris les limites arrondies et les plages numériques inhabituelles.
La liste de contrôle des propriétés pour les fonctions scientifiques
Avant d’écrire un test de propriété, identifiez les propriétés que la fonction doit satisfaire.
| Type de propriété | Exemple | Pourquoi c’est important |
|---|---|---|
| Préservation | La masse, l’énergie ou la quantité de mouvement est conservée | Empêche la dérive numérique |
| Symétrie | f(a, b) == f(b, a) pour les opérateurs symétriques |
Récupère les bogues de mise en œuvre asymétrique |
| Écaillage | f(k*a, k*b) == k^n * f(a, b) pour les fonctions homogènes |
Tests cohérence dimensionnelle |
| monotonie | La température diminue avec la distance dans un simple problème de chaleur | Vérifie l’exactitude physique |
| Bornes | Les valeurs restent dans une plage valide | Empêche les résultats non physiques |
| Comportement nul | f(0) renvoie la valeur de cas de bord attendue |
Les gardes contre la division par des cas nuls et singuliers |
Test de convergence numérique
Pourquoi les tests de convergence sont différents
Les tests unitaires vérifient si le code produit une réponse spécifique. Les tests de convergence vérifient si le code approche de la bonne réponse à mesure que les paramètres sont affinés.
Ceci est essentiel pour les solveurs itératifs, les schémas de pas de temps et les études de raffinement de maillage. Les assertions standard telles que assert result == expected ne capturent pas le comportement de convergence. Vous devez tester les modèles de convergence.
Tester la convergence du solveur
Différentes suites de solveurs utilisent des critères de convergence différents. Le point clé est que les critères de convergence sont spécifiques au solveur et que la tolérance par défaut peut ne pas convenir à chaque problème.
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}"
stratégies de tolérance
Les tests doivent couvrir les critères de tolérance les plus couramment utilisés par la pile de solveurs.
@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
Tester des cas de divergence
Vous devez également tester que le code gère les divergences avec élégance.
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")
Test d’intégration pour les flux de travail de simulation
Pourquoi les tests d’intégration sont importants
Les tests unitaires vérifient les fonctions individuelles. Les tests d’intégration vérifient que les fonctions fonctionnent correctement ensemble.
Pour le code scientifique, cela signifie tester des simulations en plusieurs étapes, des pipelines de données, des modèles de couplage de solveur et des flux de travail d’E/S.
Modèle 1 : moquerie des dépendances externes dans le code scientifique
Lorsque vous testez des fonctions qui appellent des API, des bases de données ou des systèmes de fichiers externes, utilisez la moquerie pour isoler la logique numérique.
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)
Cela isole la logique de préparation des données de l’écriture de fichier HDF5. Le test vérifie que les formats de code et de package sont correctement définis sans attendre les E/S.
Modèle 2 : Appareils paramétrés pour les balayages de simulation
Au lieu de tester manuellement quelques tailles de maillage, utilisez des appareils paramétrés pour des balayages systématiques.
@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)
Modèle 3 : tester des flux de travail en plusieurs étapes
Les simulations scientifiques ont souvent des pipelines en plusieurs étapes : génération de maillage, résolution, post-traitement et production d’écriture.
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()
Moquerie avancée pour les bibliothèques scientifiques
Le défi
Le test de code scientifique peut être lent, car les fonctions NumPy, Scipy et Fipy peuvent avoir des types de retour complexes et des chemins d’exécution coûteux. Les moquer vous permet de tester rapidement la logique de l’encapsuleur.
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)
Cela vérifie la logique d’encapsulation sans attendre l’exécution du solveur réel. Le test peut se terminer en millisecondes au lieu de secondes.
Test des allers-retours pour les données scientifiques
Le test que les données scientifiques survivent à un cycle lecture-écriture-lecture est un modèle puissant.
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)
Cela permet de détecter les bogues de sérialisation des données, les incompatibilités de type DTYPE et les problèmes de segmentation HDF5 qui pourraient silencieusement des résultats corrompus.
Reproductibilité des tests
Gestion des semences déterministes
Les opérations à virgule flottante peuvent être déterministes lorsque la graine est fixe, mais la reproductibilité nécessite également des versions de bibliothèque cohérentes, des indicateurs de compilation et des modèles de décomposition MPI.
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)
Essais à virgule flottante multiplateforme
Différentes plates-formes peuvent produire des résultats à virgule flottante légèrement différents en raison des différences de matériel et de bibliothèque.
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)
Ce que nous recommandons : la stratégie de test
Pour un projet Python scientifique de production, utilisez une stratégie de test en couches.
Doit avoir
- Tests unitaires avec
pytest.approxpour les assertions numériques. - Appareils paramétrés pour des tests systématiques à travers les paramètres.
- Tests de convergence qui vérifient le comportement du solveur à travers le raffinement.
Aurait dû
- Hypothèse Tests basés sur la propriété pour les cas de bord et la robustesse numérique.
- Tests d’intégration pour les flux de travail de simulation en plusieurs étapes.
- Stratégies de moquerie pour les dépendances externes et les interfaces de solveur.
Agréable d’avoir
- Tests de reproductibilité multi-plateformes.
- Data roundtrip teste qui écrit, lise et vérifie les données scientifiques.
- Benchmarks de performance avec
pytest-benchmark.
erreurs courantes
Erreur 1 : utilisation incorrecte des assertions dépendantes de la tolérance
N’écrivez pas de tests numériques comme assert result == expected. Utilisez pytest.approx ou les assertions sensibles à la tolérance pour le code numérique. La tolérance dépend de l’échelle du problème, donc une tolérance fixe peut échouer pour des problèmes plus importants.
Erreur 2 : ignorer la convergence
Si le code utilise des solveurs itératifs, n’ignorez pas les tests de convergence. Un solveur qui fonctionne sur un maillage peut diverger sur un autre. Testez le comportement de convergence, pas seulement le résultat final.
Erreur 3 : Tester uniquement le chemin heureux
Les tests basés sur des propriétés vous obligent à tester des cas de bord que les tests manuels manquent souvent. Ajoutez au moins un test d’hypothèse pour chaque fonction numérique majeure. Il peut révéler des bogues que les tests normaux basés sur des exemples n’attrapent jamais.
Résumé
Les tests unitaires sont nécessaires, mais ils ne sont pas suffisants pour le code scientifique. Les tests basés sur des propriétés, les tests de convergence, les flux de travail d’intégration et les contrôles de reproductibilité répondent aux défis spécifiques que les simulations numériques introduisent.
L’idée clé est que les tests scientifiques ne consistent pas seulement à vérifier que le code produit une bonne réponse. Il s’agit de vérifier que le code se comporte correctement. Il doit converger, conserver, adapter et gérer les cas de bordure avec élégance.
Commencez par les modèles incontournables, puis ajoutez les modèles de devrait avoir au fur et à mesure que la suite de tests mûrit. Utilisez les modèles Nice-to-Have pour durcir la base de code au fil du temps.
Les tests basés sur des propriétés avec Hypothesis sont l’un des ajouts les plus élevés que vous puissiez faire, car ils attrapent les bogues que les tests manuels manquent souvent.
Guides connexes
- Tests unitaires pour le code scientifique : stratégies PyTest pour des projets de recherche – la fondation dont vous avez besoin avant cet article.
- Quand utiliser FEM, FVM ou FDM : une comparaison pratique pour les débutants — Contexte sur les méthodes numériques.
- Études de qualité et de convergence du maillage : un guide pratique pour les simulations scientifiques — contexte de test de convergence connexe.
Que faire ensuite
Si vous venez de commencer à écrire des tests de code scientifique, commencez par les modèles incontournables ci-dessus. Une fois ceux-ci en place, ajoutez des tests d’hypothèse pour les fonctions numériques les plus importantes, en particulier les fonctions appelées à plusieurs reprises dans les simulations.
Pour les équipes travaillant sur des logiciels scientifiques de production, l’investissement dans des tests basés sur la propriété peut être payant rapidement. Il s’agit de la même stratégie utilisée par les principales bibliothèques scientifiques Python et l’un des moyens les plus efficaces de trouver des bogues de cas de bord dans le code numérique.
Si vous avez besoin d’aide pour concevoir une stratégie de test pour un projet de simulation, envisagez de réserver une consultation. Un examen pratique peut associer votre structure de code aux tests unitaires, aux tests de convergence, aux tests d’intégration et aux contrôles de reproductibilité.