Add a pytest scaffold and run it in CI - #102
Conversation
Nothing in the repository ran automatically before merging, so a change was only as safe as whatever the author happened to try by hand. This lays the groundwork asked for in #18: the directory layout, the configuration, the isolation fixtures, and a GitHub Actions job, plus enough tests to prove the whole path works end to end. The suite lives in tests/ at the repository root rather than under src/, which is the usual place for a src-layout project: src/ holds only what ships in the wheel, and hatch already packages src/ivert alone. Configuration is a single [tool.pytest.ini_options] block in pyproject.toml, read by both a local run and the workflow, so there is nothing that can drift between them. testpaths keeps collection out of build/, data/ and scratch_data/. The network and slow markers are excluded by addopts, which is what makes a bare "pytest" fast and offline and safe on a machine with no Earthdata credentials; the tests those markers describe come later and are run deliberately with "pytest -m network". IVERT carries process-global state that would otherwise leak into a developer's own environment: a user config file it reads and writes, the module-level Config singleton, platform detection, and an lru_cache over globato's photon classes. An autouse fixture in tests/conftest.py closes all four off. Because that fixture is invisible -- no test mentions it -- tests/test_isolation.py asserts it is actually in effect, so it cannot silently stop applying while the rest of the suite keeps passing against a real ~/.ivert. The CLI smoke tests walk the command tree rather than listing it, so a command added later is covered without anyone remembering to extend them. The workflow follows the conventions the existing three establish, since zizmor audits every workflow on change: empty top-level permissions, persist-credentials false, and commit-pinned actions. Python 3.12 and 3.13 are covered; 3.14 is left out for the same reason publish-to-pypi.yml pins 3.13, which is that fiona has no cp314 wheels yet. The ruff carve-out for tests/ is scoped to that directory rather than added to the global ignore list, and was confirmed necessary rather than added speculatively.
|
@mfisher87, I would love to hear your feedback on whether this architecture for pytest'ing makes sense to you, or whether you would suggest alternatives. This code isn't merged yet, would really appreciate your review before I start adding test that are more difficult to re-arrange later. @matth-love, I believe this is more-or-less consistent with how you arrange unit testing in |
globato dropped fiona in favor of pyogrio, and geopandas has defaulted to pyogrio since 1.0, so nothing in the tree pulls fiona in any more. Every compiled dependency ships cp314 manylinux wheels, so the 3.14 job installs without building anything from source.
|
From the look of it, this is basically how I setup tests in fetchez/transformez as well. Since i'm currently working on transformez this is how it's set up there: tests live in [dependency-groups]
test = [
"pytest",
"pytest-cov",
]and further down: [tool.pytest.ini_options]
addopts = "-m 'not validation'"
markers = [
"health: tests requiring live network access to external APIs",
"validation: scientific validation against external reference systems",
"slow: tests that are too expensive for the normal unit-test suite",
"vdatum: tests requiring the locally installed NOAA VDatum Java engine",
]currently only have default tests, which live in in the 'tests/validation/test_*.py` files I have them defined like: @pytest.mark.validation
@pytest.mark.health
@pytest.mark.slow
def test_station_validation():
results = []
for name, config in TEST_REGIONS.items():
region = Region(*config["bounds"])
result = validate_against_stations(
name,
region,
config["challenge"],
)
if result is not None:
results.append(result)
assert resultsThen just running Not sure if this is the best way to set this up, but seems to work. You don't have to follow this model, if you come up with something better, I can follow your lead as well... |
There was a problem hiding this comment.
This is a great description! We should probably add this, or something like it, to the community compass contributing guide when we decide on an ecosystem-wide standard. I really like this though.
|
And yes, looking closer here I think we have the tests set up almost exactly the same, other than a few small configuration differences. I think you've been a bit more deliberate here than I've been so I think I'll just follow whatever you end up pushing in this PR so the eco-system is set up with basically the same configurations/naming conventions, etc. And we can come up with a 'policy' we (and contributors) can follow in the community compass (based off your CONTRIBUTING.md) |
mfisher87
left a comment
There was a problem hiding this comment.
Sorry, this is a LOT of stuff for a review, but it's also a big PR :) Hope the feedback feels compassionate. I had a rough day today and I'm sorry if any of that leaks through in my comments. ❤️
| # 3.12 is the floor in pyproject; 3.13 is what the release build pins; | ||
| # 3.14 is the newest release. Every compiled dependency ships cp314 | ||
| # wheels, so nothing here has to build from source. |
There was a problem hiding this comment.
| # 3.12 is the floor in pyproject; 3.13 is what the release build pins; | |
| # 3.14 is the newest release. Every compiled dependency ships cp314 | |
| # wheels, so nothing here has to build from source. |
Hope this isn't too nitpicky, but I would suggest cleaning up some of these comments that aren't adding much useful context.
| # 3.12 is the floor in pyproject; 3.13 is what the release build pins; | ||
| # 3.14 is the newest release. Every compiled dependency ships cp314 | ||
| # wheels, so nothing here has to build from source. | ||
| python-version: ["3.12", "3.13", "3.14"] |
There was a problem hiding this comment.
| python-version: ["3.12", "3.13", "3.14"] | |
| python-version: ["3.12", "3.14"] |
I usually do oldest and newest only. If some change in Python breaks 3.13, it will almost certainly break 3.14. Testing all of them is more thorough, but IMO not worth the electricity! I don't feel strongly about this, feel free to reject :)
|
|
||
| - name: "Run the test suite" | ||
| # The network and slow markers are excluded by pyproject's addopts, so | ||
| # this needs no credentials and touches no network beyond the install. |
There was a problem hiding this comment.
This comment could easily become false if we forget to use these markers when we eventually add slow/network-dependent tests. I would remove.
| module-level Config singleton, and cached lookups of the platform and of | ||
| globato's photon classes. Left alone, tests would read and write the developer's | ||
| real configuration and pass or fail depending on whose machine they ran on. The | ||
| autouse fixture below closes all of that off. |
There was a problem hiding this comment.
This is a useful comment, but also calls out some fragility we probably want to fix. These monkeypatches can easily go out of date and silently cause some weird test-time behavior. I largely consider singletons to be a code smell, but sometimes they're necessary and I use them. A "problem" for another day, IMO, just calling it out :)
Let's open an issue to consider removing singletons / global state where possible?
|
|
||
| # photon_classes() is lru_cached over a parse of globato's docstring; clear | ||
| # it on both sides so a test that patches globato neither sees nor leaves a | ||
| # stale result. |
There was a problem hiding this comment.
I especially don't like this. We should expose a variable for photon classes instead of parsing a docstring. Another problem for the future though :)
| the module it covers: `tests/test_cuboid_funcs.py` covers | ||
| `src/ivert/utils/cuboid_funcs.py`. | ||
|
|
||
| An autouse fixture in `tests/conftest.py` isolates the process-global state |
There was a problem hiding this comment.
"autouse" is lingo and we should probably link to relevant docs
There was a problem hiding this comment.
I had to look this up as i'd never heard this term before (i thought it was a typo at first). A doc link would be helpful I think.
| the module it covers: `tests/test_cuboid_funcs.py` covers | ||
| `src/ivert/utils/cuboid_funcs.py`. | ||
|
|
||
| An autouse fixture in `tests/conftest.py` isolates the process-global state |
There was a problem hiding this comment.
This stuff is not really relevant to writing a test, I might move this to its own section. Most developers won't need to care about this.
| addopts = "--strict-markers --strict-config -m 'not network and not slow'" | ||
| markers = [ | ||
| "network: hits NSIDC/Harmony; needs Earthdata credentials in ~/.netrc", | ||
| "slow: runs a full DEM validation; minutes, not seconds", |
There was a problem hiding this comment.
We could be less specific about this marker. A slow test may not run a full DEM validation and still be slow.
| "slow: runs a full DEM validation; minutes, not seconds", | |
| "slow: A test that takes too long to run every time (i.e. tens of seconds or more)", |
| # run the excluded ones deliberately with "pytest -m network". | ||
| addopts = "--strict-markers --strict-config -m 'not network and not slow'" | ||
| markers = [ | ||
| "network: hits NSIDC/Harmony; needs Earthdata credentials in ~/.netrc", |
There was a problem hiding this comment.
Same reason I suggested a change to slow marker description. We could even break "needs credentials" into its own separate marker since tests can have >1 marker. There may be network tests that don't require credentials.
| "network: hits NSIDC/Harmony; needs Earthdata credentials in ~/.netrc", | |
| "network: hits a third-party network dependency; needs Earthdata credentials in ~/.netrc", |
|
|
||
| [tool.ruff.lint.per-file-ignores] | ||
| "conf.py" = ["A001"] | ||
| # tests/ is deliberately not a package, which INP001 objects to. |
There was a problem hiding this comment.
| # tests/ is deliberately not a package, which INP001 objects to. | |
| # tests/ is deliberately not a package, which INP001 forbids. |
Nitpick. I read "objects" as a plural noun at first :P
|
No apologies needed; this is exactly the kind of feedback I'm looking for here. I'll go through all these individually tomorrow. Thank you for putting the time & attention on it! I really appreciate it. |
Closes #18 — or rather, opens it properly. This is the groundwork only: the
layout, the configuration, the isolation fixtures, and a CI job, plus enough
tests to prove the whole path works end to end. The actual test coverage comes
after, and the plan for it is sketched at the bottom.
Infrastructure
Layout. The suite lives in
tests/at the repository root.Root rather than
src/for the usual src-layout reason —src/holds onlywhat ships in the wheel, and
[tool.hatch.build.targets.wheel]already sayspackages = ["src/ivert"].The other real option was
src/ivert/tests/— inside the package, shipped inthe wheel, the way numpy and astropy do it, so an installed copy can be checked
with
pytest --pyargs ivert. I went the other way because the on-disk andend-to-end tests below will need fixture data, and under that layout it ships
to every user and grows the conda package.
publish-to-pypi.ymlalreadysmoke-tests the built wheel, which covers most of that benefit.
tests/is deliberately not a package — no__init__.py— so test filebasenames must stay unique across the tree. Files are named after the module
they cover.
Configuration. One
[tool.pytest.ini_options]block inpyproject.toml,read by both a local run and the workflow. No
pytest.ini, notox.ini,nothing that can drift between them.
testpaths = ["tests"]— Tells the pytest regime to look for tests there,nowhere else.
addoptscarries-m 'not network and not slow', which is what makespytestwith no arguments mean the fast, offline tests. That default iswhat makes it safe in a pre-push hook and safe on a machine with no Earthdata
credentials. Run the excluded ones deliberately with
pytest -m network, orclear the filter with
pytest -m "".--strict-markersand--strict-config— a mistyped@pytest.markbecomesan error instead of a decorator that silently does nothing.
networkandslow. Nothing carries them yet; theyexist so the tests that can't run on a PR have a defined home rather than
being an argument against starting.
networkwould include tests that relyon network access, external data source downloads.
slowindicates longer-running tests (such as end-to-end workflows) that are appropriate to test
prior to version releases but not for basic PR-level unit testing.
Dependencies. A
testgroup next to the existingdocsgroup, so itinstalls the same way Read The Docs already installs docs:
Two packages, deliberately — it won't perturb a conda environment whose
geospatial stack took effort to build.
CI.
.github/workflows/test.yml, on push tomainand every PR. It runsthe same
pytestyou run locally, plus coverage flags. Following theconventions the existing three workflows establish, since
zizmor.ymlauditsevery workflow on change: empty top-level
permissions,persist-credentials: false, commit-pinned actions (both SHAs copied verbatim frompublish-to-pypi.yml). A concurrency group cancels superseded runs.Python 3.12 and 3.13. 3.14 is out for the same reason
publish-to-pypi.ymlpins 3.13 — fiona, via globato, has no cp314 wheels and would build from
source.
Docs. A
Testssection inCONTRIBUTING.md: how to run them, what themarkers mean, the no-
__init__.pynaming constraint, and that fixture data isgenerated rather than committed.
No
CHANGELOG.mdentry — CI and test-only changes have no user-visible effect,which
CONTRIBUTING.mdalready carves out.The initial tests
28 tests, 1.5 seconds. Two files.
tests/test_cli.py— smoke testsThese import and invoke every command in the
cli.pytree, which catches abroken import, a malformed decorator, or two options sharing a flag — the
failures that otherwise reach a user as a traceback on the first thing they
type.
The command tree is walked, not listed:
_command_paths()recurses throughclick.Group.commands, so a command added later is covered without anyoneremembering to extend the test. 18 paths in this check-in. A guard test asserts
the walk found something, since an empty parametrization would pass vacuously.
Plus three that run real commands:
--version,classes(which also exercisesthe globato docstring parse behind photon classification), and
options list(which reads config, under the isolation below).
tests/conftest.py— the isolation fixtureIVERT carries four pieces of process-global state. Left alone, tests read and
write the developer's real configuration and pass or fail depending on whose
machine they run on. One autouse fixture closes all four off. It applies
unconditionally, in CI as well as locally, which matters most on a developer's
own machine — that is where a real
~/.ivertexists to be clobbered.IVERT_USER_CONFIG→ a per-testtmp_path.Config.user_config_pathhonours it ahead of the packaged default, which makes it the clean seam.configfile.ivert_configConfig.__init__assigns this module global; reset so the first test to build one doesn't leak it into the rest.is_aws()pinned off — a laptop and an EC2 instance otherwise disagree, andConfigswitches to the[AWS]section on it.photon_classes()lru_cached over a parse of globato's docstring; cleared on both setup and teardown.tests/test_isolation.py— testing the fixtureThis is the part I'd most like reviewed. An autouse fixture is invisible — no
test mentions it — so if it silently stopped applying, all 22 other tests would
still pass, while reading and writing a real
~/.ivert. These six make thatfailure loud.
The first draft had five tests and two of them were decorative. I disabled the
fixture body and re-ran to see which ones actually noticed: three failed, two
passed.
test_aws_detection_is_pinned_offassertedis_aws is False, which istrue anyway on any machine that isn't EC2, and the cache check asserted an
empty cache that was empty anyway. Both looked like coverage and provided none.
They were replaced with tests of the seam rather than the state — patch the
thing to its non-default value and confirm IVERT actually reads it there:
configfiledoesfrom ivert.utils import is_awsand callsis_aws.is_aws(),so patching the module attribute reaches
Config. Rebinding that import to thefunction itself would strand the fixture's patch — and
Configwould quietlystart reading the
[AWS]section on a laptop. Verified: rewriting the import tofrom ivert.utils.is_aws import is_awsfails this test and only this test.Of note: the
[AWS]parsing in the configfile is currently stale — thepackaged
ivert_defaults.inihas no[AWS]section at all, simply becauseIVERT is set up currently as a desktop/laptop/workstation implementation, not
the client-server architecture it had when being used in the cloud. However, we
are keeping the special
[AWS]section handling inconfigfile.py(triggered ifis_aws.py::is_aws()returns True upon execution) to make it easy tore-enable a client-server architecture in the future.
The cache check became an ordered pair — one test populates the
lru_cache, thenext asserts the teardown emptied it. The ordering is load-bearing and is
commented as such.
Verified with the fixture body disabled: 4 of the 6 fail. The two that don't
are the seam tests, which do their own patching and are meant to pass
independently — they guard the refactor, not the fixture's application.
What's deliberately not here
Coverage of the actual logic. The plan, roughly in the order we'll write these
tests:
parts into plain-in/plain-out functions:
_check_dem_geotransform,_normalize_export_formats,_photon_results_filename,resolve_vdatum,normalize_format_keys,split_bbox_into_parts.utils/cuboid_funcs.py— Pure geometry over numpy, determines whetherfilter_query_bboxre-downloads granules we already have. Two propertytests pin it: volume conservation under subtraction, and no overlap between
remainder pieces, since each becomes its own NSIDC request.
committed, for
dem_geomandsplit_dem.network/slow— granule downloads and a real end-to-end validationrun, behind the markers.
Two follow-ups once this is green:
needs: "test"on thebuildjob inpublish-to-pypi.yml, so a release can't publish over a failing suite; and apre-pushhook running the fast tiers.Questions
tests/at the root, orsrc/ivert/tests/shipped in the wheel?Argument for root
tests/: we don't have to ship artifact data needed fortests with the code package on PyPI and Conda Forge.
Argument for
src/ivert/tests/: Given IVERT's correctness depends onGDAL/PROJ/pyproj versions we don't control, there's a real argument for the
shipped testing layout, that users who install the package could run
independently. (Note:
Even if a user can't install the tests with a package, they could still do
so by cloning the repository directly.)
-m 'not network and not slow'inaddoptstoo clever? It makespytestsafe by default but means the bare command doesn't run everything,which can surprise people. The alternative is an explicit
-min theworkflow and no default filter.
adding macOS now, or wait until something platform-specific breaks?
Verification
pytest→ 28 passed in 1.5sprek run --all-files→ all hooks passruff check src tests→ clean. The"tests/**" = ["INP001"]carve-out wasconfirmed necessary by removing it and seeing three real errors, not added
speculatively. It's scoped to
tests/, so it doesn't count against Progressively enable ~all Ruff rules #40.test.ymlparses as valid YAML.🔍 Docs preview: https://ivert--102.org.readthedocs.build/en/102/