Skip to content

Experiment: take 864 of 1145 tests in 13 files off pytester - #140

Draft
RonnyPfannschmidt wants to merge 30 commits into
scenario-api-pocfrom
ensemble-migration-experiment
Draft

Experiment: take 864 of 1145 tests in 13 files off pytester#140
RonnyPfannschmidt wants to merge 30 commits into
scenario-api-pocfrom
ensemble-migration-experiment

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Note

Experiment, stacked on top of #14809. Base branch is scenario-api-poc, so this diff shows only the migration. It exists to answer what a proof of concept cannot: what actually happens when you point the new API at real tests, in bulk.

What this is

864 of 1145 tests across 13 files no longer use pytester. The suite is unchanged where it counts: 4452 passed, 47 skipped, 13 xfailed, 7 xpassed. No test deleted, renamed or reordered. mypy sits at its baseline error count and the full pre-commit run --all-files passes.

file off pytester / total still on pytester
testing/python/fixtures.py 179 / 225 46
testing/test_assertion.py 131 / 152 21
testing/python/metafunc.py 117 / 121 4
testing/test_terminal.py 70 / 159 89
testing/test_skipping.py 65 / 80 15
testing/test_unittest.py 63 / 70 7
testing/test_mark.py 59 / 69 10
testing/python/collect.py 53 / 83 30
testing/test_runner.py 46 / 66 20
testing/test_warnings.py 33 / 52 19
testing/test_subtests.py 28 / 32 4
testing/test_session.py 10 / 24 14
testing/test_runner_xunit.py 10 / 12 2
total 864 / 1145 281

One commit per file, so any single port can be dropped. Every test still on pytester carries an # ensemble: <reason> comment.

Speed — and where it stops paying

Same machine, best of runs, single process. main runs the original pytester versions; ensemble runs the ported ones.

main ensemble
13 ported files 109.94s 83.03s 1.32x
testing/ complete (-n 8) 63.62s 47.87s 1.33x

The headline number got worse as more tests were ported, and that is the most useful result here. An earlier state of this branch measured 1.44x / 1.41x with ~34 fewer tests ported. Per file:

file main ensemble
test_subtests.py 4.16s 2.76s 1.51x
test_unittest.py 4.49s 3.03s 1.48x
python/fixtures.py 11.30s 7.89s 1.43x
test_assertion.py 17.75s 19.22s 0.92x
test_terminal.py 20.32s 21.94s 0.93x

The two files that go backwards are exactly the two whose ports lean on capture_output=True. Loading the terminal plugin costs ~29% per ensemble run (see bench/ensemble_vs_pytester.py in the base PR), and now that the progress column renders, a rendered ensemble also does more work per test than it used to.

So the rule, with numbers behind it:

Port outcome-shaped tests for speed. Port rendering-shaped tests for hermeticity and pytester-independence — they cost time, they do not save it.

If suite time were the only goal, the test_terminal.py ports should be dropped and the branch would sit near 1.4x. They are kept because the point of the exercise is to find out what the API can express, and because a rendered ensemble is still hermetic where a pytester run is not.

What the port surfaced

Porting was not a neutral rewrite. It found real bugs — in the tests, in the ensemble API, and in pytest itself.

In pytest. subtests rewrites report.outcome/report.longrepr inside pytest_report_teststatus, a reporting hook. So a top-level test containing a failed subtest is only marked failed if something renders it — any consumer reading reports without a terminal sees un-rewritten outcomes. This made --last-failed a silent no-op under an ensemble: LFPlugin saw the report still passed and popped the nodeid the failing subtest had just added. Doing the rewrite in pytest_runtest_logreport would make it robust. Separately, two tests in test_runner.py carried a conftest customizing node classes by name — a mechanism python.py hasn't consulted in years, with every assertion that would have noticed already commented out.

In the tests. test_failing_teardown_issue9 asserted "*1 xfail*", which happily matched the 1 xpassed, 1 xfailed summary and concealed an xpass. Two undefined names had been sitting inside makepyfile strings, invisible to ruff and mypy because they were string literals. Several outcome assertions were simply wrong: HookRecorder.assertoutcome() counts any failed report, so setup/teardown failures were recorded as failed and xfails as skipped, and one test was silently ignoring a teardown error.

In the ensemble API — eight defects, every one of the "green test that asserts nothing" kind. All are now fixed on the base PR, found only because real tests were pointed at the API:

  1. Ensemble.run() bypassed pytest_runtestloop, so --maxfail/-x/shouldstop were silently inert (hit independently by three ports).
  2. RunRecord was built before pytest_sessionfinish, so anything emitted there — warnings especially — was invisible.
  3. The assertion plugin was not in DEFAULT_PLUGINS, so util._reprcompare stayed bound to the host run: an explanation was still produced and tests went green, but the ensemble's own -vv, ini values and pytest_assertrepr_compare impls were ignored. Swapping the plugin out makes 16 of 33 ports in test_assertion.py fail, so they are load-bearing.
  4. Configure-time warnings escaped into the host run, which matters because this suite runs filterwarnings = error. One ported test was depending on the leak and had to be rewritten when it was closed.
  5. --strict-markers, -o name=value and addopts were parsed and then dropped.
  6. --setup-plan/--setup-only/--setup-show were inert, because their normalization lived in pytest_cmdline_main.
  7. collect_tests() returned [] on a collection failure, making "collects nothing" and "collection exploded" indistinguishable.
  8. Ensemble.collect() was not idempotent.

What the enablers bought

Three capabilities were added mid-experiment, each in response to something the ports measured:

  • A private output stream counts as captured. _determine_show_progress_info() suppressed the progress column whenever getoption("capture","no") == "no", always true for an ensemble — but the concern behind Use classic console output when -s is used pytest-dev/pytest#3038 is progress interleaving with test output, and a reporter writing to its own buffer has none. This alone unlocked 17 tests in test_terminal.py.
  • run_items goes through pytest_runtestloop. That is where the terminal defers its final [100%] fill, so 14 of those 17 would otherwise have needed a relaxed final pattern — and five patterns in test_subtests.py that the first pass had to weaken were restored.
  • A real module keeps its real path, plus module_from_path() which imports a file without touching sys.modules or writing bytecode. This is what lets an example script run as itself.

That last one changed how example-driven tests are handled. They had been ported by inlining their source, which orphaned eight files under testing/example_scripts/ and moved every reported location into the host test file. They now run the real scripts in place, so test_funcarg_lookupfails can assert

"file *test_funcarg_lookupfails.py, line 12"

— a pattern that was unusable while the source was inlined. Every example script under fixtures/ and unittest/ is referenced again.

What blocks the remaining 281

Measured, and it twice corrected my own ranking. I first expected host-anchored item locations to dominate; test_terminal.py quantified it, and the progress column turned out to be larger. With the column fixed, locations are now the top blocker:

  1. Item locations follow the code object (~20 in test_terminal.py alone): a source written inline in a test body reports the host file, so file:line assertions, the <- file> suffix at -vv, and console_output_style=times — which groups by report.location[0] and therefore sees every item as one module — all fail. module_from_path sidesteps this for scripts that exist on disk; making a synthesized item report its collector's path would close the rest.
  2. pytest_cmdline_main options (~30): --collect-only, --fixtures, --markers, --help.
  3. Conftest directory scoping (~25): extra_plugins reproduces a rootdir conftest exactly, but nothing below it.
  4. Real module import and file rewriting (~35), real filesystem/rootdir/packages (~40), capture (~15), subprocess/pexpect/xdist (~20), wrap_session-only output and exit codes (~15).

Two are design findings rather than gaps. scope="package" degrades silently with no Package node, so a test can go green while exercising a different path. And two tests became unportable because of the progress fix — they assert that --capture=no suppresses the column, which an ensemble's private stream can never do; porting them would assert nothing.

Caveats

  • Experiment, not a merge candidate.
  • Timings are from one machine; the ratios transfer, the absolutes do not.
  • No example script is orphaned: the inlined ports were converted to run the real scripts in place, bringing eight previously-unreferenced files back into use.
  • Where a file would otherwise have lost all pytester coverage, one or two tests were kept as deliberate file-based canaries so real module import and path-based nodeids stay exercised.
  • Each file was ported by a separate agent and then independently re-verified; several agent self-reports turned out to be wrong (a "mypy clean" claim that wasn't, an environment without ruff/mypy at all, and a codespell autofix that would have broken a parametrize id). Ports asserting rendered output were additionally mutation-tested by their agents.

Per our AI contribution policy: this was researched, designed and implemented with Claude Code under my direction and review.

@sourcery-ai

sourcery-ai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR ports a large set of pytest internal tests from the older pytester-based runner to the new _pytest.ensemble API, updating fixtures, xunit setup/teardown expectations, marker semantics, and parametrization behavior while preserving test outcomes. It also adds a small codespell ignore entry and leaves a few tests intentionally on pytester as canaries or due to current ensemble limitations.

Sequence diagram for a ported test using run_tests

sequenceDiagram
    participant TestCode as test_function
    participant build_module
    participant ConfigSpec
    participant run_tests
    participant RunRecord

    TestCode->>build_module: build_module("test_name", test_function,...)
    build_module-->>TestCode: module

    TestCode->>ConfigSpec: ConfigSpec(rootpath=tmp_path, extra_plugins,...)
    ConfigSpec-->>TestCode: spec

    TestCode->>run_tests: run_tests(module_or_fixtures..., spec=spec, capture_output=True)
    run_tests-->>RunRecord: record

    TestCode->>RunRecord: assert_outcomes(passed=..., errors=..., xfailed=..., xpassed=...)
    TestCode->>RunRecord: access record["test_name"], stdout, warnings
Loading

File-Level Changes

Change Details Files
Port TestFillFixtures and fixture override/visibility tests in testing/python/fixtures.py from pytester to ensemble-based collection and execution.
  • Replace pytester.copy_example/inline_run/runpytest usages with ensemble.run_tests and Ensemble/ConfigSpec where fixtures and tests are defined inline.
  • Model rootdir conftests as plugin objects via ConfigSpec.extra_plugins, reproducing directory-scoped fixture behavior where possible.
  • Adjust outcome expectations from generic failed counts to precise errors/passed/xfailed based on typed records, and update fnmatch_lines assertions to use captured stdout from RunRecord.
  • Introduce an unregistered_mark helper to build mark decorators without consulting the host configuration, used where strict marker validation would otherwise fail.
testing/python/fixtures.py
Port unittest integration and xunit runner tests in testing/test_unittest.py to use ensemble while preserving skip/xfail semantics and traceback behavior.
  • Replace pytester.getmodulecol/getitem/inline_run/runpytest with build_module, collect_tests, and run_tests, threading state through closures or module objects instead of real files.
  • Re-express xfail/skip outcome assertions using RunRecord.assert_outcomes with explicit xfailed/xpassed/skipped/error counts instead of HookRecorder.assertoutcome.
  • Adapt tests that depend on PDB/debugging, setuptools plugin loading, or example scripts to use ConfigSpec.with_plugins and appropriate args, while leaving a few as pytester-based canaries where subprocess or real import behavior is required.
  • Use ensemble_mark helper and inline modules to test strict marker validation, '-m' expression handling, and marker inheritance without relying on host configuration markers.
testing/test_unittest.py
Port metafunc and parametrization behavior tests in testing/python/metafunc.py to ensemble, focusing on run_tests/collect_tests and ConfigSpec instead of pytester-based file generation.
  • Swap pytester.makepyfile/inline_run/runpytest/--collect-only based tests for build_module plus collect_tests/run_tests, using ConfigSpec for command-line args like '-k', '--strict-markers', and warning filters.
  • Rework tests that assert on collection order or nodeids to examine collected Function items directly, using item.name/nodeid instead of grepping console output.
  • Preserve error and warning behaviors (invalid scopes, bad ids, missing fixtures/arguments) by catching pytest.Collector.CollectError or UsageError and matching messages, rather than relying on textual stdout.
  • Add ensemble-specific helpers such as passed_names and markexpr_module/spec to avoid re-parsing configuration and centralize mark/selection assertions.
testing/python/metafunc.py
Port collection and xunit tests in testing/python/collect.py and testing/test_runner_xunit.py to ensemble where possible, keeping a few pytester-based tests as canaries for real-import and path-based behavior.
  • Use build_module and collect_tests to replace pytester.getmodulecol/getitems for class/function collection and keyword matching tests; assert types and nodeids on in-memory modules.
  • Convert tests that previously asserted on rendered '--collect-only' output to direct assertions over collected Node/Function objects, preserving definition-order semantics and fixture scoping checks.
  • Introduce ensemble-only versions of xunit setup/teardown tests that previously used file-based modules, threading module-level state through closures and asserting RunRecord outcomes rather than HookRecorder counts.
  • Leave tests that must exercise real module import, directory scoping, and path-based nodeids (e.g. TestModule, directory-based conftest behaviors, some xunit setup tests) on pytester and add comments marking them as ensemble blockers or canaries.
testing/python/collect.py
testing/test_runner_xunit.py
Port marker semantics and keyword selection tests in testing/test_mark.py to ensemble, moving away from pytester and example scripts while retaining strict marker validation and mark-expression behavior.
  • Replace pytester.makepyfile/makeini/inline_run with inline sources built via build_module and run_tests, using ConfigSpec for ini-equivalent configuration (markers, python_functions, filterwarnings) and '-m'/'-k' expressions.
  • Introduce ensemble_mark helper to construct MarkDecorator without consulting host config, ensuring tests about unregistered markers and strict marker enforcement operate against the intended configuration rather than the pytest host suite.
  • Re-express tests that previously asserted via terminal output ('--markers', '-rs', deselection summaries) as structured assertions over RunRecord.by_test, by_test names, deselected counts, and recorded warnings/errors.
  • Retain one or two pytester/example_script-driven tests (e.g. marks considered keywords, example_scripts under testing/) as canaries so real filesystem-based discovery and plugin loading remain covered, adding comments documenting why they remain unported.
testing/test_mark.py
Update codespell configuration to ignore a newly used test id string introduced by the ensemble port.
  • Add 'hellow' to tool.codespell.ignore-words-list in pyproject.toml to prevent codespell from flagging the test id used in parametrization marker tests.
  • Leave other tooling configuration (ruff, mypy, etc.) unchanged, ensuring the port does not introduce new lint or type errors.
pyproject.toml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@RonnyPfannschmidt RonnyPfannschmidt changed the title Experiment: port 380 tests from pytester to _pytest.ensemble Experiment: port 619 tests from pytester to _pytest.ensemble Aug 13, 2026
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the ensemble-migration-experiment branch 3 times, most recently from 18edf5e to 02bcb13 Compare August 14, 2026 06:02
@RonnyPfannschmidt RonnyPfannschmidt changed the title Experiment: port 619 tests from pytester to _pytest.ensemble Experiment: take 864 of 1145 tests in 13 files off pytester Aug 14, 2026
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the ensemble-migration-experiment branch from 02bcb13 to a39aa5b Compare August 14, 2026 06:48
158 of 205 pytester-based tests now build their sources as real python
objects and assert on typed records. The largest single unlock was that a
rootdir conftest is exactly reproducible as a plugin object in
extra_plugins - fixture visibility, override chains, getfixturevalue
super-lookup and autouse all behave identically - which carried ~30 tests
on its own.

47 tests stay on pytester, each with an `# ensemble:` reason: conftest
directory scoping (10), --fixtures/--markers rendering which is served
from pytest_cmdline_main (13), assertions on a fixture's file:line, which
is host-anchored (8), package layout and scope="package" (7), errors
raised at module import (4), real plugin modules and example trees (2),
subprocess (2).

Two outcome assertions were wrong in opposite directions and are now
corrected with a note at each site: a failing setup is `errors`, not
`failed`, and one test asserting only `ret == 0` was really
`passed=1, xfailed=2`.
62 of 67 pytester-driven tests ported; the ~54 that already drive Metafunc
and IdMaker directly are untouched. Module- and class-level
pytest_generate_tests work natively because the synthesized module is a
real module object, and conftest-level hooks come over as plugin objects
in extra_plugins.

Most -v/fnmatch_lines assertions here only checked parametrization ids and
pass/fail, so they became exact collect_tests() name lists and record[...]
lookups - stronger than the globs they replace. Four rendering-centric
tests keep their fnmatch_lines verbatim via capture_output=True.

The xfail tests asserted `skipped` because HookRecorder lumps xfails in
with skips; they now assert `xfailed`, with a note at each site so the
change does not read as a mistake.

Four stay on pytester: conftest directory scoping, a misspelled marker
that raises against the host config at decoration time, a package-scope
reordering test that would go green while exercising a different path, and
a subprocess test.

Also fixes a trap this port exposed: Pytester.genitems() does not reorder,
but the full collection protocol does, so a test unpacking items
positionally silently got the wrong ones. It now looks them up by name.

`hellow` joins the codespell ignore list: it is a deliberate parametrize
id that predates this change, and codespell only scans touched files.
59 of 70 tests ported. Setup/teardown ordering tests record into closure
lists asserted directly, which removes the smuggling of state through
module attributes and through monkeypatched sys attributes.

Outcome categories are corrected where HookRecorder's counting differed
from the terminal's, each with a note: xunit-style setup failures are
`errors`, trial todo and module-level pytestmark are `xfailed`, and one
test was silently ignoring a teardown error. unittest's own setUp/tearDown
failures stay call-phase `failed` - the two mappings genuinely differ.

Moving sources out of makepyfile strings put them under ruff and mypy for
the first time, which exposed two undefined names that had been sitting in
the strings. The one in test_unittest_skip_issue148 is now a closure-list
assertion that actually proves setUpClass/tearDownClass did not run.

11 stay on pytester: example-script driven (5), subprocess (3), pexpect
(1), --fixtures output (1), and one deliberate file-based canary so real
module import and path-based nodeids remain covered here.
44 of 53 pytester-based tests ported, ~100 parametrized cases now running
in memory.

Marker names are the awkward part: pytest's own suite runs with
strict = true, and a decorator in the enclosing test body is resolved
against the *host* config long before the ensemble exists. Rather than
rename the markers to ones the host happens to register - which would
weaken the tests - an `ensemble_mark()` helper builds the MarkDecorator
directly, keeping every original name and kwargs shape. Marks resolved
*inside* an ensemble are unaffected: MARK_GEN._config is swapped to the
nested config while it is configured.

Two tests got stronger rather than merely equivalent: one asserting only
`ret == 0` now asserts the outcome counts, and the -k selection test gains
an unmarked sibling so it proves selection rather than "everything ran".

9 stay on pytester: --markers rendering served from pytest_cmdline_main
(2), path arguments and arg-level collection (2), real files and conftest
scoping (1), decoration-time marker resolution (1), string conditions
resolved in host globals (1), absence of Directory nodes (1), and one
asserting a host-anchored line number.
47 tests ported, and the file is 78 lines shorter for it. Rendering globs
became exact nodeid lists in several places - test_ordered_by_definition_order
and test_abstract_class_is_not_collected now assert the whole collected
sequence rather than fnmatch-ing a few lines of --collect-only output.

The build_module naming rule caught two sources: @pytest.fixture returns a
FixtureFunctionDefinition whose __name__ comes from the wrapped function,
so a decorated fixture passed positionally would have registered under the
wrong name and left the test green with the fixture simply missing. Both
are keyword members now, with a note saying why.

29 stay on pytester: real module import (10, the whole TestModule class is
about the import chokepoint an ensemble bypasses), real files, packages
and duplicate arguments (9), conftest scoping and hooks that never fire
(4), host-anchored reportinfo (3), rendering anchored at conftest line
numbers (2), and python_files discovery (1).
10 of 12 tests ported. These are setup/teardown ordering tests, so they
lose the module-global smuggling and the getcalls("pytest_runtest_setup")
archaeology entirely: events go into a closure list that the assertion
reads directly.

Every xunit setup failure moves from assertoutcome(failed=N) to
assert_outcomes(errors=N) - setup_module/setup_class/setup_method failures
are setup-phase - with a note at each site. One test previously asserted
no outcome at all and now asserts errors=1, so it can no longer pass
vacuously.

2 stay on pytester as canaries, each with a reason: one whose subject is a
genuine module global shared between hooks and test bodies, and one
covering path-argument collection with real file nodeids.
64 of 79 pytester-based tests ported - the highest yield in the migration
so far, because this file is dominated by outcome assertions.

The decisive rule for this file: short-summary lines keyed on the *nodeid*
(-rx/-rX/-rE/-ra, FAILED) transfer verbatim once the synthesized module is
named deliberately, while "SKIPPED [1] <path>:<line>: reason" renders
item.location, which for an ensemble source is the host file. That single
line is why the seven skip-reporting tests stayed.

Two outcome assertions were hiding something. test_failing_teardown_issue9
asserted "*1 xfail*", which happily matched the "1 xpassed, 1 xfailed"
summary and concealed the xpass; it is now assert_outcomes(xpassed=1,
xfailed=1). Four tests failing in pytest_runtest_setup are errors, not
failures.

15 stay on pytester: host-anchored skip locations (7), module-import-time
behaviour (3), custom Items via pytest_collect_file (2), conftest
directory scoping (1), --markers (1), and one whose subject is a
module-global reachable from a string condition - evaluate_condition reads
item.obj.__globals__, which for an ensemble source is this file.
34 of 53 pytester-based tests ported. Report-shape assertions map onto
RunRecord.reports and the ItemRecord phases directly, and ret == 1 has a
faithful stand-in in session.testsfailed - which is exactly what
wrap_session turns into the exit code.

Two tests carried a conftest defining `class Function(pytest.Function)`.
Node-class customization by name has not been consulted in years -
python.py instantiates Function directly - and every assertion that would
have noticed was already commented out. Verified empirically with a real
conftest on disk before dropping it.

19 stay on pytester: import-time behaviour of a real module (7),
wrap_session/main() (4), capture (2), session bail-out mid-run (2),
custom Collector via pytest_collect_file (1), module-level hooks (1),
subprocess (1), real stream encoding (1).

The two session bail-out tests are worth noting: they look like plain
assert_outcomes ports, but run_items has no shouldstop handling, so a port
would have quietly asserted a fully-run session instead.
32 of 51 pytester-based tests ported, asserting on typed WarningMessage
objects instead of scraping the -rw summary block.

The expected obstacle did not materialize. Config._catch_configured_warnings
prepends `always` filters for (Pending)DeprecationWarning, so they beat the
host suite's inherited `filterwarnings = error` - which means the whole
TestDeprecationWarningsByDefault class, whose docstring explains that all
its runs are in a subprocess precisely so they do not inherit filters,
ports with no ini override at all. Six subprocess runs became in-process.

Several assertions got stronger rather than merely equivalent: "the
warnings summary is absent from stdout" became warnings=0 plus an empty
record.warnings, and the collection warning test now asserts it was
reported with when="collect".

19 stay on pytester: warning file/line/func or a summary keyed on real
paths (8), real importable plugin modules (4), a fresh interpreter (2),
Config.parse phases an ensemble skips (2), and three one-offs.
27 of 31 pytester-based tests ported.

This file taught us how subtest outcomes actually read: a failed subtest is
plain "failed" and the top-level report is also failed, so one test with
one failing subtest is failed=2; a passed subtest is "subtests passed", a
key assert_outcomes() silently ignores, and one that only exists when
verbosity_subtests > 0. Ported tests assert it explicitly.

The --last-failed test was a silently green no-op before it was made to
load the terminal plugin: subtests marks the top-level test failed as a
side effect *inside* pytest_report_teststatus, so with nothing rendering,
LFPlugin saw the report still passed and popped the nodeid the failing
subtest had just added. cache/lastfailed was never written and the rerun
was indistinguishable from a fresh run.

4 stay on pytester: three need capture around the whole item, and one is
xdist over real files.
10 of 24 pytester-based tests ported - a lower yield than the file's
pytester count suggests, because much of it is bound to rootdir discovery
and path-argument collection.

--exitfirst and --maxfail were the interesting ones. run_items runs every
item unconditionally; the early exit lives in pytest_runtestloop, which
ensembles bypass, so a naive port would have run everything and asserted
the opposite of the subject. They now drive the real hook and assert the
exact surviving nodeid list plus the shouldfail message.

The sticky-flag tests hit a related edge: RunRecord is built before
pytest_sessionfinish, and the warnings those tests are about are emitted
inside it, so assert_outcomes(warnings=1) would have been a silent
false-green. Both use a pytest_warning_recorded impl on their plugin
object instead.

14 stay on pytester: filesystem and path-argument collection (6), real
module import (2), -p (2), rootdir discovery (2), pytest_collect_file (1),
wrap_session cwd restore (1).
33 of 54 pytester-based tests ported; the 98 that already unit-test the
assertion internals directly are untouched.

A rewritten assertion renders essentially byte-identical to a real run -
same >/E prefixes, same explanation, same Full diff, same short-summary
echo. The traceback formatter dedents the source, so exact-indent patterns
transfer unchanged however deeply the source function is nested in the
host test body.

The decisive finding is that an ensemble needs the `assertion` plugin
loaded, and it is not in DEFAULT_PLUGINS. Without it util._reprcompare
stays bound to whatever the *host* run installed: an explanation is still
produced, so tests go green, but the ensemble's own -vv, ini values and
pytest_assertrepr_compare implementations are silently ignored. Swapping
"assertion" for "terminal" in the local spec helper makes 16 of the 33
ports fail, so they are load-bearing rather than vacuous.

21 stay on pytester: rewriting of files on import (7), assertions on the
failing source's file:line (3), rewrite-time warnings (2), module-file
semantics of the rewriter (2), and seven one-offs.
39 of 146 pytester-based tests ported - deliberately conservative. This
file's subject is rendering, so a port buys speed and hermeticity but no
assertion strength, and relaxing a pattern to make one pass would destroy
what the test is for. Everything left carries an `# ensemble:` reason.

What blocks the rest, measured rather than assumed:

- the progress column, ~40 tests, and it is not the item-location problem.
  TerminalReporter._determine_show_progress_info() returns False whenever
  getoption("capture", "no") == "no", and an ensemble does not load the
  capture plugin, so the column is off at every verbosity whatever
  console_output_style says. The final [100%] additionally comes from a
  pytest_runtestloop hookwrapper that run_items never reaches.
- --collect-only rendering, ~16, a pytest_cmdline_main concern.
- captured-output sections, 10.
- host-anchored file:line, 9, plus 4 asserting the `<- file>` suffix.

Note _locationline only appends `<- ...` at verbosity >= 2, so plain -v
progress lines already match upstream verbatim - which is what carried
test_verbose_reporting and TestClassicOutputStyle.test_verbose.

One hazard recorded for future ports: assertion-explanation verbosity is
host-global, because _pytest.assertion.util._reprcompare is bound at host
configure time. -vv inside an ensemble does not un-truncate an assertion.
Follow-on to the base branch containing configure-time warnings.

test_pytest_configure_warning had been catching the warning through the
host's recwarn fixture - it was depending on the leak. It now asserts on
record.warnings, and declares the ini filter that makes the warning a
warning rather than an error: absent one, an ensemble inherits this
suite's filterwarnings = error.
These five patterns had to drop their trailing "[100%]" when the file was
ported, because the terminal reporter defers that final fill to
pytest_runtestloop and an ensemble drove the items directly. The ensemble
now goes through that hook, so the fill is rendered and the patterns match
upstream again - one less place where a port asserted less than the
original.
The example-driven tests were either left on pytester or ported by
inlining their source - and inlining orphaned the example files while
moving every reported location into the host test file.

module_from_path() plus EnsembleModule keeping a real __file__ makes the
third option work: run the example from where it lives. Eight tests in
fixtures.py move off inlined copies and back onto the real scripts, and
four more in test_unittest.py come off pytester entirely.

The locations are the point. test_funcarg_lookupfails can now assert

    "file *test_funcarg_lookupfails.py, line 12"

which named this file when the source was inlined, so the pattern could
not be carried over at all. Same for test_fixture_named_request's
"*test_fixture_named_request.py:8" and the nodeid assertions.

test_extend_fixture_conftest_module is the shape worth noting: the
example's conftest.py sits at the example's own root, which is exactly
what an ensemble plugin object stands for, so both files now run as
themselves.

Every example script under fixtures/ and unittest/ is referenced again -
the eight that inlining had orphaned are back in use.
17 more tests move to _pytest.ensemble now that a private output stream
counts as captured and the run goes through pytest_runtestloop:
TestProgressOutputStyle (6), TestProgressWithTeardown (4) and the
non-collect-only half of TestFineGrainedTestCase (7).

Every pattern is kept verbatim - the column renders at the same character
positions, needs no console_output_style ini, and ends on the same
[100%]/[20/20] fill - and each port gains an assert_outcomes() the
pytester original never had. The ported fine-grained tests pin COLUMNS=80,
which pytester used to supply implicitly.

Two tests are now unportable *because* of the fix and say so:
test_capture_no and test_capture_no_progress_enabled assert that
--capture=no suppresses the column, and an ensemble's private stream
always counts as captured, so porting them would assert nothing.

Running total: 70 of 159 tests in this file are off pytester.
The existing benchmark contrasts pytester and _pytest.ensemble on
generated sources. That measures the harness but not the usual shape of a
pytester test, which is: take a script that already exists under
testing/example_scripts, put it somewhere, and run it.

This one runs the *same real files* through both routes - copy into the
pytester tmpdir and run it there, versus module_from_path and run_tests
where it lives - so the only difference is the harness. It also isolates
the copy and the import, which turn out to be noise (0.1-0.2ms) next to
the session bootstrap either side of them.

The import-failure example is deliberately not a subject: the two
harnesses would not be doing the same work.
27 of 86 pytester tests move to _pytest.ensemble. The yield is deliberately
low: this file is the one place where the *program* is the system under
test, and roughly 40 of the 59 that stay assert something with no meaning
inside a nested config - an exit code, a stderr line, what -p/--pyargs do
to argv, which conftest is visible from which directory, what happens when
a module fails to import. TestInvocationVariants (24 tests) should never
move; its entire content is "invoked this way, pytest still works".

What did move was mostly misplaced rather than slow: parametrize ids,
fixture scope ordering, async rejection, StopIteration handling - unit-ish
behaviour that had accreted into an acceptance file because pytester was
the only tool available. Several gained real assertions on the way, with
`assert result.ret == 0` becoming exact outcome counts, and
test_with_deselected/test_with_not now asserting the deselection they were
named for.

TestDurations turns out not to be about duration measurement at all - with
mock_timing the clock is fake - so it is a terminal rendering test, and
ports onto capture_output=True keeping its lines verbatim.

test_fixture_mock_integration runs the real example script via
module_from_path rather than inlining it, so the example stays in use.

Wall clock 7.82s -> 7.32s: 30% of the tests bought 6% of the time. The
value here is placement, not speed.
44 of 50 pytester tests. cacheprovider works in an ensemble with nothing
special: with_plugins("cacheprovider") against a real tmp_path rootdir
gives a real config.cache, and two ensembles sharing a rootpath share the
cache - which is exactly the shape --lf/--ff/--nf tests need.

The one real design decision is which sources exist on disk. --lf's
file-level skipping and --nf's mtime ordering both need path.exists(), so
those use a written file plus module_from_path; everything else uses a
synthesized module, where the last-failed wrapper ignores non-existent
paths and so behaves like a file named on the command line - which is what
preserves the original "N deselected" assertions. Both helpers are
documented at module level.

Assertions that had to change are commented at each site: --co trees lose
their <Dir> lines, one summary reads "- AssertionError" rather than
"- assert 0" because ensemble sources are not rewritten, and
NO_TESTS_COLLECTED becomes an empty outcomes() dict. A print from
pytest_sessionfinish became a direct assertion on the plugin object, which
is stronger. Seven of the riskiest new assertions were mutation-tested.

6 stay on pytester: real packages and nested trees (3), modules that raise
at import (2), and --help (1).

Wall clock 4.51s -> 3.84s. Modest, because every test here was already
in-process - see the commit adding the autoload benchmark for why that is
not the interesting number.
47 of 67 pytester functions (75 of 140 instances). junitxml works in an
ensemble unchanged: the plugin loads, pytest_configure builds the LogXML,
and the file is written from pytest_sessionfinish as the context exits, so
it exists by the time run_tests returns.

The classname hazard I warned about turned out not to exist: junitxml
derives classname from report.nodeid via mangle_test_address, not from the
code object, and the synthesized module name *is* the nodeid's first
segment. Only the xunit1 file/line attributes follow the code object, and
nothing here asserts on them. The two tests that do need real paths use
module_from_path.

run_and_parse kept its shape and became ensemble-backed; the original is
preserved as run_and_parse_pytester for the tests that stayed. Every port
asserts more than before, `assert result.ret` becoming assert_outcomes.
test_record_fixtures_xunit2 was rebinding expected_lines and silently
dropping its first expected line - both warnings are asserted now.

20 stay on pytester, 13 of them for item-level capture, which is the
single largest remaining blocker in this file.

Wall clock 6.47s -> 6.23s: this file's pytester runs were already inline,
so the saving is only pytester's fixture setup, and the residual cost is
in the 56 instances that could not move.
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the ensemble-migration-experiment branch from a39aa5b to e65f3e9 Compare August 14, 2026 08:46
RonnyPfannschmidt and others added 9 commits August 14, 2026 11:06
The rewriter's output is a pure function of source and config: the
message an ``assert`` produces is fully determined by ``rewrite_asserts``
before any reporter sees it -- ``_format_explanation`` runs inside the
rewritten ``raise``, so ``str(exc)`` is already the rendered message,
escaping and all. Tests that fnmatched ``*AssertionError: ...*`` on
terminal output were paying for a session to read a string the rewriter
had already decided.

Convert 34 such tests to the file's existing direct path, and add
``getmsg_src`` for sources that need module-level statements -- imports,
class definitions, a ``PYTEST_DONT_REWRITE`` docstring, a module-level
``assert``. It execs the module and then calls every ``test_*`` it
defines, in definition order, in the module's own namespace, which is
what the walrus-leak regressions need: they are about state leaking
through module-level ``@py_assert`` temporaries from one test function
to the next.

The 45 remaining pytester tests are tagged with why they stay. The line
falls in three places, and none of them are the rewriter's output:

* who decides to rewrite -- the import hook, ``register_assert_rewrite``,
  ``python_files``, ``--assert=plain``. Note ``PYTEST_DONT_REWRITE`` is
  checked in both ``AssertionRewriter.run`` and the hook's
  ``_should_rewrite``; the direct path covers only the former.
* where the result is stored -- pyc headers, cache dirs, marshalling.
* what config the rewriter reads -- ``enable_assertion_pass_hook`` at
  rewrite time, ``util._config`` at run time, both session-installed.

Verbosity-threshold tests are left alone for that last reason: the
message is rewriter output, but the threshold arrives via ``util._config``
and stubbing it would leave the wiring untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An ensemble item's code object belongs to the *host* test file, so a
failing item's `repr_failure` runs `getstatementrange_ast` over all
4000-odd lines of `test_terminal.py` -- `inspect.findsource` hands back
whole-file lines, and the AST cache lives on `FormattedExcinfo`, i.e.
per failure, so it never hits. That is ~40ms per failing item. A
pytester temp file is ten lines, so the same repr costs ~0.2ms there.

This is why the ported tests came out *slower* than the pytester
originals they replaced: the port trades ~10ms of config build for
~37ms of host-file reparse on every failure.

`Function.repr_failure` only reaches `_getentrysource` for tbstyle
`short`/`long`, and `--tb=no` still produces `longrepr.reprcrash`, so
short summary lines (`FAILED ... - assert 0`, `-rfE`, `-rx`) render
unchanged. Pass it at the 16 call sites -- 20 test functions -- whose
assertions never touch traceback text, each annotated with what it does
assert on instead. Host-file parses drop from 109 to 18, and the file
from 20.15s to 16.70s. The remaining 18 are tests that genuinely assert
on traceback output and must keep paying.

Levers that did not pay, measured rather than assumed: dropping
`capture_output` is worth 0.9ms and only 7 of 74 ported tests can (the
rest assert on `record.output`); `collect_tests` over `run_tests` is
worth 0.5ms, since collection is nearly the whole config cost; batching
rounds into one `Ensemble` saves only config builds, and the cost here
is per failing *item* -- `test_teardown_many` did 20 reparses inside a
single run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Locating the failing statement of a traceback entry means parsing source
into an AST, and `getsource` handed `getstatementrange_ast` the entire
file. The AST cache lives on `FormattedExcinfo`, i.e. one per rendered
failure, so a large test module reparsed all of itself for every entry
of every failure -- ~40ms per entry for a 4000-line file, against ~0.2ms
for the ten-line temp file the same test would use under pytester.

Narrow the parse to the block the frame belongs to. Full suite goes from
304.45s to 249.88s.

`inspect.getblock` rather than the code object's own line extent:
`co_lines()` can undershoot, because a trailing `), "msg"` line may
carry no bytecode, and cutting it would change the rendered source.

Two guards, both needed and both pinned by tests:

* `getblock` only walks an indented suite for def/class/decorated code;
  for anything else `BlockFinder` stops at the first logical line. A
  module-level frame therefore gets a block that is not the module --
  which rendered `???` in place of the conftest line in
  `test_better_reporting_on_conftest_load_failure`. Falling back
  whenever the reported line is outside the block covers that, and
  covers exec'd or generated code whose lines do not match the file.
* tokenizing can fail on sources that do not correspond to the frame.

The astcache key carries the parse offset rather than the frame's first
line: whether narrowing applied depends on the line being reported, so
two entries in the same function can disagree, and a cached tree must
never be paired with linenos it was not parsed from.

Not used here, for the record: `co_positions()` gives the failing
*expression* (lines 9-11 of a five-line assert whose statement is 8-12),
and `ast.stmt.end_lineno` spans a compound statement's whole suite where
pytest shows only its header -- across `src/_pytest` it differs from the
current result on 30.6% of lines, 99.5% of those compound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A `TempPathFactory` built from a config allocates a numbered directory
under the global `pytest-of-<user>` root. That scans the root and every
sibling run's leftovers, takes a cleanup lock, and leaves a directory
behind -- per ensemble. Ensembles are built in the hundreds, so the
`tmpdir` plugin cannot be a default.

Drop it from DEFAULT_PLUGINS and load it only when
`ConfigSpec.tmp_path_factory` supplies a factory, bound over the one the
plugin builds by a trylast `pytest_configure`, so the fixtures, retention
handling and sessionfinish cleanup all stay the plugin's own.

`make_tmp_path_factory(basetemp)` builds one that takes the `--basetemp`
path rather than allocating; the `ensemble_tmp_path_factory` fixture
roots it in the host test's own `tmp_path`, so it needs no allocation and
is cleaned up with the host.

Five tests in testing/python/fixtures.py genuinely use `tmp_path` inside
the ensemble and now opt in; nothing else in the suite did, which is the
point -- ~1000 ensemble configs were registering a plugin they never used.

Note this does not move `find_prefixed`: `getbasetemp()` is lazy, so
those ensembles never allocated a base temp to begin with. The 19s of
directory scanning is pytester's own `tmp_path`, not the ensemble's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`unraisableexception` was already absent from DEFAULT_PLUGINS, but an
ensemble that opted into it would inherit the default gc policy and run
a full `gc.collect()` at both the cleanup and the unconfigure site. The
heap that walks is the *host* process's - measured at ~243ms a pass in
pytest's own suite - so it costs whatever the host happens to be holding
rather than anything the ensemble owns.

`ConfigSpec.gc_collect_iterations` seeds the stash key the plugin reads,
defaulting to none: opting the plugin in gets the unraisable *reporting*
without the collection, and a test that needs finalizers flushed for an
exception to surface raises it deliberately.

pytester keeps its own policy unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pytester.getitems` builds a config through `parseconfigure`, which does
not get pytester's `gc_collect_iterations = 0` the way `inline_run` does
-- so each of these three paid two full `gc.collect()` passes over the
host suite's heap, ~243ms each, for two lines of assertion.

`warn` needs a live config for the hook call, so the parametrized one
collects inside an `Ensemble` block; the type-enforcement one only needs
the item, so `collect_tests` is enough.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_check_equality` and `test_unpacked_marks_added_to_keywords` are
about node identity and mark unpacking, not about collecting a
filesystem -- which is what the rest of this file tests and what an
ensemble deliberately does not do.

Note `Node` defines no `__eq__`: equality is identity, and the original
passed because `collect_by_name` returned pytester's cached node both
times. The port looks the name up twice in one collection rather than
collecting twice, which would have compared two distinct objects with
equal node ids and failed.

`build_module` carries the module-level `pytestmark` as a keyword member,
so the class and method marks nest exactly as in the source file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`configured()` runs configure and unconfigure around the block, which is
what the original drove by hand. The terminal plugin is opted in so that
`-p terminal` names one that is loaded already, and given a private
buffer -- an ensemble's terminal reporter must never bind the outer
test's stdout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The subject is that `TopRequest.path` and `.fspath` agree with the
module's, so it needs `legacypath` opted in but nothing from the
filesystem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant