QA automation engineer reviewing pytest parameterized test failures on dual monitors

Why One pytest Parameter Changes the Next Test Case

Thu, Sep 24, 2026

A parameterized test passes when selected alone, yet the next case fails with state that the next case never created. That signal is easy to misclassify as flaky ordering, fixture caching, or a reason to add a rerun. For mutable parameters, the first operational question is narrower: does every collected case receive the independent input state its assertions assume?

This playbook answers that question with one controlled Python project. The canary is deliberately small: SHARED = {"enabled": True, "events": []} is supplied twice to one parameterized test. Each case asserts an empty event list and then appends "visited". The experiment separates four things that are often blurred together: pytest parameter binding, fixture invocation, outer-container identity, and nested-object identity.

The mechanism is documented, not speculative. pytest states that parameter values are passed to tests without copying, so mutation of a list or dictionary can be visible to later calls. The laboratory below then tests that documented behavior against a pinned environment and compares an indirect function-scoped fixture, a shallow dictionary copy, a factory, and deepcopy of an untouched plain-data template.

The release decision is equally bounded. A green isolated rerun is not proof of independence. Acceptance requires normal order, reverse order, isolated cases, identity evidence, and an explicit fixture contract. Controls that should fail stay in a diagnostic harness; corrected cases are the only variants eligible for the clean suite.

Define the independence your test cases require

The invariant comes before the failing assertion: at the start of each case, payload["events"] must equal [], and any mutation performed by one case must be unreachable through the next case's input. That is the test-case isolation contract. It is more precise than saying “the fixture is function-scoped” or “the test passes by itself,” because neither statement identifies which mutable objects are fresh.

For this lab, independence means three properties. First, the case begins with the specified logical value: {"enabled": True, "events": []}. Second, the mutable events list belongs to that case, not to a previous case. Third, the evidence must make the alias relationship visible rather than infer it from pass/fail status alone. is checks are appropriate inside one process for that diagnostic purpose; numeric id() values are logged only as local evidence, never as durable identifiers across processes.

pytest's own parametrization guide provides the key documented behavior: parameter values are passed as-is, with no copy. It specifically warns that mutations to list or dictionary parameter values can affect subsequent test calls. That documentation establishes the selected mechanism; it does not imply that every order-dependent pytest failure has this cause.

This article therefore does not repeat the broader scope of Refonte Learning's QA automation frameworks and project practice. The acceptance target here is one input boundary: each parameterized case must receive the independent mutable state that its assertions assume. Broader coverage, UI automation, service integration, and career tooling are outside this experiment.

A useful release sentence is: “For fixture revision 2026-09-24.r1, these plain-data parameter cases start from independent mutable state under the pinned serial pytest invocation.” It is deliberately narrower than “the project is order-independent.”

Pin the Python and pytest execution environment

Reproducibility starts by making environmental explanations expensive. The laboratory execution used CPython 3.13.5, pytest 9.0.2, pluggy 1.6.0, iniconfig 2.3.0, and packaging 25.0. The project revision label embedded in every trace is 2026-09-24.r1. The research and source-access date is September 24, 2026.

A default --trace-config inspection found these third-party pytest entry-point plugins installed in the environment: anyio 4.13.0, ddtrace 4.4.0, Faker 40.1.2, pytest-asyncio 1.3.0, pytest-cov 7.0.0, pytest-json-report 1.5.0, and pytest-metadata 3.1.1. They were not loaded for the controlled runs. PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 excluded third-party auto-loading, and -p no:cacheprovider also disabled pytest's cache provider. No retry option, retry plugin, xdist worker, or -n option participated.

The source documents are living documentation, not an environment lock. The Python copy page opened on the cutoff date identified itself as Python 3.14.7 documentation, whereas the executed interpreter was Python 3.13.5. The relevant semantics are the documented binding and copy rules, but any future mismatch between docs, installed versions, and observed traces must be reported rather than “fixed” by quietly changing the fixture. Python engineering foundations provide broader language context; the lab remains pinned to the versions above.

Use this project shape:

pytest-state-lab/
├── pytest.ini
├── run_matrix.py
└── tests/
    ├── lab_support.py
    ├── test_direct_shared.py
    ├── test_indirect_identity.py
    ├── test_shallow_copy.py
    ├── test_factory_fresh.py
    └── test_deepcopy_fresh.py

The configuration is intentionally minimal:

[pytest]
addopts = -p no:cacheprovider

Before executing the matrix, capture the environment separately from the test evidence. python --version, python -m pytest --version, python -m pip list, and a default python -m pytest --trace-config --collect-only -q establish what could have participated. Then repeat collection with PYTEST_DISABLE_PLUGIN_AUTOLOAD=1. This separation matters: an installed plugin is not the same thing as a loaded plugin, and a loaded plugin is not evidence that it caused the state transition. In this lab, the default trace exposed seven third-party entry points, while the controlled trace excluded them. That turns “maybe a plugin did it” from an unbounded suspicion into a variable that has been explicitly removed from this scenario.

The shell contract for every direct pytest invocation is:

PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONHASHSEED=0 \
python -m pytest -q -s -p no:cacheprovider "<quoted-node-id>"

PYTHONHASHSEED=0 is recorded for completeness; this defect does not depend on hash iteration. The important controls are one Python process per scenario, serial execution, fixed node IDs, no retries, and no auto-loaded third-party plugin behavior.

Build two cases from one shared dictionary

The failing control must be small enough that the alias can be inspected directly. The support module prints a machine-readable ledger line and records the process ID, node ID, outer dictionary identity, nested list identity, and pre-state. It writes no files and touches no external service.

# tests/lab_support.py
import json
import os

FIXTURE_REVISION = "2026-09-24.r1"


def emit(request, payload, *, source, relation):
    print(
        "LEDGER "
        + json.dumps(
            {
                "revision": FIXTURE_REVISION,
                "pid": os.getpid(),
                "nodeid": request.node.nodeid,
                "source": source,
                "relation": relation,
                "outer_id": id(payload),
                "events_id": id(payload["events"]),
                "pre_events": list(payload["events"]),
            },
            sort_keys=True,
        )
    )

The baseline deliberately supplies the exact same dictionary object twice:

# tests/test_direct_shared.py
import pytest

from lab_support import emit

SHARED = {"enabled": True, "events": []}


@pytest.mark.parametrize(
    "payload",
    [
        pytest.param(SHARED, id="case-a"),
        pytest.param(SHARED, id="case-b"),
    ],
)
def test_payload_starts_clean(payload, request):
    emit(
        request,
        payload,
        source="SHARED",
        relation=f"payload_is_SHARED={payload is SHARED}",
    )
    assert payload["events"] == []
    payload["events"].append("visited")
    assert payload["events"] == ["visited"]

Separate parameter bindings from object construction

Python assignment creates bindings; it does not copy objects. The Python copy documentation states that distinction explicitly. pytest adds no automatic copy at parametrization: its parametrization guide says parameter values are passed as-is. Two parameter entries can therefore be two bindings to one dictionary, and both can reach the same nested list.

That is not pytest “secretly sharing” independent values. The test author constructed one mutable object and inserted that object into the parameter list twice. Explicit IDs make the two executions easy to address, but IDs label cases; they do not manufacture case data. The pytest parametrization examples document explicit IDs and --collect-only as ways to identify parameterized cases. Those mechanisms improve case addressability and reporting; they do not imply automatic copying or isolation.

Reproduce the failure in a fresh process

First establish collection without executing mutations:

PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONHASHSEED=0 \
python -m pytest --collect-only -q -p no:cacheprovider

Actually observed output in the pinned laboratory environment:

tests/test_deepcopy_fresh.py::test_payload_starts_clean[case-a]
tests/test_deepcopy_fresh.py::test_payload_starts_clean[case-b]
tests/test_direct_shared.py::test_payload_starts_clean[case-a]
tests/test_direct_shared.py::test_payload_starts_clean[case-b]
tests/test_factory_fresh.py::test_payload_starts_clean[case-a]
tests/test_factory_fresh.py::test_payload_starts_clean[case-b]
tests/test_indirect_identity.py::test_payload_starts_clean[case-a]
tests/test_indirect_identity.py::test_payload_starts_clean[case-b]
tests/test_shallow_copy.py::test_payload_starts_clean[case-a]
tests/test_shallow_copy.py::test_payload_starts_clean[case-b]
10 tests collected in 0.01s

Now run only the direct control, explicitly in normal order, in a fresh process:

PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONHASHSEED=0 python -m pytest -q -s \
  -p no:cacheprovider \
  "tests/test_direct_shared.py::test_payload_starts_clean[case-a]" \
  "tests/test_direct_shared.py::test_payload_starts_clean[case-b]"

Before looking at the output, the state transition has a deterministic expected result. Let E be the one shared list. At module import, E=[]. The first execution checks E==[], appends one value, and therefore leaves E=['visited']. Because the second parameter binding reaches that same list, its first assertion evaluates ['visited'] == [], which is false. Reversing labels does not change this transition; it only changes which label executes first. That is the mathematically expected result for the intentionally aliased control.

Actually observed: process 488 entered case-a with pre_events=[]; both outer_id and events_id were then identical when process 488 entered case-b, whose pre-state was ['visited']. case-a passed, case-b failed, and pytest returned exit status 1. The observed trace therefore matched the documented pass-as-is behavior and the expected state transition; those are three different evidence categories, not interchangeable labels.

Reverse only the explicit node IDs in another new process:

PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONHASHSEED=0 python -m pytest -q -s \
  -p no:cacheprovider \
  "tests/test_direct_shared.py::test_payload_starts_clean[case-b]" \
  "tests/test_direct_shared.py::test_payload_starts_clean[case-a]"

Actually observed: process 494 passed case-b first and failed case-a second with pre_events=['visited'], again with identical outer and nested identities within that process. The failure follows second execution, not the case label. That is stronger evidence than a single order-sensitive failure because the manipulated variable is execution position.

Why each case passes when run alone

Each isolated invocation imports the module in a fresh Python process, recreating SHARED with an empty list before the selected case executes:

PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q -s \
  "tests/test_direct_shared.py::test_payload_starts_clean[case-a]"

PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q -s \
  "tests/test_direct_shared.py::test_payload_starts_clean[case-b]"

Actually observed: case-a passed alone in process 503 and case-b passed alone in process 509. Those passes do not prove independence. They prove that process restart reconstructed the module-level object before each selection. The shared-state defect remains present when both cases inhabit one process. A rerun that merely obtains a new process can therefore hide the evidence without changing the fixture contract.

Test the function-scoped fixture assumption

A common repair attempt is to put the parameter behind a function-scoped fixture and assume “function scope” means “fresh object.” pytest documents function scope as the default fixture execution scope: the fixture function is invoked once per requesting test function. But freshness depends on what that function returns. pytest also documents indirect parametrization: the supplied parameter value becomes request.param for the fixture.

The control makes that distinction explicit:

# tests/test_indirect_identity.py
import os
import pytest

from lab_support import emit

SHARED = {"enabled": True, "events": []}


@pytest.fixture(scope="function")
def payload(request):
    value = request.param
    print(
        f"FIXTURE pid={os.getpid()} nodeid={request.node.nodeid} "
        f"same_outer={value is SHARED}"
    )
    return value


@pytest.mark.parametrize(
    "payload",
    [
        pytest.param(SHARED, id="case-a"),
        pytest.param(SHARED, id="case-b"),
    ],
    indirect=True,
)
def test_payload_starts_clean(payload, request):
    assert payload is SHARED
    emit(
        request,
        payload,
        source="request.param",
        relation="payload_is_SHARED=True",
    )
    assert payload["events"] == []
    payload["events"].append("visited")

The fixture invocation is separate evidence from the object identity. In the observed normal run, process 515 printed a fixture line for both node IDs, proving two function-scoped fixture calls occurred. Both lines also reported same_outer=True. The second case saw the first case's "visited" and failed. In reverse order, process 521 again called the fixture twice, and the second execution failed.

This distinction prevents a common diagnostic dead end. Seeing two fixture setup messages can establish that pytest did not reuse one cached fixture result across test functions, yet the fixture body can still deliberately or accidentally return an object that already existed before either invocation. The acceptance question therefore has two columns: “Was the fixture called?” and “What object graph did it return?” Only the second column answers whether case mutation can flow into a later case.

A new fixture call can return an old object

The fixture scope controls fixture execution and caching behavior; it does not impose a copy operation on request.param. The pytest fixtures guide describes scope and separately illustrates fixtures that construct fresh objects. Its factory-as-fixture section likewise shows that the factory function itself performs construction. Freshness follows from that code path, not from the word function.

This is the acceptance distinction: “fixture executed twice” is necessary evidence about lifetime, but it is insufficient evidence about returned identity. For an indirect fixture that simply returns request.param, the parameter object can remain the same object across both fixture calls.

Expose the shallow-copy false repair

The next false repair changes the outer identity and can therefore look convincing in a debugger. dict(request.param) creates a new dictionary, but it is a shallow copy. Python's documentation defines shallow copying as creating a new compound object while inserting references to objects found in the original. For {"events": []}, the new dictionary can still reference the original list.

The control asserts both levels rather than relying on appearance:

# tests/test_shallow_copy.py
import pytest

from lab_support import emit

SHARED = {"enabled": True, "events": []}


@pytest.fixture(scope="function")
def payload(request):
    copied = dict(request.param)
    assert copied is not request.param
    assert copied["events"] is request.param["events"]
    return copied


@pytest.mark.parametrize(
    "payload",
    [
        pytest.param(SHARED, id="case-a"),
        pytest.param(SHARED, id="case-b"),
    ],
    indirect=True,
)
def test_payload_starts_clean(payload, request):
    assert payload is not SHARED
    assert payload["events"] is SHARED["events"]
    emit(
        request,
        payload,
        source="dict(request.param)",
        relation="outer_fresh_nested_shared",
    )
    assert payload["events"] == []
    payload["events"].append("visited")

Actually observed: in process 539, the two cases had different outer dictionary IDs but the same events_id; the first passed and the second failed. Process 545 reversed the labels and produced the same structural result: different outer dictionaries, one shared nested list, second execution failing. Both cases also passed when isolated in fresh processes 551 and 557.

The conclusion is intentionally narrow. Shallow copies are not “unsafe” in general. They are insufficient when the test mutates a nested object that the shallow copy intentionally shares. A useful review technique is to follow the exact mutation path: payload is fresh, payload["events"] is not, and the append targets that nested object. Copy depth should be justified by that path rather than by a blanket rule such as “always use deep copy.” This keeps simple data builders simple while still exposing the place where ownership matters. An all-immutable mapping, or a test that never mutates shared nested members, has a different contract. The repair must be chosen at the mutation boundary actually exercised.

Construct fresh data from immutable case specifications

A stronger default for test payloads is to parameterize immutable specifications and build mutable objects at execution time. The case description answers “what data should exist?”; the factory answers “which mutable objects must be newly allocated?” That separation makes ownership visible in code review.

# tests/test_factory_fresh.py
import pytest

from lab_support import emit

CASE_SPECS = ((True,), (True,))


@pytest.fixture(scope="function")
def make_payload():
    def makepayload(*, enabled):
        return {"enabled": enabled, "events": []}

    return makepayload


@pytest.mark.parametrize(
    "spec",
    [
        pytest.param(CASE_SPECS[0], id="case-a"),
        pytest.param(CASE_SPECS[1], id="case-b"),
    ],
)
def test_payload_starts_clean(spec, make_payload, request):
    payload = make_payload(enabled=spec[0])
    emit(
        request,
        payload,
        source=f"immutable_spec={spec!r}",
        relation="fresh_outer_fresh_nested",
    )
    assert payload["events"] == []
    payload["events"].append("visited")
    assert payload["events"] == ["visited"]

The fixture has no mutable default argument. Each factory call executes a dictionary literal and a list literal, allocating the mutable members needed by that case. The tuple specification itself is safe to share because the test does not mutate it. Explicit IDs remain case-a and case-b, so release evidence stays comparable to the failing controls.

Actually observed: normal order passed in process 563, reverse order passed in process 569, and isolated cases passed in processes 575 and 581. Within each pair process, the ledger recorded different outer IDs and different nested list IDs, with pre_events=[] for both executions.

Choose the copying boundary deliberately

deepcopy is a valid comparison for this plain nested template, not a universal fixture strategy. Python documents deep copy as recursively copying contained objects, while also warning that it can copy too much and that some object types are not meaningfully copied.

The safe comparison keeps the source template untouched:

# tests/test_deepcopy_fresh.py
from copy import deepcopy

import pytest

from lab_support import emit

TEMPLATE = {"enabled": True, "events": []}


@pytest.fixture(scope="function")
def payload(request):
    copied = deepcopy(request.param)
    assert copied is not request.param
    assert copied["events"] is not request.param["events"]
    return copied


@pytest.mark.parametrize(
    "payload",
    [
        pytest.param(TEMPLATE, id="case-a"),
        pytest.param(TEMPLATE, id="case-b"),
    ],
    indirect=True,
)
def test_payload_starts_clean(payload, request):
    assert TEMPLATE["events"] == []
    emit(
        request,
        payload,
        source="deepcopy(untouched_TEMPLATE)",
        relation="fresh_outer_fresh_nested",
    )
    assert payload["events"] == []
    payload["events"].append("visited")
    assert TEMPLATE["events"] == []

Observed normal, reverse, and isolated runs all passed. The template remained empty. Deep-copying an already-mutated template would reproduce the wrong starting value in a new object; copying is not restoration. For richer objects, custom copy behavior and non-copyable resources make the decision domain-specific. This article intentionally stops at plain nested dictionaries and lists.

Run the full order and isolation comparison matrix

The harness must launch a fresh Python process for every scenario so that an intentionally failing control cannot poison the next comparison. It also asserts the expected nonzero status of the diagnostic controls. A control that unexpectedly turns green is itself a harness failure, because the canary no longer proves what it was designed to prove.

# run_matrix.py
from future import annotations

import os
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
ENV = os.environ | {
    "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
    "PYTHONHASHSEED": "0",
}
BASE = [
    sys.executable,
    "-m",
    "pytest",
    "-q",
    "-s",
    "-p",
    "no:cacheprovider",
]

MODULES = [
    "tests/test_direct_shared.py",
    "tests/test_indirect_identity.py",
    "tests/test_shallow_copy.py",
    "tests/test_factory_fresh.py",
    "tests/test_deepcopy_fresh.py",
]


def run(label: str, args: list[str], expected: int) -> None:
    result = subprocess.run(
        BASE + args,
        cwd=ROOT,
        env=ENV,
        text=True,
        capture_output=True,
    )
    print(
        f"=== {label} expected={expected} "
        f"observed={result.returncode} ==="
    )
    print(result.stdout, end="")
    if result.stderr:
        print(result.stderr, end="")
    if result.returncode != expected:
        raise SystemExit(
            f"unexpected exit code for {label}: {result.returncode}"
        )


for module in MODULES:
    stem = Path(module).stem
    expected_pair = 1 if stem in {
        "test_direct_shared",
        "test_indirect_identity",
        "test_shallow_copy",
    } else 0
    a = f"{module}::test_payload_starts_clean[case-a]"
    b = f"{module}::test_payload_starts_clean[case-b]"
    run(f"{stem}:normal", [a, b], expected_pair)
    run(f"{stem}:reverse", [b, a], expected_pair)
    run(f"{stem}:isolated-a", [a], 0)
    run(f"{stem}:isolated-b", [b], 0)

The harness also gives each scenario a clean process boundary. That means the normal pair, reverse pair, case-a alone, and case-b alone are four separate interpreter lifetimes for every module. The matrix does not rely on pytest recovering from a prior failure inside the same interpreter. This is important for negative controls: once the direct control has intentionally dirtied its module-level list, the next comparison must not inherit that state.

For the three controls, the expected paired status is nonzero in both orders and the expected isolated status is zero. For the two repairs, every status is expected to be zero. Those expectations are encoded in run_matrix.py; a mismatch terminates the harness. Thus the script validates not only the proposed repair but also the diagnostic sensitivity of the controls.

The observed matrix under CPython 3.13.5 and pytest 9.0.2 was:

Variant

Normal pair

Reverse pair

case-a alone

case-b alone

Decision signal

Direct same SHARED object

1

1

0

0

Fails on second execution

Indirect fixture returns request.param

1

1

0

0

Function scope does not change identity

dict(request.param)

1

1

0

0

Fresh outer container, shared nested list

Factory from immutable spec

0

0

0

0

Independent mutable members

deepcopy untouched template

0

0

0

0

Independent plain nested data

Here 0 means pytest success and 1 means the expected test failure status for the deliberately failing pair. These are actually observed outputs from the pinned laboratory execution, not projected results. The controls' nonzero statuses are acceptable only inside the diagnostic harness; they do not belong in the clean suite.

Reconcile the object-identity and state ledger

Pass/fail status identifies a symptom. The identity ledger identifies the alias that can carry the mutation. The most relevant observed rows are below; numeric IDs are retained only to show within-process equality or inequality.

Variant/order

Node ID

PID

Pre-state

Post-state

Outer identity

Nested events identity

Direct normal

case-a

488

[]

['visited']

same as SHARED

same as SHARED.events

Direct normal

case-b

488

['visited']

not reached

same as case-a

same as case-a

Direct reverse

case-b

494

[]

['visited']

same shared object

same shared list

Direct reverse

case-a

494

['visited']

not reached

same as prior

same as prior

Indirect normal

case-a

515

[]

['visited']

payload is SHARED

shared

Indirect normal

case-b

515

['visited']

not reached

payload is SHARED

shared

Shallow normal

case-a

539

[]

['visited']

fresh outer

shared with SHARED.events

Shallow normal

case-b

539

['visited']

not reached

another fresh outer

same nested list

Factory normal

case-a

563

[]

['visited']

fresh

fresh

Factory normal

case-b

563

[]

['visited']

fresh

fresh

Factory reverse

case-b

569

[]

['visited']

fresh

fresh

Factory reverse

case-a

569

[]

['visited']

fresh

fresh

Deepcopy normal

case-a

587

[]

['visited']

fresh

fresh

Deepcopy normal

case-b

587

[]

['visited']

fresh

fresh

Deepcopy reverse

case-b

593

[]

['visited']

fresh

fresh

Deepcopy reverse

case-a

593

[]

['visited']

fresh

fresh

The Post-state column deserves one evidence note. The support logger prints pre-state, not post-state. For passing cases, ['visited'] is confirmed by the explicit post-mutation assertion in the direct and factory modules, or follows from the executed append where no later failure occurs. For failing second cases, mutation is never reached, so not reached is the only correct record. A production evidence format could emit an additional post line, but the acceptance conclusion here does not pretend that an unprinted value was separately logged.

The root cause is now stated without circular reasoning: the failing variants expose an alias path from the first case's mutation to the second case's input. Direct and indirect controls share both the outer dictionary and nested list. The shallow-copy control breaks the outer alias but retains the nested-list alias. The factory and deep-copy comparisons break the mutable alias needed to transmit "visited".

Distinguish local identity evidence from stable identifiers

Do not compare raw id() integers from PID 488 with values from PID 494, or even assume an integer cannot be reused later in one long process. Python object IDs are runtime diagnostics, not release identifiers. The durable evidence keys are fixture revision, module and node ID, immutable case specification, command line, process identity, and captured state transition.

For the factory case, the immutable specification is (True,) for each labeled case. The identical value is intentional: case identity comes from the explicit node ID, while input construction comes from the immutable value. pytest's documented explicit-ID mechanism makes those node IDs available for selection and reporting.

The isolated processes complete the ledger even though their numeric IDs differ: direct case-a PID 503, direct case-b PID 509; indirect 527 and 533; shallow 551 and 557; factory 575 and 581; deepcopy 599 and 605. Each isolated run began with pre_events=[]. That evidence explains why isolation-by-process can make every individual case green without repairing within-process case independence.

Repair the fixture contract without hiding failures

The preferred repair is the one that states ownership most clearly. For payloads whose mutable members are cheap and well understood, a pure builder or factory fixture is usually easier to review than a generic copy step: the code shows exactly which lists and dictionaries are created per case. If fixture dependency injection is valuable, let the fixture return the factory or construct from immutable request.param specifications. If no pytest feature is needed, a pure helper called by the test can be even simpler.

The assertions should not be weakened. Keep assert payload["events"] == [] because it is the canary for the required initial state. Do not add a cleanup line that clears a shared list after the case; that merely transfers correctness to teardown discipline and preserves the alias. Do not use retries, xfail, a favorable order, or only isolated invocation as the “repair.” Those methods can change visibility of the defect without changing ownership.

For the narrow plain-data template shown here, deepcopy is a valid comparison when the source template is demonstrably untouched. The factory is still more explicit about the mutability boundary. The broader QA automation strategy can frame where deterministic tests fit in delivery practice, but it is not evidence for this specialist aliasing mechanism. That evidence comes from the Python and pytest behavior plus the controlled trace.

Repair owner: the maintainer who owns the fixture or builder. Verification owner: QA automation. Rollback owner: the same fixture owner, with the previous clean-suite revision available if the builder change creates an unrelated compatibility failure. A rollback must restore a known revision, not reintroduce the shared-state control as production test data.

Protect larger suites at the same input boundary

The local dictionary stands in for a common larger-suite pattern: a reusable request payload template is defined once, parameterized across cases, and then enriched in place by each test. The same alias can occur without networks, APIs, or databases. The lesson is about the in-memory input boundary, not about external-resource cleanup.

Assign three ownership rules. Template owners define immutable specifications or clearly read-only templates. Builder owners create all mutable members that tests are permitted to change. Test owners may mutate only objects whose case-local ownership is explicit. A test that receives a shared template should treat it as immutable; a test that needs mutation should request a fresh built payload.

For example, a reusable API request description can be an immutable tuple of flags and expected behavior, while the builder materializes {"enabled": flag, "events": []} for that case. This maps cleanly to API testing and interface boundaries without requiring a real HTTP service in the acceptance test. The local fixture remains inert and deterministic.

Stop and refactor when a builder accepts a mutable default such as events=[], returns a module-level mutable object, shallow-copies a structure whose nested members are mutated, or deep-copies a template that may already have been mutated by earlier code. Also stop when test code receives a payload from one fixture and a second fixture retains another alias to the same nested list: ownership is still shared even if the parameter declaration itself looks clean. The same ledger method can expose that path without expanding this article into fixture-lifecycle or resource-cleanup design. Those are ownership defects at the same boundary even if the current case order happens not to expose them.

Do not generalize the copy strategy to sockets, database handles, threads, browser sessions, or other resource-bearing objects. Python's copy documentation explicitly notes categories that are not copied in the same way as plain containers. Those concerns require different lifecycle designs and are outside this playbook.

Add a deterministic acceptance gate to CI

The clean CI gate should contain corrected cases, not a deliberately red test. Package the factory variant, or the appropriately bounded deep-copy variant if that is the chosen contract, and execute the two explicit node IDs in both orders. Treat the order pair as one acceptance unit: a job that executes only the default order can verify today’s collection sequence, but it cannot demonstrate that the repaired input boundary survives the deliberately reversed sequence. Conversely, reverse-only execution is not a substitute for the normal path because release evidence should cover the order users and maintainers ordinarily see as well as the adversarial comparison. Because the test itself asserts the empty pre-state, a green result means both orders satisfied the narrow independence invariant under that pinned command.

A compact gate can be:

set -eu
export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
export PYTHONHASHSEED=0

python -m pytest -q -p no:cacheprovider \
  "tests/test_factory_fresh.py::test_payload_starts_clean[case-a]" \
  "tests/test_factory_fresh.py::test_payload_starts_clean[case-b]"

python -m pytest -q -p no:cacheprovider \
  "tests/test_factory_fresh.py::test_payload_starts_clean[case-b]" \
  "tests/test_factory_fresh.py::test_payload_starts_clean[case-a]"

Record Python 3.13.5, pytest 9.0.2, fixture revision 2026-09-24.r1, the two exact commands, plugin-autoload setting, source revision, and CI artifact identity. The release artifact should also retain the --collect-only -q listing so that a future change in node IDs or test discovery is visible rather than silently selecting a different case. A stable/latest documentation URL is useful research context, but it is not a substitute for this manifest because the documentation can evolve independently of the deployed test environment. The diagnostic run_matrix.py can execute separately and assert that the three controls still return 1 for paired runs while corrected variants return 0. That keeps the canary valuable without making the release suite intentionally red.

This gate complements DevOps delivery and CI/CD practice, but the operational claim remains local: both explicit orders of this fixture contract passed under the recorded environment. It does not certify every test or plugin combination in the repository.

Hold rather than mask an unexplained order dependency

Hold the change when the observed trace contradicts the model. Examples include a supposedly fresh factory returning the same mutable identity, a second case starting dirty despite distinct nested identities, different results only when an untracked plugin is loaded, or collection producing unexpected node IDs. Preserve the command, trace, plugin manifest, and source revision before expanding the investigation.

Do not “fix” unexplained evidence by reordering the suite until green. Do not add reruns. Do not disable the initial-state assertion. If the broader project has module globals, autouse fixtures, hooks, or plugins that can mutate inputs, isolate those factors one at a time. The acceptance gate here is a narrow proof about a known boundary, not a license to declare the entire repository order-independent.

Choose accept, refactor or hold from the evidence

A useful decision matrix separates states that look similar in pass/fail output but have different ownership consequences.

Evidence state

Decision

Required action

Accountable owner

Release statement

Same parameter dictionary and same nested list reach multiple cases; second execution starts dirty

Refactor

Replace mutable parameter object with immutable spec plus fresh construction

Fixture/builder owner

Not acceptable as independent cases

Function-scoped indirect fixture is called per case but returns the same request.param object

Refactor

Change fixture from pass-through binding to construction

Fixture owner

Scope alone did not satisfy freshness

Outer dictionaries differ but nested mutable list is shared

Refactor

Copy/build at the nested mutation boundary

Fixture owner

Outer freshness is insufficient

Factory creates fresh dictionary and fresh nested list; normal, reverse, and isolated runs pass

Accept

Keep identity assertions in diagnostic coverage and pin gate

QA automation + fixture owner

Accepted for this plain-data boundary and environment

deepcopy of an untouched plain-data template passes both orders and template remains unchanged

Accept with scope note

Document plain-data-only use and template immutability

Fixture owner

Accepted for this template shape, not a universal copy policy

Failure remains after mutable aliases are removed, or plugins/global state obscure the cause

Hold

Preserve evidence, isolate additional state sources, do not mask

QA lead + relevant maintainer

Independence not established

The words accept, refactor, and hold are operational outcomes, not grades of test quality. Accept means the stated invariant was demonstrated for the named fixture revision and environment. Refactor means the alias is understood well enough to change construction without weakening the assertion. Hold means the evidence is insufficient or contradictory, so a release claim about independence would be stronger than the proof. This vocabulary prevents a green rerun from being promoted into an acceptance decision without the identity trace.

Acceptance requires both QA automation and fixture-owner sign-off because the evidence has two dimensions. QA owns the execution proof: exact node IDs, both orders, isolated cases, statuses, process separation, and captured pre-state. The fixture owner owns the construction contract: which inputs are immutable, which mutable members are allocated per case, and whether any shared nested object remains reachable.

The diagnostic controls are valuable release evidence precisely because they fail for the expected reason. If the direct, indirect, or shallow-copy pair suddenly returns 0 in both orders, investigate before accepting the harness: perhaps a code change stopped mutating, process boundaries changed, or the control was accidentally repaired. A negative control that no longer detects its target cannot validate the positive comparison.

Recovery is bounded. If the factory refactor breaks callers because they relied on shared mutable identity, stop the rollout and classify that reliance explicitly. Either those callers need independent data and should migrate to the builder, or shared identity is intentional and should live behind a differently named fixture with a different contract. Do not overload one fixture name with both semantics.

Before sign-off, QA should reconcile four records: collection order, scenario command, scenario process ID, and object/state ledger. The fixture owner should reconcile three code facts: parameter specification is immutable for the tested path, every mutable member intended for case mutation is newly constructed, and the source template, when deepcopy is used, remains untouched. Any disagreement is a hold. For example, a passing reverse run with no matching process record is incomplete evidence; a fresh outer dictionary with an unexamined nested list is incomplete ownership analysis.

The evidence also sets a hard limit on the conclusion. This experiment proves deterministic case-local construction for one plain nested payload under one pinned serial configuration. It does not prove isolation for every fixture in the repository, for unexamined plugins, for randomized order campaigns, or for parallel workers. Those claims require their own evidence and are outside this article's acceptance boundary.

Make test-data ownership part of QA practice

Mutable-parameter defects persist when teams treat test data as inert values even though the test body treats those values as owned working state. The repair is therefore not “always deepcopy.” It is to make ownership inspectable: parameterize immutable intent where practical, construct mutable members per case, keep the initial-state assertion, and preserve a diagnostic canary that demonstrates why pass-through binding and shallow outer copies are insufficient for this shape.

The maintenance owner should revisit the gate whenever the payload schema, builder, pytest version, Python version, or plugin policy changes. A documentation access date is not a runtime guarantee, and an old green artifact is not evidence for a new fixture revision. Re-run both explicit orders under the new manifest and retain the narrow release statement.

For engineers developing broader QA foundations, Refonte Learning's QA Automation Engineering page lists testing frameworks, automated test-script development, CI/CD integration, practical projects, and a potential internship as program elements. The page currently states a three-month period and 12–14 hours per week, while its admission wording is not fully uniform across the page, so prospective learners should confirm eligibility directly there. This playbook should stand on its own: the program page does not establish that this exact pytest object-identity lab is taught.