Comida clave
- PyTest Basics cubre las pruebas unitarias. Los accesorios, la parametrización y
pytest.approxson su punto de partida, no la línea de meta. - Las pruebas basadas en la propiedad con hipótesis atrapan los casos extremos que a menudo pasan por alto las pruebas manuales. Es utilizado por los principales proyectos científicos de Python como Numpy, JAX y PyTorch.
- Las pruebas de convergencia numérica requieren estrategias de tolerancia específicas del solucionador. Las afirmaciones estándar no capturan el comportamiento de convergencia.
- Las pruebas de integración para los flujos de trabajo de simulación necesitan patrones de burlas que aíslen las dependencias externas mientras se conservan la lógica numérica.
- Las pruebas de reproducibilidad van más allá de las pruebas unitarias. Necesita una gestión determinista de semillas y verificación de punto flotante multiplataforma.
Qué saber primero
Si ya conoce los conceptos básicos de las pruebas unitarias para el código científico, es probable que entienda cómo escribir una función test_, cuándo usar accesorios y cómo pytest.approx maneja las comparaciones de punto flotante. Esa es la línea de base.
El código científico introduce desafíos que las pruebas unitarias estándar no abordan completamente. Los algoritmos numéricos convergen o no logran converger. Las simulaciones dependen de solucionadores externos y de sistemas de E/S. La aritmética de punto flotante puede comportarse de manera diferente en todas las plataformas.
Los casos de borde que nunca ocurren en datos normales, como divisores cero, matrices singulares y relaciones de aspecto extremas, son a menudo donde se esconden los errores más graves.
Este artículo cubre los patrones de prueba para el código científico Python más allá de lo básico.
La pirámide de prueba del código científico
Cada estrategia de prueba tiene un lugar en la pirámide. Para el código científico, la estructura se ve así:
┌─────────────────────────────┐
│ 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
└─────────────────────────────┘
Las pruebas unitarias forman la base. Por encima de ellos se encuentran las pruebas basadas en la propiedad, las pruebas de integración y las pruebas de simulación a nivel de sistema. Estas capas superiores hacen que las pruebas sean útiles para flujos de trabajo científicos reales.
Pruebas basadas en la propiedad con hipótesis
Por qué las pruebas basadas en la propiedad son importantes para el código científico
La hipótesis no es sólo una herramienta de enseñanza. Es utilizado por las principales bibliotecas científicas de Python para encontrar errores en las rutinas numéricas centrales.
Las pruebas basadas en la propiedad cambian el modelo mental de las pruebas. En lugar de escribir pruebas con una entrada específica y una salida esperada, escribe pruebas que verifican las propiedades matemáticas. Luego, la hipótesis genera muchas entradas automáticamente, incluidos los casos de borde que no puede pensar en probar manualmente.
Convertir una prueba escrita a mano en una propiedad basada en la propiedad
Considere esta prueba para un envoltorio de solucionador numérico:
# 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)
Esta prueba verifica un tamaño de malla y un paso de tiempo. Puede pasar incluso si el solucionador falla para mallas más grandes o diferentes valores de paso de tiempo.
Aquí hay una versión de hipótesis:
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
Esto detecta problemas que una prueba escrita a mano puede pasar por alto:
- Casos de esquina en la generación de mallas, incluidos dominios delgados y relaciones de aspecto extremas.
- Sensibilidad de paso de tiempo, especialmente cuando los valores grandes
dtdesestabilizan los esquemas explícitos. - Casos de borde flotante, incluidos límites de redondeo y rangos numéricos inusuales.
La lista de verificación de la propiedad para funciones científicas
Antes de escribir una prueba de propiedad, identifique qué propiedades debe satisfacer la función.
| Tipo de propiedad | Ejemplo | Por qué importa |
|---|---|---|
| Conservación | La masa, la energía o el momento se conservan | Previene la deriva numérica |
| Simetría | f(a, b) == f(b, a) para operadores simétricos |
Captura errores de implementación asimétrica |
| Escalada | f(k*a, k*b) == k^n * f(a, b) para funciones homogéneas |
Pruebas de consistencia dimensional |
| monotonidad | La temperatura disminuye con la distancia en un simple problema de calor | Comprueba la corrección física |
| Límites | Los valores permanecen dentro de un rango válido | Previene los resultados no físicos |
| Comportamiento nulo | f(0) Devuelve el valor de caso de borde esperado |
Protege contra la división por casos cero y singulares |
Pruebas de convergencia numérica
Por qué las pruebas de convergencia son diferentes
Pruebas unitarias Comprobar si el código produce una respuesta específica. Las pruebas de convergencia comprueban si el código se acerca a la respuesta correcta a medida que se refinan los parámetros.
Esto es crítico para los solucionadores iterativos, los esquemas de paso de tiempo y los estudios de refinamiento de malla. Las afirmaciones estándar como assert result == expected no capturan el comportamiento de convergencia. Necesita probar los patrones de convergencia.
Pruebas de convergencia del solucionador
Diferentes suites de solucionador utilizan diferentes criterios de convergencia. El punto clave es que los criterios de convergencia son específicos del solucionador, y la tolerancia predeterminada puede no ser apropiada para todos los problemas.
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}"
estrategias de tolerancia
Las pruebas deben cubrir los criterios de tolerancia más comunes utilizados por la pila del solucionador.
@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
Probando casos de divergencia
También debe probar que el código maneja la divergencia con gracia.
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")
Pruebas de integración para flujos de trabajo de simulación
Por qué importa las pruebas de integración
Las pruebas unitarias verifican las funciones individuales. Las pruebas de integración verifican que las funciones funcionen juntas correctamente.
Para el código científico, esto significa probar simulaciones de varios pasos, canalizaciones de datos, patrones de acoplamiento de solucionador y flujos de trabajo de E/S.
Patrón 1: burlas de las dependencias externas en el código científico
Cuando pruebe las funciones que llaman a API externas, bases de datos o sistemas de archivos, utilice una simulación para aislar la lógica numérica.
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)
Esto aísla la lógica de preparación de datos de la escritura del archivo HDF5. La prueba verifica que su código formatea y empaqueta los datos correctamente sin esperar en E/S.
Patrón 2: accesorios parametrizados para barridos de simulación
En lugar de probar manualmente algunos tamaños de malla, use accesorios parametrizados para barridos sistemáticos.
@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)
Patrón 3: Prueba de flujos de trabajo de varios pasos
Las simulaciones científicas a menudo tienen canalizaciones de varios pasos: generación de mallas, resolución, posprocesamiento y producción de escritura.
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()
Mofas avanzadas para bibliotecas científicas
El reto
Probar el código científico puede ser lento porque las funciones numpy, scipy y fipy pueden tener tipos de devolución complejos y rutas de ejecución costosas. Moverlos le permite probar rápidamente la lógica de envoltura.
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)
Esto verifica la lógica de envoltura sin esperar a que se ejecute el solucionador real. La prueba puede terminar en milisegundos en lugar de segundos.
Prueba de ida y vuelta para datos científicos
La prueba de que los datos científicos sobreviven a un ciclo de lectura-escritura y lectura es un patrón poderoso.
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)
Esto detecta errores de serialización de datos, desajustes de tipo DD y problemas de fragmentación de HDF5 que podrían corromper los resultados de forma silenciosa.
Prueba de reproducibilidad
Manejo determinista de semillas
Las operaciones de punto flotante pueden ser deterministas cuando la semilla es fija, pero la reproducibilidad también requiere versiones de biblioteca consistentes, banderas de compilación y patrones de descomposición de 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)
Pruebas de punto flotante multiplataforma
Diferentes plataformas pueden producir resultados de punto flotante ligeramente diferentes debido a las diferencias de hardware y biblioteca.
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)
Lo que recomendamos: La estrategia de prueba
Para un proyecto científico de Python de producción, utilice una estrategia de prueba en capas.
Debe tener
- Pruebas unitarias con
pytest.approxpara afirmaciones numéricas. - Accesorios parametrizados para pruebas sistemáticas a través de parámetros.
- Pruebas de convergencia que verifican el comportamiento del solucionador a través del refinamiento.
Debería tener
- Pruebas basadas en propiedades de hipótesis para casos de borde y robustez numérica.
- Pruebas de integración para flujos de trabajo de simulación de varios pasos.
- Estrategias de burla para dependencias externas e interfaces de solucionador.
bueno tener
- Pruebas de reproducibilidad multiplataforma.
- Pruebas de datos de ida y vuelta que escriben, leen y verifican los datos científicos.
- Puntos de referencia de rendimiento con
pytest-benchmark.
Errores comunes
Error 1: usar incorrectamente las aserciones dependientes de la tolerancia
No escriba pruebas numéricas como assert result == expected. Use pytest.approx o afirmaciones con reconocimiento de tolerancia para código numérico. La tolerancia depende de la escala del problema, por lo que una tolerancia fija puede fallar para problemas mayores.
Error 2: ignorar la convergencia
Si el código utiliza solucionadores iterativos, no omita las pruebas de convergencia. Un solucionador que funciona en una malla puede divergir en otro. Comportamiento de convergencia de prueba, no solo el resultado final.
Error 3: Probando solo el camino feliz
Las pruebas basadas en la propiedad lo obligan a probar los casos de borde que a menudo pasan por alto las pruebas manuales. Agregue al menos una prueba de hipótesis para cada función numérica principal. Puede revelar errores que las pruebas normales basadas en ejemplos nunca detectan.
Resumen
Las pruebas unitarias son necesarias, pero no son suficientes para el código científico. Las pruebas basadas en propiedades, las pruebas de convergencia, los flujos de trabajo de integración y las comprobaciones de reproducibilidad abordan los desafíos específicos que introducen las simulaciones numéricas.
La idea clave es que las pruebas científicas no se trata solo de verificar que el código produce una respuesta correcta. Se trata de verificar que el código se comporta correctamente. Debe converger, conservar, escalar y manejar los casos de borde con gracia.
Comience con los patrones imprescindibles, luego agregue los patrones deber-tener a medida que madura el conjunto de pruebas. Use los patrones agradables para endurecer la base de código con el tiempo.
Las pruebas basadas en propiedades con hipótesis son una de las adiciones de mayor valor que puede hacer porque detecta errores que a menudo pasan por alto las pruebas manuales.
Guías relacionadas
- pruebas unitarias para código científico: estrategias pytest para proyectos de investigación — La base que necesita antes de esto artículo.
- cuándo usar FEM, FVM o FDM: una comparación práctica para principiantes — Contexto sobre métodos numéricos.
- Estudios de calidad y convergencia de malla: una guía práctica para simulaciones científicas — Contexto de prueba de convergencia relacionado.
Qué hacer a continuación
Si acaba de comenzar a escribir pruebas para código científico, comience con los patrones imprescindibles anteriores. Una vez que están en su lugar, agregue pruebas de hipótesis para las funciones numéricas más importantes, especialmente funciones llamadas repetidamente en simulaciones.
Para los equipos que trabajan en software científico de producción, la inversión en pruebas basadas en la propiedad puede dar sus frutos rápidamente. Es la misma estrategia utilizada por las principales bibliotecas científicas de Python y es una de las formas más efectivas de encontrar errores de casos de borde en código numérico.
Si necesita ayuda para diseñar una estrategia de prueba para un proyecto de simulación, considere reservar una consulta. Una revisión práctica puede asignar su estructura de código a pruebas unitarias, pruebas de convergencia, pruebas de integración y comprobaciones de reproducibilidad.