Las pruebas unitarias no son negociables para software científico de confianza. A diferencia de las aplicaciones comerciales, el código de investigación a menudo carece de pruebas formales, lo que conduce a resultados irreproducibles y un esfuerzo desperdiciado. Esta guía cubre las estrategias de PyTest específicamente para proyectos científicos de Python: manejo de precisión numérica con pytest.approx, aislando dependencias externas con simulacro, uso de accesorios y parametrización de manera eficiente e integrando pruebas en canalizaciones de integración continua. Aprenderá cuándo usar la validación de caja negra con los resultados publicados y cómo diseñar pruebas que sobrevivan a la evolución del código sin volverse frágil.
Por qué es importante la prueba unitaria en el software de investigación
El software científico existe en un espacio desafiante. Debe ser lo suficientemente flexible como para explorar nuevas hipótesis, pero lo suficientemente confiable como para que los resultados publicados puedan reproducirse meses o años después. A diferencia del software comercial con especificaciones claras, el código de investigación a menudo evoluciona junto con los experimentos, y los requisitos cambian a medida que surgen nuevos descubrimientos.
Las consecuencias de las pruebas inadecuadas en la investigación son graves:
- Resultados irreproducibles: diferentes investigadores obtienen diferentes resultados del mismo código
- Errores silenciosos: errores numéricos que parecen pequeños individualmente compuestos en inexactitudes significativas
- Pérdida de conocimiento: cuando los desarrolladores originales se van, las pruebas sirven como documentación ejecutable
- Esfuerzo desperdiciado: la depuración se convierte en una caza de detectives en lugar de un proceso sistemático
Las pruebas unitarias abordan estos problemas validando componentes pequeños y aislados de su código. Cada prueba verifica que una función o clase específica se comporta como se espera dadas las entradas definidas. Cuando las pruebas pasan de manera consistente entre entornos, tiene evidencia de que su código produce resultados confiables.
Pero probar el código científico presenta desafíos únicos que los enfoques de prueba de software estándar no abordan completamente.
Desafíos únicos de probar el código científico
El código científico y numérico difiere de las aplicaciones comerciales típicas de varias maneras que afectan la estrategia de prueba.
Precisión numérica y errores de punto flotante
La aritmética de punto flotante es inherentemente imprecisa. Debido a cómo los equipos representan números decimales, 0.1 + 0.2 no es exactamente 0.3 en la representación de punto flotante binario. En simulaciones científicas que involucran miles o millones de operaciones, estos pequeños errores se acumulan.
Una prueba ingenua que utiliza una igualdad exacta (==) fallará de forma intermitente o en diferentes hardware:
def test_numerical_computation():
result = complex_simulation() # returns 1.0000000000000002
assert result == 1.0 # FAILS! Even though the difference is negligible
La solución es utilizar comparaciones basadas en la tolerancia. PyTest proporciona pytest.approx() para este propósito:
def test_numerical_computation():
result = complex_simulation()
assert result == pytest.approx(1.0, rel=1e-9, abs=1e-12)
rel (tolerancia relativa) es útil para valores de cualquier magnitud; abs (tolerancia absoluta) maneja casos en los que el valor esperado es cercano a cero. Los valores predeterminados son rel=1e-6 y abs=1e-12, pero el código científico a menudo requiere tolerancias más estrictas.
Respuestas «correctas» desconocidas
En muchos escenarios de investigación, no tiene una salida correcta conocida. La simulación podría estar explorando territorio desconocido. ¿Cómo se prueba el código cuando no sabe cuál debería ser la respuesta?
Varias estrategias funcionan:
- Operaciones inversas: Si su código calcula
B = f(A), también pruebe queA ≈ f⁻¹(B). - Leyes de conservación: para simulaciones físicas, verifique que la masa, la energía o el impulso se conserven dentro de la tolerancia.
- Casos limitantes: Comportamiento de prueba en límites simplificados donde existen soluciones analíticas.
- Pruebas de regresión: Almacene los resultados de una ejecución de confianza y detecte cambios inesperados.
Dependencias externas y cálculo pesado
El código científico a menudo depende de:
- Grandes conjuntos de datos (terabytes de entrada de simulación)
- Solvers o Bibliotecas externas (Paquetes HPC, Bibliotecas Fortran)
- E/S de archivo con formatos complejos
- Conexiones de base de datos o API
Ejecutar el sistema completo en cada prueba unitaria no es práctico. Necesitas aislamiento.
Características de PyTest que resuelven problemas de prueba de investigación
PyTest proporciona varias características que son particularmente valiosas para el código científico.
Accesorios para la instalación y el desmontaje
Los accesorios encapsulan el código de instalación que se ejecuta antes de las pruebas. Para las pruebas científicas, los accesorios pueden:
- Crear datos o mallas de prueba temporales
- Inicializar objetos de simulación con parámetros conocidos
- Limpiar archivos temporales después de las pruebas
- Proporcionar configuraciones de prueba reutilizables
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
Parametrización para múltiples escenarios
En lugar de escribir funciones de prueba separadas para casos similares, use @pytest.mark.parametrize para ejecutar la misma lógica de prueba con diferentes entradas.
@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 parametrización es especialmente útil para:
- Casos de prueba de borde (valores cero, números muy pequeños/grandes)
- Verificación del comportamiento en diferentes resoluciones de malla
- Validación de varios tipos de condiciones de contorno
- Comprobación de varios valores de propiedad del material
Para el código de investigación, puede parametrizar contra resultados de referencia conocidos de artículos publicados.
Burlarse de las dependencias externas
La burla reemplaza a las dependencias reales con falsificaciones controladas. Esto aísla la unidad bajo prueba y hace que las pruebas sean más rápidas y confiables.
Cuándo simular en código científico:
- Archivos de datos externos: reemplace los grandes conjuntos de datos con datos sintéticos mínimos que ejercen las mismas rutas de código
- Solvers HPC: simulacros costosas de Fortran con implementaciones de Python puras que devuelven resultados conocidos
- API de red: servicios remotos de stub que proporcionan parámetros o configuración
- Generadores de números aleatorios: sembrarlos para producir secuencias deterministas
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
Directriz importante: simula las dependencias de su propio código, no las bibliotecas de terceros que no controla. Sigue el principio «No te burles de lo que no tienes».
Usando pytest.approx para comparaciones numéricas
Las comparaciones de punto flotante deben tener en cuenta los errores de redondeo. El objeto approx de PyTest maneja esto de forma elegante:
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)
También puede usar approx con matrices:
def test_array_computation():
result = compute_field()
expected = np.array([1.0, 2.0, 3.0])
assert result == pytest.approx(expected)
Para el código científico, elija tolerancias basadas en:
- La precisión numérica de sus métodos (por ejemplo, la diferencia finita de segundo orden tiene error de truncamiento O(dx²))
- La precisión requerida por su aplicación (tolerancia de ingeniería frente a investigación exploratoria)
Desarrollo basado en pruebas para proyectos de investigación
El desarrollo basado en pruebas (TDD) sigue un ciclo simple: escriba una prueba defectuosa, luego escriba un código mínimo para que pase, luego refactorice. Si bien TDD está bien establecido en el software comercial, los proyectos de investigación a menudo se resisten debido a las limitaciones de tiempo percibidas.
La realidad: TDD ahorra tiempo en la investigación al detectar errores antes de que se propaguen a través de experimentos. Las pruebas de escritura primero lo obligan a aclarar la interfaz y el comportamiento esperado de cada función antes de la implementación.
TDD adaptado para la exploración científica:
- Comience con un modelo o algoritmo simple que entienda analíticamente
- Escribir pruebas que validen contra resultados conocidos (soluciones analíticas, casos limitantes)
- Implementar el código para pasar esas pruebas
- Extender el modelo de forma incremental, agregando pruebas para cada nueva capacidad
- Cuando descubra un error, escriba una prueba que lo reproduzca primero, luego corrija
TDD funciona bien para:
- Funciones de utilidad (generación de malla, transformaciones de coordenadas)
- Operaciones Matemáticas (Manipulaciones de Matriz, Funciones Especiales)
- Pipelines de procesamiento de datos (análisis, filtrado, normalización)
- Validación de configuración
TDD es menos adecuado para:
- Código altamente exploratorio donde la interfaz en sí es incierta
- Scripts únicos que no se reutilizarán
- Código que depende de los recursos externos aún no disponibles
En la práctica, un enfoque híbrido funciona mejor: escribir pruebas para componentes básicos y estables; Utilice pruebas de integración más ligeras para secciones experimentales.
Organización de pruebas para proyectos científicos
¿Dónde deberían vivir los archivos de prueba? PyTest ofrece flexibilidad:
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
Convenciones clave:
- Mantenga las pruebas en un directorio separado
tests/paralelo asrc/(olib/) - Nombre de archivo de prueba
test_*.pyo*_test.py - Funciones de prueba de nombre
test_*()Para permitir el descubrimiento automático de PyTest - Use
conftest.pypara accesorios compartidos en varios archivos de prueba
Para proyectos basados en FIPY, pruebas de estructura para que coincidan con la jerarquía de módulos:
fipy_project/
├── fipy/
│ ├── meshes/
│ │ └── grid1d.py
│ └── terms/
│ └── diffusion.py
├── tests/
│ ├── meshes/
│ │ └── test_grid1d.py
│ └── terms/
│ └── test_diffusion.py
Integración con integración continua
Las pruebas unitarias solo proporcionan valor si se ejecutan de manera consistente. La integración continua (CI) automatiza la ejecución de la prueba cada vez que cambia el código.
Acciones de GitHub proporciona una configuración de CI sencilla para proyectos de 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 asegura:
- Las pruebas pasan en múltiples plataformas (Linux, macOS, Windows)
- Las pruebas pasan en varias versiones de Python
- La cobertura del código se rastrea a lo largo del tiempo
- Las solicitudes de extracción se validan antes de fusionar
Para el software de investigación, considere agregar:
- Pruebas que se ejecutan con diferentes versiones de dependencias clave (numpy, scipy, fipy)
- Comprobaciones de regresión de rendimiento (asegurar que los algoritmos no se ralenticen)
- Las compilaciones de documentación para verificar los ejemplos aún funcionan
Errores comunes y cómo evitarlos
Con base en las mejores prácticas de investigación y de la industria, aquí están los errores de prueba unitarias más comunes en el código científico:
1. Prueba de detalles de implementación en lugar de comportamiento
Probar la implementación interna hace que las pruebas sean frágiles. Cuando refactoriza el código, las pruebas aún deben pasar si el comportamiento externo es correcto.
# ❌ 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. Ignorar la tolerancia numérica
Las comparaciones exactas en los resultados de coma flotante provocan pruebas descamadas que fallan aleatoriamente o en diferentes hardware. Utilice siempre pytest.approx() o afirmaciones similares basadas en tolerancia para salidas numéricas.
3. Escribir pruebas lentas
Las pruebas unitarias deben ejecutarse rápidamente (milisegundos, no segundos). Si una prueba es lenta:
- no se ejecutará con la frecuencia suficiente
- Los desarrolladores omitirán la ejecución de la suite de pruebas completa
- IC se vuelve caro y lento
Soluciones:
- Utilice pequeños conjuntos de datos sintéticos en lugar de grandes reales
- simulacros de cálculos externos caros
- Separe las pruebas de integración lenta de las pruebas unitarias rápidas
- Use la parametrización sabiamente: no ejecute miles de variaciones en cada pase de prueba
4. No aislar las pruebas
Las pruebas no deben depender unas de otras ni del estado global. Cada prueba debe:
- Cree sus propios datos de prueba (utilice accesorios)
- Limpiar después de sí mismo
- no depender de la orden de ejecución
# ❌ 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. Saltarse las pruebas sin una buena razón
@pytest.mark.skip se debe usar con moderación. Si se omite una prueba porque el entorno carece de algo, use pytest.importorskip() a nivel de módulo o haga que la dependencia sea opcional en la configuración de CI.
6. Escribir afirmaciones vagas
Las pruebas deben expresar claramente lo que se está verificando y por qué.
# ❌ 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. Caminos de codificación y supuestos ambientales
Utilice directorios y accesorios temporales en lugar de rutas fijas. El dispositivo tmp_path de PyTest proporciona un directorio temporal nuevo para cada prueba.
Ejemplo práctico: Probar un solucionador de difusión Fipy
Pongamos estas estrategias junto con un ejemplo concreto relevante para la audiencia 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)
Este ejemplo demuestra:
- Accesorios para la configuración de prueba reutilizable
- Parametrización para probar múltiples resoluciones
- Aserciones numéricas basadas en la tolerancia
- Prueba de principios físicos (conservación, linealidad)
- Nombres y afirmaciones de prueba claros y descriptivos
Cuando la prueba unitaria no es suficiente
Las pruebas unitarias validan los componentes individuales, pero el software de investigación también necesita:
- Pruebas de integración: verifique que varios módulos funcionen juntos correctamente
- Pruebas del sistema: Ejecute simulaciones completas de extremo a extremo y compare con las salidas conocidas
- Pruebas de rendimiento: garantizar que los algoritmos cumplan con las expectativas de complejidad computacional
- Verificaciones de visualización: spot Errores de renderizado obvios (comparación de imágenes automatizada cuando sea factible)
Una estrategia de prueba completa para proyectos de investigación incluye múltiples niveles de prueba, con pruebas unitarias que forman la base.
Lo que recomendamos: Una estrategia de prueba pragmática para proyectos de investigación
Basado en la evidencia de las mejores prácticas de software científico, aquí está nuestro enfoque recomendado:
Comience con las pruebas unitarias fundamentales
Comience escribiendo pruebas para:
- Funciones matemáticas básicas (funciones especiales, transformaciones de coordenadas)
- Utilidades de generación y manipulación de mallas
- Implementaciones de condiciones de contorno
- Rutinas de entrada/salida de datos (validación, formato)
Estos componentes son estables, tienen comportamientos claros esperados y se reutilizan en muchas simulaciones.
Adopte pytest.approx como estándar
Nunca use == para resultados de coma flotante. Utilice siempre pytest.approx() con las tolerancias apropiadas. Haga de esta una convención de equipo.
Usar accesorios extensivamente
Los accesorios reducen la duplicación y hacen que las pruebas sean más mantenibles. Crear accesorios para:
- Mallas comunes (rejillas de prueba 1D, 2D, 3D)
- Configuraciones de condiciones de contorno estándar
- soluciones analíticas conocidas
- Gestión de archivos/directorios temporales
integrar CI temprano
Configure acciones de GitHub (o similares) antes de que el proyecto crezca. Ejecutar pruebas automáticamente en:
- cada empujón
- Cada solicitud de extracción
- Construcciones nocturnas programadas (para atrapar la deriva ambiental)
Medir y rastrear la cobertura del código
Use pytest-cov para medir qué partes de su código se ejercen mediante las pruebas. Apunta a tener al menos un 80% de cobertura en los módulos centrales, pero no te obsesiones más del 100%: el objetivo es la confianza, no una puntuación perfecta.
Escribe pruebas cuando corrijas errores
Cada vez que se informa un error, escriba una prueba que lo reproduzca antes de corregirlo. Esto garantiza que el error no volverá a aparecer más tarde.
Mantenga las pruebas rápidas
Si una prueba toma más de unos pocos segundos, considere:
- Uso de problemas de prueba más pequeños
- burlándose de las operaciones caras
- Moverlo a un conjunto de pruebas de integración que se ejecuta con menos frecuencia
Guías relacionadas
- seguimiento de la deuda técnica en software de investigación – Gestión de la deuda de prueba a medida que los proyectos evolucionan
- administrar el software de investigación a través de tickets – usando el seguimiento de problemas para coordinar los esfuerzos de prueba
- Reproducibilidad y su papel en la depuración – Cómo las pruebas permiten la depuración sistemática
- Cómo escribir un informe de error claro y útil – proporcionando la información necesaria para crear pruebas de regresión
- Solicitudes de funciones frente a informes de errores: Conociendo la diferencia – Clasificación de problemas que impulsan el desarrollo de pruebas
Conclusión
La prueba unitaria transforma el software de investigación de scripts frágiles en instrumentos confiables y reproducibles. Si bien la configuración de pruebas integrales requiere una inversión inicial, el pago viene en un tiempo de depuración reducido, una mayor confianza en los resultados y una colaboración más fluida.
El marco PyTest proporciona herramientas poderosas (fixtures, parametrización, burlas y approx()) que abordan directamente los desafíos del código científico: precisión numérica, dependencias externas y respuestas correctas desconocidas. Combinadas con la integración continua, estas prácticas aseguran que las pruebas se ejecuten de manera consistente en todos los entornos.
Recuerde: las pruebas no se trata de lograr la perfección. Se trata de generar suficiente confianza en su código para que pueda confiar en sus resultados cuando más importa. Comience con los componentes principales, escriba pruebas que expresen expectativas claras y amplíen gradualmente la cobertura a medida que crece el proyecto.
Su futuro yo, y cualquiera que herede su código, se lo agradecerá.
Próximos pasos
¿Listo para agregar pruebas a su proyecto de investigación?
- Instalar PyTest:
pip install pytest - Cree un directorio
tests/con un archivo de prueba simple - Escriba una prueba para una función central usando
pytest.approx - Configure un flujo de trabajo de acciones de GitHub para ejecutar pruebas automáticamente
- Amplíe gradualmente la cobertura a medida que modifica el código
Para obtener ayuda personalizada para implementar estrategias de prueba en su software de investigación específico, contáctenos para una consulta (visite nuestra página de inicio para obtener más información).