Skip to content

Refactor/colocate unit tests - #20

Merged
baraline merged 27 commits into
mainfrom
refactor/colocate-unit-tests
Jul 30, 2026
Merged

Refactor/colocate unit tests#20
baraline merged 27 commits into
mainfrom
refactor/colocate-unit-tests

Conversation

@baraline

Copy link
Copy Markdown
Owner

Colocate unit tests beside the modules they test

Every unit test now lives in a tests/ package next to the module it covers — _async/auth/auth.py is tested by _async/auth/tests/test_auth.py. Client tests are written once, as async def, under _async/, and unasync_build.py generates the _sync/**/tests/ twins, so both surfaces run the same assertions from a single source. Cross-cutting suites (codegen guards, reference guards, the raise-site audit, packaging) live in glpi_python_client/testing/tests/. integration_tests/ is untouched.

This also fixes a live packaging bug

main ships the test suite inside the distributions. Verified by building at both commits and diffing the file lists:

before after
wheel 180 entries, 56 test files 126 entries, 0
sdist 204 entries, 56 test files 150 entries, 0

[tool.hatch.build] exclude now drops tests/ and conftest.py. The only additions anywhere in the distribution are the two _testing.py helpers; every removal is a test file. glpi_python_client.testing — the documented downstream factories, fixtures and fake responses — still ships in full, and the package imports cleanly in a venv with no pytest installed.

Verification

  • 844 → 854 passed, 1 skipped, coverage 97.07%, mypy strict clean, ruff clean, sphinx -W clean.
  • Test function names: 399 → 405, zero missing. A name-set checker ran on every one of the 18 steps against a baseline taken at the merge base; all 6 additions are genuinely new.
  • Every split failure suite reassembled exactly. test_kb_failure_paths dissolved across 4 files and test_api_coverage across 10; old-union vs new-union is set-identical, no parameter dropped or duplicated.
  • Line and branch coverage of the source tree preserved exactly — 71 missing on both sides, zero newly uncovered.
  • A three-lens review (assertion integrity, codegen contract, packaging) ran over the finished branch, including a ~35-mutation differential campaign hunting for anything killed on main but surviving here.

Review findings, fixed

  • A lost assertion. test_glpi_client_async_context_manager had traded pytest.raises(RuntimeError, ...) for a private _closed flag. Restored — and pinned to the guard's own message, because with _ensure_open disabled the call still raises RuntimeError, just from httpx ("Cannot send a request, as the client has been closed."), so the obvious match="closed" passed either way.
  • Five docstrings false in the generated tree. unasync only substitutes a string literal when its entire content is a key, so an async-only name inside a multi-sentence docstring passes straight through. Two rendered into the published API reference: __enter__ documented as returning AsyncGlpiClient, and a httpx.Client parameter documented as httpx.AsyncClient. A new guard now scans the generated tree for all fifteen remaining substitution keys, with an explicit allowlist for the deliberately contrastive sentences.
  • HAND_WRITTEN matched bare filenames at any depth, so a future _async/**/test_concurrency.py would silently get no twin while --check stayed green — the scratch tree and _sync/ would agree on its absence. Now tree-relative. Colocated tests make generic filenames recur, so this stopped being hypothetical.
  • iter_search_tickets pagination was unasserted. Mutating start += batch_size to start += 0, or dropping the sort=/fields= forwarding, all survived. Each now fails on both surfaces.
  • The coverage omit matched glpi_python_client/testing/*, excluding the published helpers as well as their suites. Narrowed — and the helpers it exposed are now tested rather than merely counted: SearchResponse, TicketResponse and the client_factory fixture had shipped with no test at all. testing/ goes to 100%.
  • Two positive controls that did not control. One "prove the root resolves" test passed against a broken root because pytest's own sys.path entry satisfied the import; another used endswith, which a root resolved one level too deep still matches. Both now fail in the drift directions they were written for.

Each fix was verified by breaking the thing it guards and watching the test fail, then restoring.

Known, pre-existing, not addressed here

  • lxml is declared as a runtime dependency but never imported; markdownify is called without features=, so bs4's default parser is used.
  • testing/fixtures.py ships and imports pytest, which is correctly dev-only. The module now documents that and points application code at testing.utils.

baraline and others added 26 commits July 28, 2026 14:39
Adds the tests/ and conftest.py exclusion patterns, a config tripwire
test, and a CI step that asserts the built wheel itself. Also promotes
_DEFAULT_CLIENT_CONFIG to a public DEFAULT_CLIENT_CONFIG so the per-tree
test-client twins can share it, and adds the name-set checker that guards
the colocation move against dropped tests.
Proves the pipeline end to end on the smallest module: a test written
once under _async/ generates a working sync twin, and both run.

make_client lives in _async/_testing.py rather than in the published
glpi_python_client.testing module because its return type has to change
when the tree is transformed, and testing.make_client is documented API
that must keep returning a GlpiClient.
Also pulls test_4xx_raises_a_typed_status_error_from_ensure_response_status
across from test_retry_semantics.py -- it exercises ensure_response_status
directly and never touches the transport.
Retry policy, network-fault translation and timeout narrowing all live on
the transport mixin, so their tests belong beside it. The stub callables
become named async defs: the transport awaits them, and a lambda returning
a FakeResponse cannot be awaited.
The docstring said "asynchronous GLPI transport mixin" and named
AsyncGlpiClient specifically. Copied verbatim into _sync/, that read as
false there: the generated file exercises GlpiClient synchronously and
never touches AsyncGlpiClient. Reworded to describe what the tests assert
without naming a surface-specific class, per C6.
Moves the 10 remaining client-lifecycle tests out of
glpi_python_client/tests/clients/test_glpi_client.py into
_async/clients/tests/test_client.py, rewritten as async def per the
unasync recipe, and deletes the now-empty source file.

Also tightens the generated-tree guard in test_unasync_codegen.py:
it flagged the bare substring "_async" anywhere in a line, which
false-positives on the two carried-over test names that legitimately
contain "_async" as a fragment inside a larger identifier
(test_glpi_client_async_context_manager,
test_async_transport_ensure_open_blocks_after_close). The check now
matches "_async" as its own token, matching what the codegen itself
can and cannot rewrite, and keeps catching genuine qualified or bare
tree references.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_glpi_client_init_failure_creates_no_session's docstring named
httpx.AsyncClient and "the async surface" as the reason the old
except-clause unwind was replaced with up-front validation. unasync
only substitutes a string literal when its entire content is a
substitution key, so that prose survived unrewritten into the
generated sync twin, where it names a class and a surface absent from
that tree -- a repeat of the C6 docstring defect from Task 5.

Reworded to describe the constructor invariant without naming either
client class or surface, matching the two docstrings already reworded
in this file for the same reason. Assertion and test name unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds TransportRecorder and FailingTransportRecorder to _async/_testing.py
so the recorder stub is written as async once rather than duplicated
across nine mixin test files.
…_page

Review found the test had been rewritten to stub the transport via
TransportRecorder instead of client.search_tickets, dropping the
call_count assertion, the "status==1" filter argument, and weakening
len(batches[0]) == 1 to batches[0][0].id == 1. Restored the original
structure (fake_search converted to async def per recipe C2), mirroring
the sibling multi-page test.
test_api_coverage.py and test_smoke.py are fully dissolved: every test in
them exercised one mixin, and none of them asserted contract completeness
-- that is test_method_invocation.py's job.
test_kb_failure_paths.py unrolls into the four mixins it covered. The
revision mixin is read-only, so it takes only the read suite.
…tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_async_surface.py becomes _async/tests/test_concurrency.py, and the
sync side gets a hand-written counterpart that asserts what is actually
true there: ordered sequential gather and a real threading lock. Nothing
exercised _sync/_concurrency.py directly before this.
test_the_lock_serialises_concurrent_threads had a critical section too
short to create real contention: verified empirically that a no-op lock
stand-in passed the test on every trial without a hold, since thread
startup and GIL scheduling alone kept the eight runs from overlapping. A
brief time.sleep in the critical section makes a broken lock fail every
time while a real threading.Lock keeps passing.
Three of them derived the repo root with parents[2] from one level up and
needed parents[3]. test_raise_site_audit.py did not: its parents[2] points
at the package root, which is the same at both depths.
_build_module inserted _REPO_ROOT on sys.path and imported unasync_build,
but pytest already puts the real repository root there, and sys.modules may
hold the module from an earlier test. Either route satisfied the import
regardless of _REPO_ROOT, so test_the_substitution_map_is_reachable -- the
positive control meant to catch a broken root -- passed against one.

Assert the imported module's __file__ resolves under _REPO_ROOT. With the
root deliberately set one level too shallow the file now fails 6 tests
including that control; before, it failed 4 and the control passed.
One rule: a unit test lives beside the module it tests. Records the two
traps the async-source style introduces -- a lambda stub that cannot be
transformed, and a forgotten await that passes vacuously.
CLAUDE.md is gitignored and has never been tracked, so the docstring added
in the previous commit referred to a file no clone has. docs/development.md
carries the same layout description and is tracked.
The colocation move is complete and every baseline name is still
collected, so the checker has done its job.
test_glpi_client_async_context_manager traded a behavioural assertion for
a private flag during the move. Restored, and pinned to the guard's own
message: with _ensure_open disabled the call still raises RuntimeError,
but from httpx ("Cannot send a request, as the client has been closed."),
so match="closed" passed either way. It now fails on both surfaces.

Two docstrings were false once copied into the generated tree -- test_http
called the transport "asynchronous", and _testing.py claimed to be the
source that generates its twin, which also shipped in the wheel.

HAND_WRITTEN matched bare filenames at any depth, so a future
_async/**/test_concurrency.py would silently get no twin with --check
still green. Now tree-relative. Colocated tests make generic names recur,
so this stopped being hypothetical.

The raise-site positive control used endswith, which a root resolved one
level too deep still satisfies. Now pins the root and both exact paths.

CHANGELOG records that the wheel and sdist were shipping 56 test modules.
Five docstrings named an async-only spelling that unasync cannot rewrite
-- substitution only fires when a string literal's entire content is a
key, so a name inside a multi-sentence docstring passes through and the
generated tree documents itself in terms that are false there. Two of
them rendered into the published API reference: __enter__ was documented
as returning AsyncGlpiClient, and a httpx.Client parameter as
httpx.AsyncClient. All five are now surface-neutral or explicitly
contrastive, and a new guard scans the generated tree for the other
fifteen substitution keys the _async scan never covered.

iter_search_tickets had no test pinning its pagination: mutating
start += batch_size to start += 0, or dropping the sort= and fields=
forwarding, all survived. Each now fails on both surfaces.

The coverage omit matched glpi_python_client/testing/*, which excluded
utils.py and fixtures.py as well as the suites -- published API measured
by nothing. Narrowed to testing/tests/, and the helpers it exposed are
now tested rather than merely counted: SearchResponse, TicketResponse and
the client_factory fixture had shipped with no test at all. testing/ goes
to 100%, total coverage 97.02% -> 97.07%.

pytest stays out of the runtime dependencies and in the dev extra, where
it already was. testing/fixtures.py is the one shipped module importing
it, so it now says so and points application code at testing.utils.
@codecov-commenter

codecov-commenter commented Jul 30, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.10%. Comparing base (ed466e6) to head (f758dae).
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #20      +/-   ##
==========================================
+ Coverage   96.95%   97.10%   +0.15%     
==========================================
  Files          74       79       +5     
  Lines        2330     2422      +92     
==========================================
+ Hits         2259     2352      +93     
+ Misses         71       70       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

… sync one

The omit list excluded _async/ and measured the generated tree. That was
the only tree the suite exercised before this branch, but it is the wrong
one to report on: _async/ is what a person edits, so an uncovered line
there is a line nobody tested, while an uncovered line in _sync/ only
records which surface a test happened to run on.

Flipping the omit exposed a real gap rather than just renaming the
denominator. test_method_invocation.py swept all 85+ methods on
GlpiClient but sampled three on AsyncGlpiClient -- a split that was
harmless while _sync/ was measured and hid five async statements behind
their sync twins the moment it was not. The async sweep now mirrors the
sync one, driving async generators with `async for` rather than awaiting
them.

Net effect: 854 -> 935 tests, and coverage of the source tree is higher
than the generated tree ever reported (97.11% vs 97.07%). The remaining
async/sync asymmetry is one module, auth.py, where _async/ is better
covered by one statement -- an async-only path in test_concurrency.py.
@baraline
baraline merged commit 849ecd0 into main Jul 30, 2026
7 checks passed
@baraline
baraline deleted the refactor/colocate-unit-tests branch July 30, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants