Testing strategy¶
Tests fall into two tiers (fast unit + slow integration) plus a focused upstream-bug regression suite. All run through make test / make test-slow / make test-upstream-bugs.
Tier 1: fast unit tests (make test)¶
Use respx to mock httpx2 responses. Cover:
- Every auth provider returns the right headers.
- OAuth2 token caching and refresh paths.
- Dhis2Client parses typed pydantic responses, surfaces error hierarchy correctly.
available_versions()discovers generated modules from the filesystem.Dhis2Client.connect()version-dispatch logic: strict refusal vs nearest-lower fallback.- Codegen name/type mapping (pure functions).
- Generated resources' CRUD verbs (GET/POST/PUT/DELETE) hit the right paths with the right HTTP verbs.
Fast — currently runs in <0.5s.
respx against httpx2¶
The root conftest.py sets respx.mocks.DEFAULT_MOCKER = "httpcore2", so a plain
respx.mock intercepts httpx2 traffic without any per-test set-up. pytest-httpx2
is a dev dependency too, and its httpx2_mock fixture is the other route for a new
test that would rather assert on requests than register routes.
respx builds and asserts on the original httpx.Response class, so a test file
imports both libraries: httpx for exactly the Response and Request objects
respx consumes and produces, httpx2 for everything the code under test touches —
clients, transports, MockTransport handlers, and exception classes. Ruff's
TID251 rule enforces that split by banning httpx outside **/tests/**.
@respx.mock
async def test_conflict_raises() -> None:
respx.get("https://play.example/api/me").mock(return_value=httpx.Response(409))
async with httpx2.AsyncClient(base_url="https://play.example") as client:
with pytest.raises(httpx2.HTTPStatusError):
(await client.get("/api/me")).raise_for_status()
Tier 2: slow integration tests (make test-slow)¶
Hit the live DHIS2 play/dev instance. Read-only by default (no destructive writes against a shared demo server). Cover:
- Raw HTTP auth/discovery round trips with Basic auth.
- Full codegen pipeline: discover → emit → import generated module → inspect.
- Connected client:
client.system.info(),client.system.me(), andclient.resources.data_elements.list()/get()against real data.
Marked with @pytest.mark.slow so the default make test skips them. They run in ~3s and confirm the full chain works against real DHIS2.
Tier 3: upstream-bug regression suite (make test-upstream-bugs)¶
Every entry in BUGS.md (top-level) describing a real DHIS2-side bug we've worked around in this repo gets a paired test in packages/dhis2w-client/tests/test_upstream_bugs.py. Each pair has two flavours:
Mocked (fast, default)
- Bug-still-present (mocked) — respx-mocked test that models DHIS2's buggy wire response and verifies our client handles it.
- Workaround-works (mocked) — same mocked response, asserts the workaround code produces the correct end state.
Live (slow, opt-in)
- Bug-still-present (live) —
@pytest.mark.slowtest that hits the local docker DHIS2 stack viamake dhis2-run DHIS2_VERSION=vN. POSTs / GETs the actual wire and asserts the bug is observable. When DHIS2 ships an upstream fix and the wire shape changes, this test fails — the loud signal to drop the workaround. Skips when the stack isn't reachable or when the server version doesn't match the bug's target major (so the v43 bug tests only run against a v43 stack).
All flavours carry @pytest.mark.upstream_bug. make test-upstream-bugs filters to just this marker (pytest -m upstream_bug), useful for "show me every workaround we depend on". The respx-mocked halves are fast and run as part of the default make test too. The live halves run via make test-slow when a stack is up.
Adding a new pair¶
- Append a
### N. <summary>entry toBUGS.mdwith the curl repro + workaround pointer. - Add three tests to
packages/dhis2w-client/tests/test_upstream_bugs.py: test_bug_N_<short>_<bug-pattern>— mocked bug-still-present (respx).test_bug_N_workaround_<does_the_right_thing>— mocked workaround-works (respx).test_bug_N_v<X>_live_<bug-pattern>—@pytest.mark.slowlive verifier. Calls_skip_if_stack_unreachable(local_url)+_skip_unless_version(client, "v<X>")at the top, then POSTs / GETs against the real wire. Cleans up any mutations.- Reference BUGS.md #N in every docstring so the link is bidirectional.
The pattern is illustrated in the file with BUGS.md #34 (v43 dropping the categorys wire alias) covered end-to-end across all three flavours.
Test connection details¶
The @pytest.mark.slow E2E suite targets the local docker stack — make -C infra up-seeded DHIS2_VERSION=vN brings up the matching DHIS2 major and writes infra/home/credentials/.env.auth with DHIS2_URL and DHIS2_PAT. Each member's tests/conftest.py auto-sources that file and exposes the fixtures below.
Defaults:
Overridable via environment variables. Session-scoped fixtures in each member's tests/conftest.py:
@pytest.fixture(scope="session")
def local_url() -> str: ...
@pytest.fixture(scope="session")
def local_username() -> str: ...
@pytest.fixture(scope="session")
def local_password() -> str: ...
@pytest.fixture(scope="session")
def local_available(local_url: str) -> bool: ... # probes /dhis-web-login/, gate for skips
Simple strings, not dataclasses — this sidesteps mypy's "duplicate conftest module" problem across workspace members.
Live-against-play coverage lives in a separate workflow — @pytest.mark.contract (.github/workflows/contract.yml) hits play.im.dhis2.org/dev-2-{42,43} to catch upstream API drift. Nightly E2E never touches play.
Reusing the test environment from a pack¶
A plugin pack that lives in its own repository gets the same test environment from
dhis2w-core, as a pytest plugin rather than a copied conftest.py. Depend on the
testing extra, which brings pytest, pytest-asyncio, respx and pytest-httpx2:
Then load the plugin from the pack's root conftest.py — the one file pytest allows
pytest_plugins in:
dhis2w_core.testing provides:
respx.mocks.DEFAULT_MOCKER = "httpcore2", set at import so routers built at module import time intercept httpx2 traffic too.- The colour-forcing variables (
FORCE_COLOR,CLICOLOR_FORCE,GITHUB_ACTIONS,TF_BUILD) cleared at import, before a test module builds its RichConsole. - An autouse fixture clearing the profile-resolution variables (
DHIS2_PROFILE,DHIS2_URL,DHIS2_PAT,DHIS2_USERNAME,DHIS2_PASSWORD,DHIS2_VERSION) per test. - An autouse fixture that fails any test reaching
webbrowser.open/open_new/open_new_tabinstead of launching the interactive OAuth2 login. core_version,plugin_service,mock_system_infoandcore_profile, which parametrize one test body across the v41/v42/v43 plugin trees.
There is no pytest11 entry point on purpose: the autouse fixtures reshape the process
environment, which is right for a dhis2w suite and wrong for any other suite that happens
to have dhis2w-core installed. Opting in is a single line.
Destructive writes¶
Currently none. Any test that creates or deletes real resources needs to:
- Use a unique, obviously-test name prefix (e.g.
dhis2w-test-<uuid>). - Clean up in a try/finally.
- Be clearly marked in its docstring.
Until that policy is formalised, CRUD write tests live in the unit tier (respx-mocked) only.
What we don't test (yet)¶
- OAuth2 end-to-end against a real DHIS2 instance with the interactive browser flow — covered by unit tests (cached token + refresh paths) and by the Playwright-driven e2e in
examples/client/oidc_playwright_login.py, but not yet wired intomake test. - Per-version
tests/v{41,43}/accessor + plugin trees — v42 has full coverage; v41 + v43 have divergence + smoke tests only (@upstream_bugregressions,test_v41_divergence.py,test_v43_divergence.py). The hand-written client + plugin code indhis2w_client.v{41,43}anddhis2w_core.v{41,43}is currently excluded from coverage (tool.coverage.run.omitinpyproject.toml) until those tests fill in.