Testing research software
Use this skill when writing tests for research code, setting up a test
framework, deciding what and how much to test, or designing a CI test
matrix that has grown across compilers, platforms, and dependency
versions. The goal is code whose results others can trust and reproduce,
so favour tests that are automated, saved with the code, and run on every
change.
Decide what kind of tests to write
Always start with functional testing (does the software produce
correct outputs for given inputs), then add non-functional testing
only where a requirement demands it (performance, usability,
security, compatibility, compliance). The decision rule: unit tests
always; integration/system tests once components interact;
regression tests whenever behaviour must stay stable; non-functional
tests keyed to explicit requirements (many users -> performance;
multiple platforms -> compatibility).
Choose tactics per test: black-box (behaviour without knowing
internals) versus white-box (specific internal paths). The full
type-by-type toolbox follows.
The wider test-type toolbox
Unit tests are the floor, not the toolbox. Match the test type to
the risk being retired:
- Unit tests: one small unit of functionality in isolation - the
minimum bar for any research code, and where TDD lives.
- Integration tests: components together - the file reader feeding
the model, the pipeline stages chained; most research bugs live
at these seams, not inside single functions.
- System / end-to-end tests: the whole tool as a user runs it (CLI
invocation on a real small dataset, checking outputs) - one good
end-to-end test catches whole classes of wiring mistakes.
- Regression tests: every fixed bug becomes a test that fails if it
returns (rseng-debugging, rseng-lessons-learned) - the suite as
institutional memory.
- Golden-master / snapshot tests: pin current outputs and diff
future runs against them, with tolerances for numerics
(rseng-numerical-accuracy) - the workhorse for legacy code
(rseng-legacy-code) and format stability
(rseng-scientific-file-formats).
- Property-based tests: state an invariant ("sorting is idempotent",
"energy is conserved", "the fit residual never grows when data
matches the model") and let the framework generate hostile inputs
(Hypothesis) - dramatically better than hand-picked cases for
numerical and parsing code, and the cheap sibling of fuzzing
(rseng-security).
- Performance/benchmark regression tests: guard runtimes that
matter (rseng-performance-profiling's asv discipline).
- Smoke tests: a seconds-fast subset (import, --help, tiny run)
wired first in CI so broken builds fail in seconds, not after the
full matrix.
- Mutation testing (occasionally): mutate the code and check tests
notice - the honest audit of whether a green suite actually
asserts anything; use it to spot-check critical modules, not as a
gate.
- Acceptance/validation against the science: the reference-case
tests below - for research software, THE test type that matters
most.
Write good tests (F.I.R.S.T.)
Apply these properties to every test:
- Fast: run quickly so feedback is immediate.
- Isolated/Independent: each test checks one responsibility and does not
depend on the state or ordering of other tests.
- Repeatable: deterministic; same input gives same result regardless of
environment.
- Self-validating: automated pass/fail, no manual inspection of output.
- Thorough/Timely: cover edge cases and error paths (not just happy
paths), and write tests at the right time - ideally test-first.
Additional checklist when authoring or reviewing a test:
- Give the test a descriptive name that states what it verifies.
- Verify one condition per test.
- Use inputs whose correct output you already know.
- Keep tests in a dedicated
tests/ folder, version-controlled and shipped
with the code.
- Do no harm: tests belong in the test environment, never wired into
production.
Test-driven development
Consider a test-first policy: write the failing test just before the code
that makes it pass. This forces small, testable units from the start
instead of refactoring for testability later.
Principles to keep expectations honest
State these when advising, so nobody over-trusts a green test suite:
- Testing shows the presence of defects, never their absence.
- Exhaustive testing is impossible - prioritise instead of chasing every
path.
- Test early and often for rapid feedback.
- Defects cluster: found one in a unit, look for more there.
- Pesticide paradox: re-running identical tests finds nothing new; add new
cases to find new defects.
- Testing is context-dependent: match the approach to the software type.
Coverage guidance
- Aim for high coverage to shrink the space of undetected bugs, but do not
treat 100% as the goal.
- 100% coverage does not mean bug-free.
- Skip testing well-tested third-party/library code and language built-ins.
- Prioritise critical paths, complex logic, edge cases, and any code that
carries "reputational risk" - i.e. could distort reported results.
- Keep a balance: automate the repeatable checks, reserve manual testing
for exploratory and usability work where human judgement matters.
Automate with a test framework, then CI
Progress from informal manual checks (fine for first drafts, but forgotten
once the editor closes) to saved test functions, to a full framework:
- Pick the framework for the language: pytest (Python), testthat (R), JUnit
(Java), the Test standard library (Julia).
- Frameworks auto-discover tests by naming convention (files/functions
named
test_* or *_test), run them, compare actual vs expected, and
emit a report.
- Wire the framework into Continuous Integration so tests run
automatically on every push/merge on an integration machine (e.g. GitHub
Actions, GitLab CI/CD), not just on demand locally.
- Automated + CI testing buys wider coverage, earlier error detection,
lower maintenance, and consistent runs across environments and
platforms.
Choose the strongest tools, not the default ones
Pick the best current tool for the job and say why - defaults and
familiarity are not reasons:
- Python: pytest over the stdlib unittest module for anything not
explicitly constrained to the standard library - plain assert
with rich failure introspection, fixtures over setUp inheritance,
parametrization instead of copy-pasted cases, and the plugin
ecosystem (coverage, hypothesis, nbval, benchmark). unittest is
the right call ONLY when the constraint is "no dependencies at
all" - and then say that constraint out loud.
- Property-based: Hypothesis alongside pytest for invariant-rich
code. Coverage: coverage.py via pytest-cov, measured not chased.
- Other ecosystems follow the same rule: the community's strongest
current framework (testthat for R, Catch2/GoogleTest for C++,
the language guide knows - rseng-language-guides), not the oldest
bundled one.
This is a pack-wide principle, not a testing quirk: when any skill
picks a tool, prefer the strongest current option for the user's
context, name the runner-up, and give the one-line reason - and
revisit choices as ecosystems move (rseng-dependency-management's
currency discipline applies to tool choices too).
Manage large CI testing matrices
When research software must support many compilers, library versions,
architectures, and runtimes, a naive full matrix explodes - e.g.
(4 GCC + 6 Clang) x 10 CUDA x 4 CMake x 7 Boost = 2,800 jobs (~9.3 h even
with 30 parallel runners). Use these strategies:
- Prefer pairwise testing over the full matrix. Ensuring every pair of
parameter values appears in at least one job cuts
2,800 combinations to
~100-150 jobs (30-45 min) while keeping all 2-way interaction coverage;
100 is the floor here, since every one of the 10x10 compiler/CUDA pairs
needs a job of its own.
Generate jobs with a library such as allpairspy; random sampling
(~200 jobs) is a weaker fallback.
- Encode exclusion rules to drop known-incompatible combinations (e.g. an
old CUDA with a new GCC, or CUDA on PowerPC) instead of testing them.
- Generate the matrix dynamically. Use GitLab dynamic child pipelines or
GitHub Actions matrix strategies so the job set is computed at runtime
from available resources.
- Speed up builds with containers: pre-built images with compiled
dependencies, multi-stage builds, layer caching, and a registry close to
the runners.
- Use wave scheduling: run fast/critical checks in an early stage, then
medium combinations, then the full slow GPU/HPC matrix. This fails early
and frees shared infrastructure between waves.
- Allow selective testing during development (e.g. commit-message tags like
[cuda-only]) so iterative work does not trigger the whole pipeline.
- Add performance-regression jobs with baselines and thresholds where
performance is a requirement, not just correctness.
- Monitor pipeline health (job duration, queue time, failure rate,
utilisation) and prune the matrix as versions age.
Rollout when adopting this: catalog every parameter dimension, start with
pairwise core-compatibility testing, add specialized hardware incrementally,
then performance testing, then full multi-platform validation. Document why
each parameter and exclusion exists.
- Sustainability note: extensive matrices consume real energy. Run the full
matrix only when it earns its cost (e.g. before releases) and use smaller
subsets for day-to-day development.
A concrete reference stack (NLeSC python-template): pytest with branch
coverage enabled, and a tox matrix spanning the Python versions the
SPEC 0 policy currently designates (the three most recent minors).
Verify the delivered entry point, not just the suite
A green unit-test suite is not the finish line: software has been
handed over "complete" with a broken docker compose up because
nothing ever ran the app the way its users would. Before declaring
work done, verify through the same door the user will enter:
- Ship-with-compose project:
docker compose up --build must
succeed and every service reach healthy; then one real request
per exposed endpoint (curl the API route, load the page).
- CLI: run the actual commands from the README quickstart against
the example data, not only the test suite.
- Library: execute the quickstart snippet in a fresh interpreter.
- Web app with a frontend: the page must load AND talk to its
backend - one round-trip through each integration seam
(frontend-to-API, service-to-service, app-to-database), because
unit tests structurally miss cross-boundary wiring: mismatched
routes, schemas, env vars and ports live exactly there.
Re-run the entry point after every wiring change (routes, schemas,
configuration, env vars) and at every milestone, not once at the
end - a failure found next to its cause is cheap. Never report
completion while the entry point fails or was never run: state
plainly what was run and what passed (rseng-honesty), and run it
yourself before asking the user to (rseng-human-verification).
A functional-correctness measure for analysis code
For analysis-tier code, "the tests pass" is often too weak a claim -
the question is whether the ANALYSIS is right. Give it a quantifiable
answer: validate the pipeline against reference cases with known
expected results (analytic solutions, published benchmark values,
conservation laws and invariants, or a trusted prior implementation)
and report the agreement quantitatively within stated tolerances.
One honest reference-case test measuring functional correctness is
worth more for analysis code than high line coverage - coverage
proves the code ran, the reference case proves it computed the right
thing. Keep the reference values and their provenance in the test
itself, and treat a tolerance change as a scientific decision.
Working with this skill
The generated references.md beside this file lists the source
material and pointers:
- references.md - verified Learn more pointers
Learn more (verified):
Related skills
Check whether any of these applies before moving on:
- rseng-ci-cd - running the suite on every push
- rseng-debugging - every fix becomes a regression test
- rseng-defensive-coding - runtime checks become test assertions
- rseng-green-computing - budgeting energy cost of full matrices
- rseng-legacy-code - characterization tests before changing inherited code
- rseng-numerical-accuracy - choosing tolerances for numerical assertions
1---2name: rseng-testing3description: Covers how to test research software: choosing test types and levels (unit, integration, system, regression, property-based, golden-master), test frameworks and coverage, TDD, validating analysis code against reference cases, and taming CI testing matrices across compilers, platforms and dependency versions. Use when the user asks how to write tests, set up pytest/testthat/JUnit, decide what to test, raise or interpret code coverage, do test-driven development, or when a CI matrix is exploding. Also use PROACTIVELY when new result-bearing code is written without tests, and before declaring any deliverable complete - the shipped entry point must be run and verified working, not only the test suite. For CI pipeline setup see rseng-ci-cd; for review-time test scrutiny see rseng-code-review.4license: CC-BY-4.05---67# Testing research software89Use this skill when writing tests for research code, setting up a test10framework, deciding what and how much to test, or designing a CI test11matrix that has grown across compilers, platforms, and dependency12versions. The goal is code whose results others can trust and reproduce,13so favour tests that are automated, saved with the code, and run on every14change.1516## Decide what kind of tests to write1718Always start with functional testing (does the software produce19correct outputs for given inputs), then add non-functional testing20only where a requirement demands it (performance, usability,21security, compatibility, compliance). The decision rule: unit tests22always; integration/system tests once components interact;23regression tests whenever behaviour must stay stable; non-functional24tests keyed to explicit requirements (many users -> performance;25multiple platforms -> compatibility).26Choose tactics per test: black-box (behaviour without knowing27internals) versus white-box (specific internal paths). The full28type-by-type toolbox follows.2930## The wider test-type toolbox3132Unit tests are the floor, not the toolbox. Match the test type to33the risk being retired:3435- Unit tests: one small unit of functionality in isolation - the36 minimum bar for any research code, and where TDD lives.37- Integration tests: components together - the file reader feeding38 the model, the pipeline stages chained; most research bugs live39 at these seams, not inside single functions.40- System / end-to-end tests: the whole tool as a user runs it (CLI41 invocation on a real small dataset, checking outputs) - one good42 end-to-end test catches whole classes of wiring mistakes.43- Regression tests: every fixed bug becomes a test that fails if it44 returns (rseng-debugging, rseng-lessons-learned) - the suite as45 institutional memory.46- Golden-master / snapshot tests: pin current outputs and diff47 future runs against them, with tolerances for numerics48 (rseng-numerical-accuracy) - the workhorse for legacy code49 (rseng-legacy-code) and format stability50 (rseng-scientific-file-formats).51- Property-based tests: state an invariant ("sorting is idempotent",52 "energy is conserved", "the fit residual never grows when data53 matches the model") and let the framework generate hostile inputs54 (Hypothesis) - dramatically better than hand-picked cases for55 numerical and parsing code, and the cheap sibling of fuzzing56 (rseng-security).57- Performance/benchmark regression tests: guard runtimes that58 matter (rseng-performance-profiling's asv discipline).59- Smoke tests: a seconds-fast subset (import, --help, tiny run)60 wired first in CI so broken builds fail in seconds, not after the61 full matrix.62- Mutation testing (occasionally): mutate the code and check tests63 notice - the honest audit of whether a green suite actually64 asserts anything; use it to spot-check critical modules, not as a65 gate.66- Acceptance/validation against the science: the reference-case67 tests below - for research software, THE test type that matters68 most.6970## Write good tests (F.I.R.S.T.)7172Apply these properties to every test:7374- Fast: run quickly so feedback is immediate.75- Isolated/Independent: each test checks one responsibility and does not76 depend on the state or ordering of other tests.77- Repeatable: deterministic; same input gives same result regardless of78 environment.79- Self-validating: automated pass/fail, no manual inspection of output.80- Thorough/Timely: cover edge cases and error paths (not just happy81 paths), and write tests at the right time - ideally test-first.8283Additional checklist when authoring or reviewing a test:8485- Give the test a descriptive name that states what it verifies.86- Verify one condition per test.87- Use inputs whose correct output you already know.88- Keep tests in a dedicated `tests/` folder, version-controlled and shipped89 with the code.90- Do no harm: tests belong in the test environment, never wired into91 production.9293## Test-driven development9495Consider a test-first policy: write the failing test just before the code96that makes it pass. This forces small, testable units from the start97instead of refactoring for testability later.9899## Principles to keep expectations honest100101State these when advising, so nobody over-trusts a green test suite:102103- Testing shows the presence of defects, never their absence.104- Exhaustive testing is impossible - prioritise instead of chasing every105 path.106- Test early and often for rapid feedback.107- Defects cluster: found one in a unit, look for more there.108- Pesticide paradox: re-running identical tests finds nothing new; add new109 cases to find new defects.110- Testing is context-dependent: match the approach to the software type.111112## Coverage guidance113114- Aim for high coverage to shrink the space of undetected bugs, but do not115 treat 100% as the goal.116- 100% coverage does not mean bug-free.117- Skip testing well-tested third-party/library code and language built-ins.118- Prioritise critical paths, complex logic, edge cases, and any code that119 carries "reputational risk" - i.e. could distort reported results.120- Keep a balance: automate the repeatable checks, reserve manual testing121 for exploratory and usability work where human judgement matters.122123## Automate with a test framework, then CI124125Progress from informal manual checks (fine for first drafts, but forgotten126once the editor closes) to saved test functions, to a full framework:127128- Pick the framework for the language: pytest (Python), testthat (R), JUnit129 (Java), the Test standard library (Julia).130- Frameworks auto-discover tests by naming convention (files/functions131 named `test_*` or `*_test`), run them, compare actual vs expected, and132 emit a report.133- Wire the framework into Continuous Integration so tests run134 automatically on every push/merge on an integration machine (e.g. GitHub135 Actions, GitLab CI/CD), not just on demand locally.136- Automated + CI testing buys wider coverage, earlier error detection,137 lower maintenance, and consistent runs across environments and138 platforms.139140## Choose the strongest tools, not the default ones141142Pick the best current tool for the job and say why - defaults and143familiarity are not reasons:144145- Python: pytest over the stdlib unittest module for anything not146 explicitly constrained to the standard library - plain assert147 with rich failure introspection, fixtures over setUp inheritance,148 parametrization instead of copy-pasted cases, and the plugin149 ecosystem (coverage, hypothesis, nbval, benchmark). unittest is150 the right call ONLY when the constraint is "no dependencies at151 all" - and then say that constraint out loud.152- Property-based: Hypothesis alongside pytest for invariant-rich153 code. Coverage: coverage.py via pytest-cov, measured not chased.154- Other ecosystems follow the same rule: the community's strongest155 current framework (testthat for R, Catch2/GoogleTest for C++,156 the language guide knows - rseng-language-guides), not the oldest157 bundled one.158159This is a pack-wide principle, not a testing quirk: when any skill160picks a tool, prefer the strongest current option for the user's161context, name the runner-up, and give the one-line reason - and162revisit choices as ecosystems move (rseng-dependency-management's163currency discipline applies to tool choices too).164165## Manage large CI testing matrices166167When research software must support many compilers, library versions,168architectures, and runtimes, a naive full matrix explodes - e.g.169(4 GCC + 6 Clang) x 10 CUDA x 4 CMake x 7 Boost = 2,800 jobs (~9.3 h even170with 30 parallel runners). Use these strategies:171172- Prefer pairwise testing over the full matrix. Ensuring every pair of173 parameter values appears in at least one job cuts ~2,800 combinations to174 ~100-150 jobs (~30-45 min) while keeping all 2-way interaction coverage;175 100 is the floor here, since every one of the 10x10 compiler/CUDA pairs176 needs a job of its own.177 Generate jobs with a library such as `allpairspy`; random sampling178 (~200 jobs) is a weaker fallback.179- Encode exclusion rules to drop known-incompatible combinations (e.g. an180 old CUDA with a new GCC, or CUDA on PowerPC) instead of testing them.181- Generate the matrix dynamically. Use GitLab dynamic child pipelines or182 GitHub Actions matrix strategies so the job set is computed at runtime183 from available resources.184- Speed up builds with containers: pre-built images with compiled185 dependencies, multi-stage builds, layer caching, and a registry close to186 the runners.187- Use wave scheduling: run fast/critical checks in an early stage, then188 medium combinations, then the full slow GPU/HPC matrix. This fails early189 and frees shared infrastructure between waves.190- Allow selective testing during development (e.g. commit-message tags like191 `[cuda-only]`) so iterative work does not trigger the whole pipeline.192- Add performance-regression jobs with baselines and thresholds where193 performance is a requirement, not just correctness.194- Monitor pipeline health (job duration, queue time, failure rate,195 utilisation) and prune the matrix as versions age.196197Rollout when adopting this: catalog every parameter dimension, start with198pairwise core-compatibility testing, add specialized hardware incrementally,199then performance testing, then full multi-platform validation. Document why200each parameter and exclusion exists.201202- Sustainability note: extensive matrices consume real energy. Run the full203 matrix only when it earns its cost (e.g. before releases) and use smaller204 subsets for day-to-day development.205206A concrete reference stack (NLeSC python-template): pytest with branch207coverage enabled, and a tox matrix spanning the Python versions the208SPEC 0 policy currently designates (the three most recent minors).209210## Verify the delivered entry point, not just the suite211212A green unit-test suite is not the finish line: software has been213handed over "complete" with a broken `docker compose up` because214nothing ever ran the app the way its users would. Before declaring215work done, verify through the same door the user will enter:216217- Ship-with-compose project: `docker compose up --build` must218 succeed and every service reach healthy; then one real request219 per exposed endpoint (curl the API route, load the page).220- CLI: run the actual commands from the README quickstart against221 the example data, not only the test suite.222- Library: execute the quickstart snippet in a fresh interpreter.223- Web app with a frontend: the page must load AND talk to its224 backend - one round-trip through each integration seam225 (frontend-to-API, service-to-service, app-to-database), because226 unit tests structurally miss cross-boundary wiring: mismatched227 routes, schemas, env vars and ports live exactly there.228229Re-run the entry point after every wiring change (routes, schemas,230configuration, env vars) and at every milestone, not once at the231end - a failure found next to its cause is cheap. Never report232completion while the entry point fails or was never run: state233plainly what was run and what passed (rseng-honesty), and run it234yourself before asking the user to (rseng-human-verification).235236## A functional-correctness measure for analysis code237238For analysis-tier code, "the tests pass" is often too weak a claim -239the question is whether the ANALYSIS is right. Give it a quantifiable240answer: validate the pipeline against reference cases with known241expected results (analytic solutions, published benchmark values,242conservation laws and invariants, or a trusted prior implementation)243and report the agreement quantitatively within stated tolerances.244One honest reference-case test measuring functional correctness is245worth more for analysis code than high line coverage - coverage246proves the code ran, the reference case proves it computed the right247thing. Keep the reference values and their provenance in the test248itself, and treat a tolerance change as a scientific decision.249250## Working with this skill251252The generated references.md beside this file lists the source253material and pointers:254255- references.md - verified Learn more pointers256257258Learn more (verified):259 - https://docs.pytest.org - pytest documentation260 - https://hypothesis.readthedocs.io - Hypothesis property-based261 testing for Python262 - https://testthat.r-lib.org - testthat unit testing for R263 - https://coderefinery.github.io/testing/ - CodeRefinery automated264 testing lesson265 - https://book.the-turing-way.org/reproducible-research/testing -266 Turing Way code testing chapter267268269<!-- related-skills:begin -->270271## Related skills272273Check whether any of these applies before moving on:274275- rseng-ci-cd - running the suite on every push276- rseng-debugging - every fix becomes a regression test277- rseng-defensive-coding - runtime checks become test assertions278- rseng-green-computing - budgeting energy cost of full matrices279- rseng-legacy-code - characterization tests before changing inherited code280- rseng-numerical-accuracy - choosing tolerances for numerical assertions281282<!-- related-skills:end -->