Fix: Let pytest drive test output and isolate per-case file writes#3402
Open
a-v-popov wants to merge 1 commit into
Open
Fix: Let pytest drive test output and isolate per-case file writes#3402a-v-popov wants to merge 1 commit into
a-v-popov wants to merge 1 commit into
Conversation
Builds on the parametrize work in the previous commit by removing the manual print/diff scaffolding that duplicates what pytest already does, and switching per-case file writes to tmp_path so the suite stops littering the tests/ directory. What changes in tests/test_transformation.py: - The unconditional "Test case: ..." / "... succeeded, ..." prints and the on-failure sys.stdout.writelines(difflib.unified_diff(...)) block are gone. Now that each YAML case is its own pytest node, pytest's -v shows the node ID per case and its assertion rewriter formats the diff on failure. Default -q output is clean dots; -v lists IDs; no duplicate diffs in stdout. Same story for run_error_case: the "Accumulated error log..." dump is replaced with an assertion message. - run_transformation_test now takes tmp_path (the pytest fixture) and os.chdir's into it for the duration of ansible.ansible_inventory / ansible.ansible_config / ansible.dump / _TopologyOutput.write calls. ansible_inventory creates group_vars/ and host_vars/ trees relative to CWD, which used to land in tests/ and forced the wrapper scripts to 'rm -fr *files' after every run. Each parametrized case now gets its own tmp_path, so the cases no longer step on each other or on the tests/ directory. - test_coverage_verbose_cases used to be 'if not sys.gettrace(): return', which reported PASSED outside coverage runs while doing nothing. It is now @pytest.mark.skipif(...) so the result is visibly SKIPPED. - Drops the unused 'import difflib' and the vestigial 'tmpdir' string parameter on run_transformation_test. Consequences in the wrappers: - run-tests.sh and run-coverage-tests.sh drop their 'rm -fr *files' trailing cleanup. The 'until we fix that' comment in those scripts is now resolved; the tests no longer write into tests/. Behavior summary (run from tests/ or repo root, both work identically): -q: one dot per case, summary line. Failures show pytest's diff. -v: one node ID per case. Failures show pytest's diff. -vv: full assertion expansion on failure. The xform_/error_cases/coverage selectors in the wrappers continue to match because the test function names are preserved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is the second PR for
pytestfollowing #3391. After that PR manual test outputs are limited to a specific case, but they are mostly redundant.$ pytest -q --tb=short -k topology/input/addressing-lan.yml F [100%] ======================================================= FAILURES ======================================================= _________________________________ test_xform_cases[topology/input/addressing-lan.yml] __________________________________ /home/apopov/netlab/tests/test_transformation.py:70: in test_xform_cases run_transformation_test(test_case) /home/apopov/netlab/tests/test_transformation.py:64: in run_transformation_test assert result == expected E AssertionError: assert 'input:\n- to...er: libvirt\n' == 'input:\n- to...er: libvirt\n' E E Skipping 302 identical leading characters in diff, use -v to show E r2 E - - ififindex: 2 E ? -- E + - ifindex: 2 E ifname: GigabitEthernet2... E E ...Full output truncated (1098 lines hidden), use '-vv' to show ------------------------------------------------- Captured stdout call ------------------------------------------------- Test case: topology/input/addressing-lan.yml Test case: topology/input/addressing-lan.yml FAILED --- expected +++ result @@ -13,7 +13,7 @@ ifname: GigabitEthernet2 ipv6: 2001:db8:1::15/64 node: r2 - - ififindex: 2 + - ifindex: 2 ifname: GigabitEthernet2 ipv6: 2001:db8:1::2a/64 node: r3 ------------------------------------------------- Captured stderr call ------------------------------------------------- Warning in csr: Changing Ansible network_cli connection SSH type to 'paramiko' ... The installed version of ansible-pylibssh might not work with Cisco IOS devices ... Set defaults.devices.csr.warnings.paramiko to False to hide this warning =============================================== short test summary info ================================================ FAILED tests/test_transformation.py::test_xform_cases[topology/input/addressing-lan.yml] - AssertionError: assert 'input:\n- to...er: libvirt\n' == 'input:\n- to...er: libvirt\n' 1 failed, 273 deselected in 0.90sInformation in "Captured stdout call" section already available in the standard
pytestoutput.This PR aggressively removes all the duplication.
Alternatives:
pytestassert rewrite-vDepending on what is the goal we might choose one of the alternatives.
Specifically if we need to capture large diffs in CI the latter is likely to be the right choice.
It is possible that I devalue output like "Writing inventory…" too much, informed opinion would be appreciated.
Another change in the PR is usage of
tmp_pathandtmp_path_factoryto isolate test cases. These are not free, but their impact should be negligible, while general robustness of test results should improve.Below is a more detailed (and AI driven) description of the changes proposed in this SR.
Summary
print()anddifflib.unified_diffscaffolding fromtest_transformation.py. With each YAML case as its own pytest node, pytest's-vand assertion rewriter cover the same ground and respect the standard verbosity flags.tmp_pathfixture viaos.chdir, soansible_inventory's CWD-relativegroup_vars/andhost_vars/writes no longer land intests/.rm -fr *filescleanup hack fromrun-tests.shandrun-coverage-tests.sh— the tests no longer write intotests/.test_coverage_verbose_casesto@pytest.mark.skipifso its result is visibly SKIPPED outside coverage runs instead of silently PASSED, and switch its inner loop totmp_path_factory.mktemp(...)so each iteration gets its own scratch dir (matching the parametrized suite's per-case isolation).Why
This PR completes the cleanup by removing the duplicated framework-level work the harness was doing by hand.
Manual output handling duplicates pytest
run_transformation_testprints"Test case: %s"regardless of pytest verbosity. On success it adds"… succeeded, string length = %d". On failure it manually dumps adifflib.unified_diffto stdout before lettingassertraise — so pytest's own assertion-rewriter diff appears alongside it, doubled up.run_error_casedoes the same with a manualAccumulated error logblock.With each YAML case now a pytest node (per the previous PR), pytest already supplies:
"Test case: …"printdifflib.unified_diff-qfor quiet,-vvfor full diff expansion — replaces a hard-coded chatty defaultDeleting the manual code makes the standard pytest flags work as users expect.
Per-case file writes leak into tests/
run_transformation_testpassestmpdir+"/extra/hosts.yml"toansible.ansible_inventory, butansible_inventoryalso writesgroup_vars/<g>/topologyandhost_vars/<h>/topologyto CWD — which istests/after the conftest chdir. The wrappers worked around this withrm -fr *filesat the end; cases polluted each other's state inside a single run.Switching to
tmp_path(via a localizedos.chdiraround the write block) gives each parametrized case its own scratch directory. The cleanup hack becomes unnecessary, cases stop stepping on each other, andtests/stays clean.test_coverage_verbose_caseswas silently a no-op, and its scratch dir wasn't deterministic eitherif not sys.gettrace(): returnexits cleanly with status PASSED outside coverage runs — the same silent-PASS pattern the previous PR fixed elsewhere. Converting to@pytest.mark.skipif(not sys.gettrace(), reason="coverage-only test")reports SKIPPED, which is the truthful result.While we're here: the function's inner loop reused a single
tmp_pathacross all 109 iterations, which doesn't change the assertion (the comparison is against in-memory state, not disk) but does make coverage measurement filesystem-state-sensitive. If any output module's "create-if-missing" branch sees the previous case'sgroup_vars/already present, case N+1 doesn't exercise the same lines it would in a clean dir. Coverage results become invocation-order-dependent. Switching totmp_path_factory.mktemp(...)per iteration extends the parametrized suite's per-case isolation guarantee to this path — standard pytest idiom, two-line change.What changes
tests/test_transformation.pyprint()calls inrun_transformation_test("Test case: …","Writing inventory…","… succeeded, …","Test case: … FAILED") and inrun_error_case("Test case: …", theAccumulated error logblock).sys.stdout.writelines(difflib.unified_diff(...))call. Bareassert result == expectedbecomesassert result == expected, f"transformation mismatch for {test_case}"; pytest's assertion rewriter produces the diff.run_transformation_testtakestmp_path: pathlib.Pathinstead of the vestigialtmpdir: str = '/tmp'. Around the file-writing block (ansible.ansible_inventory,ansible.ansible_config,ansible.dump,_TopologyOutput.write) itos.chdirs intotmp_pathso all CWD-relative writes — including thegroup_vars/andhost_vars/treesansible_inventorycreates internally — land inside the per-test temp dir. The topology read happens before the chdir; the expected-fixture read happens after thefinally: os.chdir(cwd).test_coverage_verbose_cases→@pytest.mark.skipif(not sys.gettrace(), reason="coverage-only test"), and its inner loop takes atmp_path_factoryinstead of a singletmp_pathso each iteration gets a freshtmp_path_factory.mktemp("coverage")scratch dir.import difflib.tests/run-tests.shandtests/run-coverage-tests.sh# Remove files unnecessarily created by various provider modules (until we fix that)/rm -fr *filesblock.run-xform.shandrun-xerr.share unchanged — they never had the cleanup hack.Anticipated questions
-vlists every case as it runs, andpytest -v --no-header -qis a knob users already know. The deletion is about not duplicating that information when users have not asked for it. If you want a real progress bar,pytest-sugarorpytest-progressare one-line plugin installs.os.chdiraround the write block leak if a test errors mid-block?" It is wrapped intry / finally, so even an exception inside the inventory/output write restores the original CWD before the test moves on or the assertion runs.ansible_inventoryto take a base dir instead of usingchdir?" Bigger change, affects production code paths and other callers.chdiris contained to one test helper and inherits the per-test isolation pytest already provides viatmp_path. Worth doing eventually but not in this PR.rm -fr *filesrisk leftover state if something regresses?" Yes — a regression that re-introduces CWD-relative writes from insiderun_transformation_testwould leak. We mitigate this in the test plan below; longer-term, apytestautouse fixture that snapshotstests/contents and fails the test on diff would close the gap. Out of scope here.-vvchange subtly changes failure output." Yes —Left/Rightheaders in pytest's rewriter replaceexpected/resultin the legacydiffliboutput. Same content, different labels. The labels follow operand order inassert result == expected(soLeft= result,Right= expected); if that ordering is confusing we can flip the operands.Test plan
cd tests && python3 -m pytest -q -k 'xform_ or error_cases'— expect 219 passed, output is dots + summary only, noTest case:/succeededlines.cd tests && python3 -m pytest -v -k xform_— expect node IDs and PASSED lines, no leftoverTest case:prints.tests/topology/expected/6pe.yml), runcd tests && python3 -m pytest -vv -k xform_. Confirm the failure section shows pytest's unified-diff-style assertion expansion, with no duplicate manual diff in stdout. Confirm summary shows "1 failed, 108 passed". Revert.ls tests/group_vars tests/host_vars tests/*files 2>/dev/nullreturns no results —tests/stays clean without the wrapper cleanup.cd tests && ./run-tests.sh— expect 219 passed, exits cleanly even with therm -fr *filesline removed.cd tests && ./run-coverage-tests.sh— expect coverage tests pass,tests/clean afterwards.cd tests && python3 -m pytest -v -k coverage_verbose— expectSKIPPED (coverage-only test), not silent PASSED.coverage run -m pytest -k coverage_verbose), confirmtest_coverage_verbose_casesis collected and runs.