Reading Time: 11 minutes

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:

  1. Operaciones inversas: Si su código calcula B = f(A), también pruebe que A ≈ f⁻¹(B).
  2. Leyes de conservación: para simulaciones físicas, verifique que la masa, la energía o el impulso se conserven dentro de la tolerancia.
  3. Casos limitantes: Comportamiento de prueba en límites simplificados donde existen soluciones analíticas.
  4. 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:

  1. Comience con un modelo o algoritmo simple que entienda analíticamente
  2. Escribir pruebas que validen contra resultados conocidos (soluciones analíticas, casos limitantes)
  3. Implementar el código para pasar esas pruebas
  4. Extender el modelo de forma incremental, agregando pruebas para cada nueva capacidad
  5. 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 a src/ (o lib/)
  • Nombre de archivo de prueba test_*.py o *_test.py
  • Funciones de prueba de nombre test_*() Para permitir el descubrimiento automático de PyTest
  • Use conftest.py para 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

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?

  1. Instalar PyTest: pip install pytest
  2. Cree un directorio tests/ con un archivo de prueba simple
  3. Escriba una prueba para una función central usando pytest.approx
  4. Configure un flujo de trabajo de acciones de GitHub para ejecutar pruebas automáticamente
  5. 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).