This directory holds the automated tests for HydroModPy. It is split into five release tiers plus focused auxiliary tiers with different purposes, budgets, and selection rules.
tests/
├── conftest.py # shared scratch root, update-goldens flag, shared fixtures
├── unit/ # one module under test, 2 s target, no real I/O
├── integration/ # cross-module workflows with shared fixtures, < 10 s
├── regression/ # golden-reference tests for full workflows
│ ├── fast/ # routine non-regression tier
│ ├── extensive/ # deeper end-to-end non-regression tier
│ └── reference/ # committed golden JSON files
├── validation/ # scientific benchmarks (analytical, MMS)
│ ├── analytical/
│ │ ├── steady/
│ │ └── transient/
│ └── numerical/
├── characterization/ # what a produced run offers a reader, plus the
│ # double-execution comparator
├── e2e/ # subprocess-level command scenarios
├── performance/ # pytest-benchmark storage-wrapper benchmarks
├── contract/ # compatibility contracts across implementations
└── _helpers/ # pytest-local helpers and shared builders
validity_frame/ is a separate installable package with its own
validity_frame/tests/; it is not part of this suite.
| Tier | Target budget | Purpose | Selection |
|---|---|---|---|
unit |
2 s per test (design target), 60 s hard timeout | One module under test, pure-Python logic, no external binaries, no real I/O outside tmp_path. |
pytest tests/unit/ |
integration |
≤ 10 s per test | Cross-module workflows exercising more than one HydroModPy subpackage. Two shared fixtures are available from the root conftest (tmp_workspace, minimal_config); most tests build their own. No golden files. |
pytest tests/integration/ or pytest -m integration |
regression/fast |
≤ 5 min | Full launcher/pipeline workflows on mini fixtures, compared to committed golden signatures. | pytest tests/regression/fast/ |
regression/extensive |
≤ 30 min | Deeper end-to-end golden checks with heavier fixtures. | pytest tests/regression/extensive/ |
validation |
≤ 30 min | Numerical results vs analytical / MMS references with documented tolerances. | pytest tests/validation/ |
characterization |
≤ 1 min | One real hmp run on a committed example project, read back only through the file formats a stranger would use. Holds exactly five xfail(strict=True) cases, one per claim the code makes and does not honour; a strict xfail that starts passing fails the tier, which is how a repaired claim gets noticed. |
pytest tests/characterization/ |
Declared in the repository-root pytest.ini:
| Marker | Meaning |
|---|---|
regression |
compares output against a committed reference dataset |
intercomparison |
solver-to-solver regression based on compact comparison metrics |
validation |
compares output against an analytical/numerical reference |
analytical |
validation against a closed-form solution |
steady |
steady-state case |
transient |
transient case |
fast |
cheap tier (fast regression or quick validation) |
slow |
long-running test; filtered out of the push gate by -m "not slow" in main-ci.yml, still runs in ci-weekly.yml |
extensive |
deeper regression tier |
nwt |
MODFLOW-NWT / MODPATH / MT3DMS |
mf6 |
MODFLOW 6 / GWT |
petsc |
Linux PETSc Boussinesq runtime |
integration |
cross-module workflow test (auto-applied to tests/integration/) |
coverage |
long-running coverage-focused test |
solver_sanity |
benchmark built directly on the solver SDK (e.g. flopy); validates the external solver against an analytical reference, not the hydromodpy pipeline |
e2e |
end-to-end pipeline scenarios |
performance |
performance baseline benchmarks |
unit |
unit-tier tests, auto-applied by path |
characterization |
characterization-tier tests, auto-applied by path |
boussinesq |
Boussinesq solver specific test |
network |
requires network access |
binary |
requires a solver binary on PATH |
gpu |
requires a GPU at runtime |
allow_subprocess |
permits subprocess calls inside unit/ |
timeout |
per-test timeout in seconds |
xdist_group |
pytest-xdist worker grouping |
The fast/extensive markers under tests/regression/ are auto-applied
based on the file's subdirectory (see tests/conftest.py).
# Unit tests (fastest feedback)
pytest tests/unit/ -q # all unit tests
pytest tests/unit/solver -q # a subpath
pytest tests/unit/ -q -k metrics_nse # keyword filter
# Regression - fast tier (parallel)
pytest tests/regression/fast/ -q -n auto
pytest -m "regression and intercomparison" -q -n 1
# Regression - extensive tier (serial)
pytest tests/regression/extensive/ -q -n 1
# Validation - all analytical cases
pytest tests/validation/ -q
# Characterization - one real run, read back from disk
pytest tests/characterization/ -q
# Double-execution comparator on the four frozen projects (about one minute)
python -m tests.characterization.comparator --frozen --report ~/hmp-parity.md
# Marker selection
pytest -m "regression and fast" -q
pytest -m "validation and steady" -q
pytest -m nwt -q
pytest -m "not slow" -q
# Parallel execution (xdist)
pytest tests/unit/ -q -n auto
pytest tests/regression/fast/ -q -n autoThe hmp CLI wraps the most common invocations:
hmp test unit # → pytest tests/unit/
hmp test regression --fast # → pytest tests/regression/fast/
hmp test regression --fast --intercomparison
hmp test regression --extensive
hmp test regression --update-goldensScratch data lands in a repository-external directory to keep the working tree clean:
| Variable | Default | Purpose |
|---|---|---|
HMP_TEST_SCRATCH_ROOT |
/tmp/hydromodpy_tests/ |
Shared scratch root; set this to redirect all test artifacts. |
HMP_COVERAGE |
unset | When 1, enables coverage collection during regression runs. |
PYTEST_DEBUG_TEMPROOT |
<scratch>/pytest |
Points pytest's tmp_path generator inside the scratch root. |
The conftest sets TMPDIR/TMP/TEMP to a subdirectory of the scratch root
so spawned subprocesses inherit the same cleanup policy.
Regression tests compare the output of a pipeline run to a committed
signature - a statistical summary of fields stored as JSON under
tests/regression/reference/golden_references/{fast,extensive}/.
When a regression assertion fails:
- Identify the golden file referenced by the failing test (path is printed in the assertion message).
- Open the corresponding JSON and compare the stored signature to the freshly computed one.
- Decide whether the difference is:
- a legitimate code change - update the golden (see below),
- a numerical drift - investigate the responsible commit,
- a platform difference - document and, if needed, tighten the tolerance or mark the test platform-specific.
Never edit a JSON file by hand. Use the dedicated CLI switch, which regenerates the golden from the current output:
# Refresh every golden referenced by a test run
pytest tests/regression/ -q -n auto --update-goldens
# Refresh the goldens of a single test module
pytest tests/regression/fast/test_simulation_regression_fast_mf6_regression.py \
-q --update-goldensThen commit the updated JSON files in a dedicated commit. The commit message must state the scientific or code-level reason the signature changed.
Numerical comparisons use numpy.testing.assert_allclose (or equivalent
math.isclose) with an explicit rtol/atol. Prefer statistical
signatures (min, p25, p50, p75, p95, max, mean, std, sum) over full-array
dumps so goldens stay compact and cross-platform stable.
Validation tolerances live in tests/TOLERANCES.md, with some
case-local files under validation_cases/**/tolerances.toml when the
benchmark owns a named envelope. tests/_helpers/tolerances.py::tol()
parses the shared table for tests that need a named tolerance.
- unit/ - one importable module under test, one behaviour per test,
2 s target. The hard limit enforced by
tests/conftest.pyis a 60 s per-test timeout; anything that needs more must carry@pytest.mark.slow, which takes it out of the push gate. No external binaries. Usetmp_pathfor any I/O. - integration/ - cross-module test (pipeline + catalog, planner +
adapters, …). Budget ≤ 10 s, no golden files. Two shared fixtures are
available from the root conftest,
tmp_workspaceandminimal_config; neither is mandatory. - regression/ - exercise a full launcher / pipeline on a fixture, then
compare a committed signature. Tag with
@pytest.mark.regressionand (if solver-specific)@pytest.mark.nwtor@pytest.mark.mf6. - validation/ - exercise a physical case against a known analytical solution; document the reference and the tolerance rationale.
The shared fixtures come from tests/conftest.py:
tmp_workspace(tmp_path)yields an initialized workspace directory (standarddata/,projects/, onedata/<variable>/folder per variable) ready to back aSimulationCatalog.minimal_config(tmp_path)returns the smallest validHydroModPyConfig(syntheticgeographic+ aworkspacepointed attmp_path / "project"). Extend via.model_copy(update=...).
Prefer @pytest.mark.parametrize over copy-pasting N near-identical
tests. Keep test files under ~400 LOC: a file that grows beyond that is
usually hiding several independent suites.
The active GitHub Actions gates are split across .github/workflows/:
- main-ci.yml - PR + push gate: quality (ruff), secrets, architecture,
a
--collect-onlygate over the whole tree, fast / unit (-m "not slow") / contract / integration / regression-fast tiers, package-smoke, typing. - ci-nightly.yml - nightly extensive regression, validation (not petsc), integration with coverage.
- ci-weekly.yml - cross-OS unit + fast-regression + analytical, serial e2e, full regression.
- perf.yml - pytest-benchmark drift gate over
tests/performance/. - petsc-smoke.yml - Linux PETSc Boussinesq smoke subset (conda env).
Validation, extensive regression, and e2e are not part of the fast
PR-blocking suite; run validation manually before a release with
pytest tests/validation/ -q.