Reading Time: 12 minutes

Les tests unitaires ne sont pas négociables pour les logiciels scientifiques fiables. Contrairement aux applications commerciales, le code de recherche manque souvent de tests formels, ce qui conduit à des résultats irréproductibles et à un effort inutile. Ce guide couvre les stratégies PyTest spécifiquement pour les projets Python scientifiques : gestion de la précision numérique avec pytest.approx, isolement des dépendances externes avec moquerie, utilisation efficace des luminaires et paramétrage, et intégration de tests dans des pipelines d’intégration continue. Vous apprendrez quand utiliser la validation en boîte noire par rapport aux résultats publiés et comment concevoir des tests qui survivent à l’évolution du code sans devenir cassants.

Pourquoi les tests unitaires sont importants dans les logiciels de recherche

Les logiciels scientifiques existent dans un espace difficile. Il doit être suffisamment flexible pour explorer de nouvelles hypothèses, mais suffisamment fiable pour que les résultats publiés puissent être reproduits des mois ou des années plus tard. Contrairement aux logiciels commerciaux avec des spécifications claires, le code de recherche évolue souvent parallèlement aux expériences, avec des changements d’exigences à mesure que de nouvelles découvertes émergent.

Les conséquences d’un test inadéquat dans la recherche sont graves :

  • Résultats irréproductibles : différents chercheurs obtiennent des résultats différents à partir du même code
  • Bugs silencieux : erreurs numériques qui semblent petites aggravées individuellement en inexactitudes importantes
  • perte de connaissances : lorsque les développeurs originaux partent, les tests servent de documentation exécutable
  • Efforcement gaspillé : le débogage devient une chasse aux détectives au lieu d’un processus systématique

Les tests unitaires résolvent ces problèmes en validant de petits composants isolés de votre code. Chaque test vérifie qu’une fonction ou une classe spécifique se comporte comme attendue avec des entrées définies. Lorsque les tests passent de manière cohérente dans tous les environnements, vous disposez de la preuve que votre code produit des résultats fiables.

Mais les tests de code scientifique présentent des défis uniques auxquels les approches de tests logiciels standard ne répondent pas entièrement.

Défis uniques de tester le code scientifique

Le code scientifique et numérique diffère des applications commerciales typiques de plusieurs manières qui affectent la stratégie de test.

Précision numérique et erreurs à virgule flottante

L’arithmétique à virgule flottante est intrinsèquement imprécise. En raison de la façon dont les ordinateurs représentent des nombres décimaux, 0.1 + 0.2 n’est pas exactement 0.3 dans la représentation binaire à virgule flottante. Dans des simulations scientifiques impliquant des milliers ou des millions d’opérations, ces petites erreurs s’accumulent.

Un test naïf qui utilise une égalité exacte (==) échouera par intermittence ou sur un autre matériel :

def test_numerical_computation():
    result = complex_simulation()  # returns 1.0000000000000002
    assert result == 1.0  # FAILS! Even though the difference is negligible

La solution consiste à utiliser des comparaisons basées sur la tolérance. PyTest fournit pytest.approx() à cette fin :

def test_numerical_computation():
    result = complex_simulation()
    assert result == pytest.approx(1.0, rel=1e-9, abs=1e-12)

rel (tolérance relative) est utile pour des valeurs de toute grandeur ; abs (Tolérance absolue) traite les cas où la valeur attendue est proche de zéro. Les valeurs par défaut sont rel=1e-6 et abs=1e-12, mais le code scientifique nécessite souvent des tolérances plus strictes.

Réponses « correctes » inconnues

Dans de nombreux scénarios de recherche, vous n’avez pas une sortie correcte connue. La simulation pourrait explorer un territoire inconnu. Comment testez-vous le code lorsque vous ne savez pas quelle devrait être la réponse ?

Plusieurs stratégies fonctionnent :

  1. Opérations inverses : si votre code calcule B = f(A), testez également cela A ≈ f⁻¹(B).
  2. Lois sur la conservation : pour les simulations physiques, vérifiez que la masse, l’énergie ou la quantité de mouvement est conservée dans le cadre de la tolérance.
  3. Cas limitants : test du comportement dans des limites simplifiées là où des solutions analytiques existent.
  4. Test de régression : stockez les sorties d’une exécution approuvée et détectez les modifications inattendues.

Dépendances externes et calcul lourd

Le code scientifique dépend souvent de :

  • Grands ensembles de données (téraoctets d’entrée de simulation)
  • Solveurs ou bibliothèques externes (packages HPC, bibliothèques Fortran)
  • E/S de fichiers avec des formats complexes
  • Connexions ou API de base de données

L’exécution du système complet dans chaque test unitaire n’est pas pratique. Vous avez besoin d’isolement.

Fonctionnalités PyTest qui résolvent les problèmes de tests de recherche

PyTest propose plusieurs fonctionnalités particulièrement précieuses pour le code scientifique.

Appareils pour la configuration et le démontage

Les luminaires encapsulent le code de configuration qui s’exécute avant les tests. Pour les tests scientifiques, les accessoires peuvent :

  • Créer des données de test temporaires ou des maillages
  • Initialiser des objets de simulation avec des paramètres connus
  • Nettoyer les fichiers temporaires après les tests
  • Fournir des configurations de test réutilisables
import pytest
import tempfile
import numpy as np

@pytest.fixture
def simple_mesh():
    """Create a small 1D mesh for testing."""
    from fipy import Grid1D
    return Grid1D(dx=0.1, nx=10)

@pytest.fixture
def diffusion_solver(simple_mesh):
    """Set up a diffusion solver on the test mesh."""
    from fipy import CellVariable, DiffusionTerm
    var = CellVariable(name="concentration", mesh=simple_mesh, value=1.0)
    eq = DiffusionTerm(coeff=1.0) == 0
    return var, eq

Paramétrage de plusieurs scénarios

Au lieu d’écrire des fonctions de test distinctes pour des cas similaires, utilisez @pytest.mark.parametrize pour exécuter la même logique de test avec des entrées différentes.

@pytest.mark.parametrize("dx,nx,expected_volume", [
    (0.1, 10, 1.0),
    (0.01, 100, 1.0),
    (0.001, 1000, 1.0),
])
def test_mesh_volume(dx, nx, expected_volume):
    """Test that mesh volume matches domain size."""
    mesh = Grid1D(dx=dx, nx=nx)
    assert mesh.cellVolumes.sum() == pytest.approx(expected_volume)

La paramétrisation est particulièrement utile pour :

  • Test des cas de bord (valeurs nulles, très petits/grands nombres)
  • Vérification du comportement sur différentes résolutions de maillage
  • Validation de plusieurs types de conditions aux limites
  • Vérification de diverses valeurs de propriétés de matériaux

Pour le code de recherche, vous pouvez paramétrer les résultats de référence connus des articles publiés.

Moquement des dépendances externes

La moquerie remplace les vraies dépendances par des contrefaçons contrôlées. Cela isole l’unité testée et rend les tests plus rapides et plus fiables.

Quand se moquer du code scientifique :

  • Fichiers de données externes : remplacez les grands ensembles de données par des données synthétiques minimales qui exercent les mêmes chemins de code
  • Solveurs HPC : simulez les bibliothèques Fortran coûteuses avec des implémentations Pure Python qui renvoient des résultats connus
  • API réseau : STUB Remote Services qui fournissent des paramètres ou une configuration
  • Générateurs de nombres aléatoires : semez-les pour produire des séquences déterministes
from unittest.mock import patch, MagicMock

def test_simulation_with_external_data():
    # Mock the data loading function to return small, known data
    with patch('mycode.load_large_dataset') as mock_load:
        mock_load.return_value = np.array([1.0, 2.0, 3.0])
        result = run_simulation()
        assert result.converged

Des directives importantes : simulez les dépendances de votre propre code, et non les bibliothèques tierces que vous ne contrôlez pas. Suivez le principe « Ne vous moquez pas de ce que vous ne possédez pas ».

Utilisation de pytest.approx pour les comparaisons numériques

Les comparaisons à virgule flottante doivent tenir compte des erreurs d’arrondi. L’objet approx de PyTest gère cela avec élégance :

def test_diffusion_solution():
    """Test that diffusion reaches expected steady state."""
    concentration = solve_diffusion(time=100.0)
    expected = 0.5  # analytical steady state for this boundary condition
    assert concentration.mean() == pytest.approx(expected, rel=1e-6)

Vous pouvez également utiliser approx avec les tableaux :

def test_array_computation():
    result = compute_field()
    expected = np.array([1.0, 2.0, 3.0])
    assert result == pytest.approx(expected)

Pour le code scientifique, choisissez les tolérances basées sur :

  • La précision numérique de vos méthodes (par exemple, la différence finie du second ordre a une erreur de troncature O(dx²))
  • La précision requise par votre application (Tolérance de l’ingénierie par rapport à la recherche exploratoire)

Développement axé sur les tests pour des projets de recherche

Le développement piloté par les tests (TDD) suit un cycle simple : rédiger un test d’échec, puis écrire un code minimal pour le faire passer, puis refactoriser. Alors que TDD est bien établi dans les logiciels commerciaux, les projets de recherche y résistent souvent en raison des contraintes de temps perçues.

La réalité : TDD fait gagner du temps dans la recherche en attrapant des bogues avant de se propager à travers des expériences. Les tests d’écriture vous obligent d’abord à clarifier l’interface et le comportement attendu de chaque fonction avant la mise en œuvre.

TDD adapté à l’exploration scientifique :

  1. Commencez par un modèle ou un algorithme simple que vous comprenez analytiquement
  2. Rédiger des tests qui valident par rapport aux résultats connus (solutions analytiques, cas limitant)
  3. Implémenter le code pour réussir ces tests
  4. Étendez le modèle de manière incrémentielle, en ajoutant des tests pour chaque nouvelle capacité
  5. Lorsque vous découvrez un bogue, écrivez un test qui le reproduit en premier, puis corrigez-le

TDD fonctionne bien pour :

  • Fonctions utilitaires (génération de maillage, transformations de coordonnées)
  • Opérations mathématiques (manipulations matricielles, fonctions spéciales)
  • Pipelines de traitement de données (analyse, filtrage, normalisation)
  • Validation de la configuration

TDD est moins adapté pour :

  • Code hautement exploratoire où l’interface elle-même est incertaine
  • Des scripts uniques qui ne seront pas réutilisés
  • Code qui dépend de ressources externes non encore disponibles

En pratique, une approche hybride fonctionne le mieux : rédiger des tests pour des composants stables et fondamentaux ; Utilisez des tests d’intégration plus légers pour les sections expérimentales.

Organisation de tests pour des projets scientifiques

Où tester les fichiers ? PyTest offre une flexibilité :

my_research_project/
├── src/
│   └── mypackage/
│       ├── __init__.py
│       ├── solver.py
│       └── mesh.py
├── tests/
│   ├── __init__.py
│   ├── test_solver.py
│   ├── test_mesh.py
│   └── conftest.py  # shared fixtures
├── data/
│   └── reference_results/  # stored outputs for regression tests
├── .github/
│   └── workflows/
│       └── ci.yml  # GitHub Actions CI configuration
├── pyproject.toml
└── README.md

Conventions clés :

  • Conserver les tests dans un répertoire séparé tests/ parallèle à src/ (ou lib/)
  • Nommer les fichiers de test test_*.py ou *_test.py
  • Nommer les fonctions de test test_*() pour autoriser la découverte automatique de PyTest
  • Utilisez conftest.py pour les luminaires partagés entre plusieurs fichiers de test

Pour les projets basés sur Fipy, structurez les tests pour correspondre à la hiérarchie des modules :

fipy_project/
├── fipy/
│   ├── meshes/
│   │   └── grid1d.py
│   └── terms/
│       └── diffusion.py
├── tests/
│   ├── meshes/
│   │   └── test_grid1d.py
│   └── terms/
│       └── test_diffusion.py

Intégration avec intégration continue

Les tests unitaires ne fournissent de la valeur que s’ils fonctionnent de manière cohérente. Intégration continue (CI) Automatise l’exécution des tests chaque fois que le code change.

GitHub Actions fournit une configuration CI simple pour les projets Python :

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.10", "3.11"]

    steps:
    - uses: actions/checkout@v3
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -e .[test]
    - name: Run tests with pytest
      run: |
        pytest --cov=src --cov-report=xml --cov-report=html
    - name: Upload coverage
      uses: codecov/codecov-action@v3

CI assure :

  • Les tests passent sur plusieurs plates-formes (Linux, macOS, Windows)
  • Les tests réussissent sur plusieurs versions de Python
  • La couverture du code est suivie dans le temps
  • Les demandes d’extraction sont validées avant la fusion

Pour les logiciels de recherche, pensez à ajouter :

  • Tests qui s’exécutent avec différentes versions de dépendances clés (numpy, scipy, fipy)
  • Vérifications de la régression des performances (les algorithmes ne ralentissent pas)
  • La documentation construite pour vérifier les exemples fonctionne toujours

erreurs courantes et comment les éviter

Sur la base de la recherche et des meilleures pratiques de l’industrie, voici les erreurs de test unitaire les plus courantes dans le code scientifique :

1. Tester les détails de l’implémentation au lieu du comportement

Test de la mise en œuvre interne rend les tests cassants. Lorsque vous refactorisez le code, les tests doivent toujours passer si le comportement externe est correct.

# ❌ Bad: tests internal state
def test_algorithm_updates_counter():
    obj = MyAlgorithm()
    obj.step()
    assert obj.counter == 1  # fragile if counter implementation changes

# ✅ Better: tests observable outcome
def test_algorithm_produces_correct_result():
    obj = MyAlgorithm()
    result = obj.run()
    assert result == expected

2. Ignorer la tolérance numérique

Les comparaisons exactes sur les résultats à virgule flottante provoquent des tests flous qui échouent au hasard ou sur un matériel différent. Utilisez toujours pytest.approx() ou des assertions similaires basées sur la tolérance pour les sorties numériques.

3. Écrire des tests lents

Les tests unitaires doivent fonctionner rapidement (millisecondes, pas secondes). Si un test est lent :

  • Il ne sera pas exécuté assez fréquemment
  • Les développeurs ignoreront l’exécution de la suite de tests complète
  • CI devient cher et lent

Solutions :

  • Utilisez de petits ensembles de données synthétiques au lieu de grands réels
  • simuler des calculs externes coûteux
  • Séparez les tests d’intégration lente des tests unitaires rapides
  • Utilisez la paramétrisation à bon escient : n’exécutez pas de milliers de variations dans chaque test réussi

4. Ne pas isoler les tests

Les tests ne doivent pas dépendre les uns des autres ou de l’état global. Chaque test doit :

  • Créez ses propres données de test (utilisez des appareils)
  • Nettoyer après lui-même
  • ne pas compter sur l’ordre d’exécution
# ❌ Bad: shared mutable state
results = []

def test_first():
    results.append(1)

def test_second():
    assert results == [1]  # fails if tests run in wrong order

# ✅ Better: independent tests
def test_first():
    result = compute_something()
    assert result == 1

def test_second():
    result = compute_something_else()
    assert result == 2

5. Sauter les tests sans raison valable

@pytest.mark.skip doit être utilisé avec parcimonie. Si un test est ignoré parce que l’environnement manque de quelque chose, utilisez pytest.importorskip() au niveau du module ou rendez la dépendance facultative dans la configuration CI.

6. Écrire de vagues assertions

Les tests doivent exprimer clairement ce qui est vérifié et pourquoi.

# ❌ Unclear: what's being tested?
assert result != None

# ✅ Clear: specific expectation with context
assert result.converged is True, "Solver should converge for well-posed problem"

7. Chemins de codage en dur et hypothèses d’environnement

Utilisez des répertoires et des luminaires temporaires plutôt que des chemins fixes. Le luminaire tmp_path de PyTest fournit un nouveau répertoire temporaire pour chaque test.

Exemple pratique : test d’un solveur de diffusion Fipy

Rassemblons ces stratégies avec un exemple concret pertinent pour le public de Matforge.

# tests/test_diffusion.py
import pytest
import numpy as np
from fipy import Grid1D, CellVariable, DiffusionTerm

@pytest.fixture
def simple_1d_grid():
    """Create a uniform 1D grid for diffusion testing."""
    return Grid1D(dx=0.1, nx=50)

@pytest.fixture
def steady_state_diffusion(simple_1d_grid):
    """Set up diffusion with Dirichlet boundaries at both ends."""
    mesh = simple_1d_grid
    var = CellVariable(name="concentration", mesh=mesh, value=0.0)
    var.constrain(1.0, mesh.facesLeft)
    var.constrain(0.0, mesh.facesRight)
    eq = DiffusionTerm(coeff=1.0) == 0
    return var, eq

def test_mesh_volume(simple_1d_grid):
    """Total domain length should equal nx * dx."""
    expected_length = 50 * 0.1
    assert simple_1d_grid.cellVolumes.sum() == pytest.approx(expected_length)

def test_diffusion_conservation(steady_state_diffusion):
    """For steady diffusion with no sources, total mass should be conserved."""
    var, eq = steady_state_diffusion
    eq.solve(var, dt=1.0)
    # With fixed values at boundaries, mass enters from left and exits right
    # In steady state, flux in should equal flux out
    left_flux = var.faceValue[simple_1d_grid.facesLeft.value]
    right_flux = var.faceValue[simple_1d_grid.facesRight.value]
    # Flux direction: positive means flow to the right
    assert left_flux > 0  # mass enters from left
    assert right_flux < 0  # mass exits from right (negative direction)
    assert abs(left_flux + right_flux) == pytest.approx(0, abs=1e-10)

def test_diffusion_solution_shape(steady_state_diffusion):
    """Concentration should decrease monotonically from left to right."""
    var, eq = steady_state_diffusion
    eq.solve(var, dt=1.0)
    # Steady state should be linear for constant diffusivity
    x = simple_1d_grid.cellCenters[0]
    expected = 1.0 - x / (50 * 0.1)  # linear from 1 to 0
    assert var.value == pytest.approx(expected, rel=1e-5)

@pytest.mark.parametrize("dx,nx", [(0.1, 50), (0.05, 100), (0.02, 250)])
def test_mesh_independence(dx, nx):
    """Solution should converge as mesh refines."""
    mesh = Grid1D(dx=dx, nx=nx)
    var = CellVariable(name="c", mesh=mesh, value=0.0)
    var.constrain(1.0, mesh.facesLeft)
    var.constrain(0.0, mesh.facesRight)
    eq = DiffusionTerm(coeff=1.0) == 0
    eq.solve(var, dt=1.0)
    # Check at midpoint
    mid_idx = nx // 2
    assert var.value[mid_idx] == pytest.approx(0.5, rel=0.1)

Cet exemple illustre :

  • Appareils pour une configuration de test réutilisable
  • Paramétrage pour tester plusieurs résolutions
  • Affirmations numériques basées sur la tolérance
  • Tester les principes physiques (conservation, linéarité)
  • Noms et assertions de test clairs et descriptifs

Lorsque les tests unitaires ne suffisent pas

Les tests unitaires valident des composants individuels, mais les logiciels de recherche ont également besoin :

  • Tests d’intégration : vérifiez que plusieurs modules fonctionnent correctement ensemble
  • Tests de système : exécuter des simulations complètes de bout en bout et comparer aux sorties connues
  • Tests de performance : s’assurer que les algorithmes répondent aux attentes de complexité de calcul
  • Vérifications de visualisation : repérer les erreurs de rendu évidentes (comparaison d’images automatisée si possible)

Une stratégie de test complète pour les projets de recherche comprend plusieurs niveaux de test, les tests unitaires formant la base.

Ce que nous recommandons : une stratégie de test pragmatique pour des projets de recherche

Sur la base des preuves tirées des meilleures pratiques de logiciels scientifiques, voici notre approche recommandée :

Commencez par des tests unitaires fondamentaux

Commencez par écrire des tests pour :

  • Fonctions mathématiques de base (fonctions spéciales, transformations de coordonnées)
  • Utilitaires de génération et de manipulation de maillage
  • Implémentations des conditions aux limites
  • Routines d’entrée/sortie de données (validation, mise en forme)

Ces composants sont stables, ont des comportements clairs attendus et sont réutilisés dans de nombreuses simulations.

Adoptez pytest.approx en standard

N’utilisez jamais == pour les résultats à virgule flottante. Utilisez toujours pytest.approx() avec les tolérances appropriées. Faites-en une convention d’équipe.

Utiliser largement les luminaires

Les luminaires réduisent la duplication et rendent les tests plus maintenables. Créer des luminaires pour :

  • Maillages communs (grille de test 1D, 2D, 3D)
  • Configuration des conditions aux limites standard
  • Solutions d’analyse connues
  • Gestion des fichiers/répertoires temporaires

Intégrer CI tôt

Configurez des actions GitHub (ou similaires) avant que le projet ne devienne grand. Exécutez automatiquement des tests sur :

  • Chaque poussée
  • Chaque demande d’extraction
  • Constructions nocturnes prévues (pour attraper la dérive environnementale)

Mesurer et suivre la couverture du code

Utilisez pytest-cov pour mesurer quelles parties de votre code sont exercées par des tests. Visez au moins 80 % de couverture sur les modules de base, mais ne vous obsédez pas à plus de 100 % : l’objectif est la confiance, pas un score parfait.

Rédigez des tests lorsque vous corrigez des bogues

Chaque fois qu’un bogue est signalé, écrivez un test qui le reproduit avant de corriger. Cela garantit que le bogue ne réapparaîtra pas plus tard.

Gardez les tests rapides

Si un test dure plus de quelques secondes, pensez à :

  • Utilisation de petits problèmes de test
  • Se moquer des opérations coûteuses
  • Déplacement vers une suite de tests d’intégration qui s’exécute moins fréquemment

Guides connexes

Conclusion

Les tests unitaires transforment les logiciels de recherche à partir de scripts fragiles en instruments fiables et reproductibles. Bien que la mise en place de tests complets nécessite un investissement initial, le gain s’explique par une réduction du temps de débogage, une confiance accrue dans les résultats et une collaboration plus fluide.

Le framework PyTest fournit des outils puissants – aménagements, paramétrage, moquerie et approx() – qui répondent directement aux défis du code scientifique : précision numérique, dépendances externes et réponses correctes inconnues. Associées à une intégration continue, ces pratiques garantissent que les tests s’exécutent de manière cohérente dans tous les environnements.

N’oubliez pas que les tests ne consistent pas à atteindre la perfection. Il s’agit de renforcer suffisamment la confiance dans votre code pour que vous puissiez faire confiance à ses résultats lorsque cela compte le plus. Commencez par les composants de base, rédigez des tests qui expriment des attentes claires et élargissent progressivement la couverture au fur et à mesure que le projet se développe.

Votre futur moi et toute personne qui hérite de votre code vous remerciera.

Prochaines étapes

Prêt à ajouter des tests à votre projet de recherche ?

  1. Installer PyTest : pip install pytest
  2. Créer un répertoire tests/ avec un simple fichier de test
  3. Rédigez un test pour une fonction de base en utilisant pytest.approx
  4. Configurer un workflow d’actions GitHub pour exécuter des tests automatiquement
  5. Élargissez progressivement la couverture lorsque vous modifiez le code

Pour une aide personnalisée à la mise en œuvre de stratégies de test dans votre logiciel de recherche spécifique, nous contacter pour une consultation (visitez notre page d’accueil pour plus d’informations).