Add a Test#
HydroModPy ships a five-tier test tree under tests/: unit,
integration, e2e, regression, and validation. This
page tells you which tier to pick, which marker to use, and what
fixture to reach for.
For the full role-by-role inventory and how to interpret a failure, see Test Families and Quality Roles.
Pick the tier#
Tier |
When to pick it |
Hard limits |
|---|---|---|
|
One local function, class, or schema in isolation. |
Pure Python, no real I/O, <= 2 s per test, full tier <= 1 min. |
|
Several layers compose without a golden file. |
Allowed to write to |
|
One full user scenario through |
Mid-size case; reads back persisted artefacts. |
|
Detect drift in a known workflow output. |
Two flavours: |
|
Compare the numerical result to a trusted reference (analytical, MMS, calibration twin, solver sanity). |
Tolerances live in |
Markers in pytest.ini#
Available markers:
regression, validation, analytical, steady,
transient, fast, slow, extensive, nwt, mf6,
petsc, boussinesq, intercomparison, integration,
e2e, unit, coverage, performance, solver_sanity,
network, binary, gpu, allow_subprocess, timeout,
xdist_group.
Add the marker on the function:
import pytest
@pytest.mark.regression
@pytest.mark.mf6
@pytest.mark.fast
def test_my_workflow(tmp_path):
...
The CLI selects subsets:
hmp test unit
hmp test regression --fast --mf6
hmp test regression --extensive
hmp test validation --analytical --steady
pytest -m solver_sanity -q
pytest -m petsc -q
hmp test wraps only the unit, regression, and validation
tiers. Run the integration, e2e, and MMS suites with raw
pytest (for example pytest tests/integration/ or
pytest tests/e2e/).
Where to put the file#
Mirror the package under test:
tests/unit/<package>/<module>/test_<feature>.py
tests/integration/<scenario>/test_*.py
tests/regression/fast/test_launcher_*.py
tests/regression/extensive/test_launcher_*.py
tests/validation/analytical/<case>/test_*.py
tests/validation/numerical/<case>/test_*.py
tests/validation/mms/<case>/test_*.py
tests/validation/calibration/<case>/test_*.py
Reusable fixtures#
Top-level conftest.py exposes:
tmp_workspace– a fresh workspace directory (aPathrooted intmp_pathwith the standarddata//projects/layout).minimal_config– a ready-to-runHydroModPyConfigfor the smallest case (extend viamodel_copy(update=...)).
For a DuckDB/Zarr catalog seeded on tmp_path, use the
tests._helpers.fixtures_catalog.simulation_catalog context manager.
Use them to avoid re-implementing scaffolding in every test.
Unit-test pattern#
# tests/unit/data/test_load_result.py
from hydromodpy.data.contracts.results import LoadResult
def test_load_result_concat(tmp_path):
a = LoadResult(points=[...], fields=[], warnings=[])
b = LoadResult(points=[...], fields=[], warnings=["warn"])
merged = a.merge(b)
assert len(merged.points) == 2
assert merged.warnings == ["warn"]
Integration pattern#
# tests/integration/test_overview_workflow.py
import pytest
import hydromodpy as hmp
@pytest.mark.integration
def test_overview(tmp_workspace, minimal_overview_toml):
hmp.run(minimal_overview_toml) # [workflow] mode = "overview"
assert (tmp_workspace / "data").exists()
Regression pattern#
# tests/regression/fast/test_simulation_regression_fast_mf6.py
import pytest
from tests.regression.golden_utils import (
load_golden_reference,
resolve_tiered_golden_file,
snapshot_signature,
write_golden_reference,
)
@pytest.mark.regression
@pytest.mark.mf6
@pytest.mark.fast
def test_simulation_regression_fast_mf6(tmp_path, update_goldens):
signature = snapshot_signature(run_workflow(tmp_path, ...))
golden = resolve_tiered_golden_file(
test_file=__file__, filename="simulation_regression_fast_mf6.json"
)
if update_goldens:
write_golden_reference(golden, signature)
assert signature == load_golden_reference(golden)
Refresh the golden with pytest tests/regression/ --update-goldens;
review the diff before committing.
Validation pattern#
A validation case lives in two trees:
the benchmark logic in
validation_cases/<case>/{run_case.py, comparison.py, metadata.toml, tolerances.toml};the pytest entry in
tests/validation/<family>/test_*.pythat imports the comparison function and asserts metrics.
The split exists so the same case can be run as an automated test or
launched manually for figure-first diagnosis (python -m
validation_cases.run_cases).
Tolerances are never inline magic numbers. A case envelope lives in the
case’s tolerances.toml and is read through comparison.tolerances;
a single documented scalar from tests/TOLERANCES.md is read through
tests/_helpers/tolerances.py::tol('<slug>'). See tests/TOLERANCES.md
for the global policy and tests/unit/test_tolerances_single_source.py
for the drift guard.
Solver-sanity pattern#
When the test should validate the external solver rather than
the HydroModPy launcher, mark it solver_sanity:
@pytest.mark.solver_sanity
def test_modflow6_against_theis(tmp_path):
...
That subset is meant to flag solver-binary drift independently of HydroModPy’s orchestration.
Pitfalls flagged by the layer matrix#
The test layer must not import
hydromodpyprivate modules with a leading underscore (_api,_lazy,_bootstrap).A unit test must not write to a network resource. Mock at the
HTTPClientlevel (core/io/http_client.py).Validation tests must keep tolerances in their case-local
tolerances.toml(case envelopes) or intests/TOLERANCES.mdviatol('<slug>')(documented scalars); do not hard-code numeric thresholds in the pytest file.
Coverage expectation#
Coverage is gated by Codecov, not by pyproject: the patch target
is 80 % (your diff must be at least 80 % covered) and the project
target is auto (overall coverage must not drop). [tool.coverage]
fail_under in pyproject.toml is not enforced by CI. Raise coverage
by asserting real behavior or a physical/mathematical invariant, never by
adding a test purely to hit a line.
See also#
Test Families and Quality Roles for the full ladder.
Test Inventory for the current suite snapshot by family and subsystem.
Contribute for the contributor workflow.
tests/README.mdandvalidation_cases/README.mdfor the in-repo conventions.