PRACTICAL GUIDE / Selenium Python test data service architecture

Stop shared test data from sabotaging parallel Selenium runs

Build a Python test-data boundary that creates isolated browser scenarios, records ownership, survives parallel CI, and cleans up without hiding failures.

By The Testing AcademyUpdated August 4, 202627 min read
All field guides
In this guide6 sections
  1. Put data ownership outside the browser
  2. Build a contract that survives partial failure
  3. Exercise product behavior with the returned handle
  4. Diagnose the owner before changing a wait
  5. Roll the service into an existing suite safely
  6. Accept the costs, and know when not to use it

What you will learn

  • Put data ownership outside the browser
  • Build a contract that survives partial failure
  • Exercise product behavior with the returned handle
  • Diagnose the owner before changing a wait

Two checkout tests pass alone and fail when CI starts four workers. Both sign in as qa-buyer@example.test, so one test empties the cart while the other is trying to pay. A retry happens to run after the cart is rebuilt, which makes a data race look like a browser timing problem.

The durable fix is to give each test an owned scenario through a narrow application service, pass the returned identifiers into Selenium, and delete that same scenario in fixture teardown, so the browser tests browser-visible behavior instead of acting as an expensive database seeder.

Put data ownership outside the browser

WebDriver automates a browser. It navigates, locates elements, sends input, and observes what the application renders. It does not provide a general-purpose test-data service, and Selenium has no built-in concept of a disposable customer, cart, tenant, or order. A Python class named TestDataService is therefore part of your test architecture, backed by an API your application team owns. Making that boundary explicit matters because it stops framework code from acquiring imaginary Selenium behavior.

UI setup is attractive when a suite is small. A helper can log in as an administrator, visit five forms, create a customer, sign out, and start the real journey. The cost appears later. Every setup step inherits locator changes, animation timing, browser rendering, network latency, and permissions intended for humans. When step four fails, the report says the test failed even though the product action under test never ran. Parallel execution adds a worse problem: two setup flows may discover or edit the same record between separate page loads.

An application-owned setup endpoint narrows the arrangement to one request. It can run the same domain commands the product uses, enforce required invariants, and return the canonical identifiers created for the test. It should not write arbitrary rows behind the application's back. Direct database inserts are fast, but they can skip password hashing, tenant assignment, event publication, search indexing, or other rules that make the record usable. The test-support service should be a deliberate adapter over supported domain operations, not a SQL escape hatch with an HTTP wrapper.

The result of creation should be an immutable scenario handle. For a checkout test, that handle might contain a scenario ID, user ID, email address, order ID, SKU, and environment label. Selenium normally needs only the login identity and a route to the expected order, but diagnostics need the server-side IDs. If the browser displays the wrong order, those IDs let an engineer ask whether the page loaded the wrong data or whether setup created the wrong data. A helper that returns only an email address throws away that distinction.

Ownership needs at least two coordinates. The run coordinate separates one CI execution from another. The test coordinate separates collected cases inside that run. Pytest assigns node IDs using the test path, class, function, and parametrization, so request.node.nodeid is a practical test coordinate. The pytest configuration documentation describes how those node IDs are constructed. A prefix such as run-4812:tests/e2e/test_checkout.py::test_reserved_order[visa] is useful in service logs because it identifies the test without depending on a worker number or execution order. The fixture below adds one lifecycle suffix so an in-process rerun owns a fresh scenario.

Do not mistake an owner label for isolation. Prefixing a shared username with a worker name helps only if every mutable resource downstream is also separate. Two unique users can still belong to the same tenant and consume the same single-use coupon. Two unique orders can still reserve the final unit of a shared stock item. Write down the state each test changes, then choose the isolation unit that contains all of it. Sometimes that unit is a user. Sometimes it is an entire tenant seeded with its own catalog and billing account.

Creation also needs an idempotency story. A client can time out after the service commits a scenario but before the response reaches pytest. Blindly sending a second create request may leave two customers and no reliable handle for either. When the service accepts an idempotency key, a retry with the same key can resolve that uncertainty according to the service's documented contract. The server, not the Python client, must enforce that behavior. Merely adding an Idempotency-Key header does nothing unless the endpoint stores and honors it.

Deletion needs an equally precise contract. Repeating cleanup should lead to the same final state, but teams differ on the HTTP status they use to represent an already absent resource. The example below requires its endpoint to return 204 both when it deletes a scenario and when that scenario is already absent. A 404 remains an error because it may identify a wrong route or environment. Your client must not copy this choice until the actual endpoint agrees.

Pytest's yield fixtures fit this ownership model well. Setup runs before yield; teardown runs after it once the fixture has yielded. The official fixture finalization guide also calls out the important edge: if a yield fixture raises before reaching yield, pytest does not execute the code after it. That is why idempotent creation and server-side orphan reconciliation are part of the design rather than optional housekeeping.

Build a contract that survives partial failure

Start with the smallest scenario that removes a known source of shared state. Avoid a universal create_anything(payload) method. Generic payloads move domain knowledge into every test and make cleanup ambiguous. A method named create_checkout_scenario can require a SKU and quantity, create the linked records that checkout needs, and return a typed handle. Another scenario, such as an invited workspace member, deserves another method because its readiness conditions and cleanup rules differ.

The following module uses only Python's standard HTTP library. /test-support/scenarios/checkout is an illustrative application endpoint, not a Selenium endpoint and not a standard that other products implement. Replace the path and response fields with your application's documented contract. This example requires both the first accepted key and its replay to return 201 with the same scenario document. The code deliberately validates every field the browser test relies on. A server regression that omits order_id therefore fails during setup instead of producing a misleading locator timeout later.

Python
from __future__ import annotations

from dataclasses import dataclass
from http.client import RemoteDisconnected
import json
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen


class TestDataError(RuntimeError):
    """Raised when the test-data service cannot honor its contract."""


class TestDataTransportError(TestDataError):
    """Raised when no HTTP response arrives from the test-data service."""


@dataclass(frozen=True)
class CheckoutScenario:
    scenario_id: str
    user_id: str
    email: str
    order_id: str
    sku: str
    quantity: int
    environment: str


def required_string(document: dict[str, Any], field: str) -> str:
    value = document.get(field)
    if not isinstance(value, str) or not value.strip():
        raise TestDataError(f"response field {field!r} must be a non-empty string")
    return value


def required_positive_int(document: dict[str, Any], field: str) -> int:
    value = document.get(field)
    if isinstance(value, bool) or not isinstance(value, int) or value < 1:
        raise TestDataError(f"response field {field!r} must be a positive integer")
    return value


class TestDataService:
    def __init__(
        self,
        base_url: str,
        bearer_token: str,
        expected_environment: str,
        timeout_seconds: float = 10.0,
    ):
        self._base_url = base_url.rstrip("/")
        self._bearer_token = bearer_token
        self._expected_environment = expected_environment
        self._timeout_seconds = timeout_seconds

    def create_checkout_scenario(
        self,
        *,
        owner: str,
        idempotency_key: str,
        sku: str,
        quantity: int,
        user_password: str,
    ) -> CheckoutScenario:
        if quantity < 1:
            raise ValueError("quantity must be at least one")

        for attempt in range(2):
            try:
                document = self._request_json(
                    method="POST",
                    path="/test-support/scenarios/checkout",
                    payload={
                        "owner": owner,
                        "sku": sku,
                        "quantity": quantity,
                        "user_password": user_password,
                    },
                    idempotency_key=idempotency_key,
                    expected_statuses={201},
                )
                break
            except TestDataTransportError:
                if attempt == 1:
                    raise
        scenario_id = required_string(document, "scenario_id")
        try:
            scenario = CheckoutScenario(
                scenario_id=scenario_id,
                user_id=required_string(document, "user_id"),
                email=required_string(document, "email"),
                order_id=required_string(document, "order_id"),
                sku=required_string(document, "sku"),
                quantity=required_positive_int(document, "quantity"),
                environment=required_string(document, "environment"),
            )
            if scenario.environment != self._expected_environment:
                raise TestDataError(
                    "test-data response came from "
                    f"{scenario.environment!r}, expected {self._expected_environment!r}"
                )
            if scenario.sku != sku or scenario.quantity != quantity:
                raise TestDataError(
                    "test-data response did not match the requested SKU and quantity"
                )
        except TestDataError as contract_error:
            try:
                self.delete_scenario(scenario_id)
            except TestDataError as cleanup_error:
                raise TestDataError(
                    f"{contract_error}; cleanup also failed: {cleanup_error}"
                ) from cleanup_error
            raise
        return scenario

    def delete_scenario(self, scenario_id: str) -> None:
        encoded_id = quote(scenario_id, safe="")
        self._request_json(
            method="DELETE",
            path=f"/test-support/scenarios/{encoded_id}",
            payload=None,
            idempotency_key=None,
            expected_statuses={204},
        )

    def _request_json(
        self,
        *,
        method: str,
        path: str,
        payload: dict[str, Any] | None,
        idempotency_key: str | None,
        expected_statuses: set[int],
    ) -> dict[str, Any]:
        headers = {
            "Accept": "application/json",
            "Authorization": f"Bearer {self._bearer_token}",
        }
        body = None
        if payload is not None:
            headers["Content-Type"] = "application/json"
            body = json.dumps(payload).encode("utf-8")
        if idempotency_key is not None:
            headers["Idempotency-Key"] = idempotency_key

        request = Request(
            url=f"{self._base_url}{path}",
            data=body,
            headers=headers,
            method=method,
        )
        try:
            with urlopen(request, timeout=self._timeout_seconds) as response:
                status = response.status
                response_body = response.read()
        except HTTPError as exc:
            status = exc.code
            response_body = exc.read()
        except (URLError, TimeoutError, RemoteDisconnected) as exc:
            raise TestDataTransportError(
                f"{method} {path} did not receive an HTTP response"
            ) from exc

        if status not in expected_statuses:
            raise TestDataError(f"{method} {path} returned HTTP {status}")
        if not response_body:
            return {}
        try:
            document = json.loads(response_body)
        except json.JSONDecodeError as exc:
            raise TestDataError(f"{method} {path} returned invalid JSON") from exc
        if not isinstance(document, dict):
            raise TestDataError(f"{method} {path} returned a non-object JSON value")
        return document

This client has a narrow job. It validates transport status and the response shape, then returns data. One handled transport failure, including a URL error, timeout, or peer disconnect before a response, triggers one repeat of the same POST with the same idempotency key. That costs up to one additional request and timeout, and it is safe only because this example requires the server to honor that key. The client does not assert that the order appears on screen, that the price is right, or that payment works. Those are product claims and belong in the test that observes the product. Keeping them separate prevents an API response from becoming an oracle that cannot detect a broken UI.

Credentials need the same restraint. The bearer token should authorize only the required test-support operations in a non-production environment. The generated user's password can enter the create request, but neither value belongs in the scenario handle, structured logs, screenshots, or assertion messages. A stable password shared across isolated test users may be acceptable in a closed test environment, but it must still be supplied as a secret and rotated under the team's normal policy.

The fixture below derives an idempotency key from the run ID, pytest node ID, and a random fixture-instance suffix. The client reuses that key for its one transport retry. If a rerun plugin starts the same node again after teardown, the new fixture instance gets a different key and cannot replay a handle that the earlier instance deleted. The event log records ownership and resource IDs, but never records either secret. Letting deletion errors propagate is intentional. Swallowing them makes the current build look clean while leaving state that can corrupt the next one.

Python
import hashlib
import json
import logging
import os
import uuid
from collections.abc import Iterator

import pytest

from data_service import CheckoutScenario, TestDataService


testdata_log = logging.getLogger("testdata")


def record_testdata_event(event: str, **fields: str) -> None:
    record = {"component": "test-data", "event": event, **fields}
    testdata_log.info("TESTDATA %s", json.dumps(record, sort_keys=True))


@pytest.fixture(scope="session")
def test_run_id() -> str:
    value = os.environ.get("TEST_RUN_ID", "").strip()
    if not value:
        raise pytest.UsageError("TEST_RUN_ID must identify this test execution")
    return value


@pytest.fixture(scope="session")
def test_user_password() -> str:
    value = os.environ.get("E2E_USER_PASSWORD", "")
    if not value:
        raise pytest.UsageError("E2E_USER_PASSWORD is required")
    return value


@pytest.fixture(scope="session")
def data_service() -> TestDataService:
    base_url = os.environ.get("TEST_DATA_BASE_URL", "").strip()
    token = os.environ.get("TEST_DATA_TOKEN", "")
    environment = os.environ.get("TEST_ENVIRONMENT", "").strip()
    if not base_url or not token or not environment:
        raise pytest.UsageError(
            "TEST_DATA_BASE_URL, TEST_DATA_TOKEN, and TEST_ENVIRONMENT are required"
        )
    return TestDataService(
        base_url=base_url,
        bearer_token=token,
        expected_environment=environment,
    )


@pytest.fixture
def checkout_scenario(
    data_service: TestDataService,
    request: pytest.FixtureRequest,
    test_run_id: str,
    test_user_password: str,
) -> Iterator[CheckoutScenario]:
    fixture_instance = uuid.uuid4().hex
    owner = f"{test_run_id}:{request.node.nodeid}:{fixture_instance}"
    idempotency_key = hashlib.sha256(owner.encode("utf-8")).hexdigest()
    scenario = data_service.create_checkout_scenario(
        owner=owner,
        idempotency_key=idempotency_key,
        sku="SKU-17",
        quantity=1,
        user_password=test_user_password,
    )
    record_testdata_event(
        "scenario.created",
        owner=owner,
        scenario_id=scenario.scenario_id,
        user_id=scenario.user_id,
        order_id=scenario.order_id,
        environment=scenario.environment,
    )
    try:
        yield scenario
    finally:
        try:
            data_service.delete_scenario(scenario.scenario_id)
        except Exception:
            record_testdata_event(
                "scenario.delete_failed",
                owner=owner,
                scenario_id=scenario.scenario_id,
                environment=scenario.environment,
            )
            raise
        record_testdata_event(
            "scenario.deleted",
            owner=owner,
            scenario_id=scenario.scenario_id,
            environment=scenario.environment,
        )

Function scope is deliberate. A module-scoped mutable checkout scenario would let tests change the same order again. Session scope is appropriate for the stateless client object because it owns no scenario. The data object and its teardown remain function scoped. This distinction is more useful than a blanket rule that every fixture must use the narrowest scope.

When a response contains scenario_id but another field fails validation, the client attempts deletion before it raises the contract error. One limitation remains: both client attempts might lose their responses after the server creates the scenario, or a malformed response might omit the scenario ID itself. In either case the fixture never receives a usable handle and never reaches yield, so its finally block cannot delete anything. An out-of-band reconciler must find resources by run owner and age after the in-process options are exhausted. That requires server work. A Python try/finally alone cannot solve a handle it never obtained.

Exercise product behavior with the returned handle

The browser test should consume the scenario rather than know how its database was assembled. That keeps the test readable and makes the assertion capable of catching a product regression. The example below is complete Selenium Python code for an application whose sign-in form uses name=email, name=password, and button[type=submit], and whose orders render data-order-id, data-testid=line-item-sku, and data-testid=line-item-quantity. Those selectors are application-specific. They are shown as a concrete contract, not as universal Selenium locators.

Selenium's expected conditions documentation confirms that explicit waits can wait for conditions such as element visibility and text. Here a custom callable searches the current order elements until the one with the returned server ID exists. It waits for the state the test needs instead of sleeping for an arbitrary interval.

Python
import os

import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

from data_service import CheckoutScenario


@pytest.fixture
def driver():
    browser = webdriver.Chrome()
    try:
        yield browser
    finally:
        browser.quit()


@pytest.fixture(scope="session")
def app_base_url() -> str:
    value = os.environ.get("APP_BASE_URL", "").rstrip("/")
    if not value:
        raise pytest.UsageError("APP_BASE_URL is required")
    return value


def test_reserved_order_is_visible(
    driver,
    app_base_url: str,
    checkout_scenario: CheckoutScenario,
    test_user_password: str,
) -> None:
    driver.get(f"{app_base_url}/sign-in")
    driver.find_element(By.NAME, "email").send_keys(checkout_scenario.email)
    driver.find_element(By.NAME, "password").send_keys(test_user_password)
    driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()

    WebDriverWait(driver, 10).until(EC.url_contains("/account"))
    driver.get(f"{app_base_url}/account/orders")

    def expected_order(browser):
        orders = browser.find_elements(By.CSS_SELECTOR, "[data-order-id]")
        return next(
            (
                order
                for order in orders
                if order.get_attribute("data-order-id") == checkout_scenario.order_id
            ),
            False,
        )

    order = WebDriverWait(driver, 10).until(expected_order)
    displayed_sku = order.find_element(
        By.CSS_SELECTOR, "[data-testid='line-item-sku']"
    ).text
    displayed_quantity = order.find_element(
        By.CSS_SELECTOR, "[data-testid='line-item-quantity']"
    ).text
    assert displayed_sku == checkout_scenario.sku
    assert displayed_quantity == str(checkout_scenario.quantity)

There are at least three independent ways for this test to fail, and the boundary keeps them distinguishable. A setup error from create_checkout_scenario says the browser action never began. A timeout in expected_order says setup returned an order ID but the page did not expose that order within the chosen wait. A final equality failure says the expected order element appeared but displayed a different SKU or quantity. One broad helper that catches all three and raises Checkout setup failed destroys that evidence.

Worked example one: two tests mutate one cart. Imagine a discount test and a tax test using the same buyer. The discount test removes the default item before adding a sale item. The tax test reaches its assertion between those two actions and sees an empty cart. A screenshot shows an empty state, while the browser log contains no JavaScript error. Increasing the explicit wait cannot restore an item that another test intentionally removed.

The decisive evidence is identity reuse. Search setup events from both node IDs. If they contain the same user_id, cart ID, or scenario ID, the tests were not isolated. With the fixture above, distinct node IDs should create distinct scenario IDs. The assertion remains capable of failing if the application loads the wrong cart, but another test no longer owns permission to mutate this one. The cost is proportional setup load: four browser workers now create four scenarios instead of sharing one account.

Worked example two: the service returns before data is readable. A create endpoint may accept a domain command and return an order ID while a separate read model, cache, or search index is still catching up. The browser opens the orders page immediately and finds nothing. It looks like the shared-cart failure because both end at expected_order, but the ownership evidence differs. No second test touched the scenario, the service returned a unique order ID, and repeated reads for that same ID become successful without any new setup call.

Choose the readiness contract at the layer whose behavior the test is supposed to cover. If the browser test is about checkout history after normal checkout, eventual visibility may be part of the product and the Selenium wait belongs in the test. If the test starts from a prebuilt historical order and is about downloading an invoice, the test-data service should not return until its documented setup state is readable through the same application read path. That may increase setup latency, and it can hide a production consistency problem if used in the wrong test. Record which contract each scenario method offers instead of adding a global sleep after all setup.

Worked example three: cleanup times out after deletion. The test passes, then teardown reports that the DELETE request received no HTTP response. A careless fixture catches the exception and moves on. Another implementation immediately calls create again, assuming the old scenario survived. Neither conclusion follows from a timeout. The server may have deleted the records and lost only the response, or it may not have processed the request at all.

The evidence comes from the cleanup contract and service-side correlation, not the Selenium screenshot. Retry deletion only if the endpoint defines that operation as idempotent. Preserve the scenario ID, owner, environment, and server request correlation if the endpoint returns one. A separate reconciler can later inspect owned scenarios that outlived their run. Building that reconciler costs engineering time and additional service permissions, but it addresses process crashes, killed CI jobs, and lost responses that no fixture finalizer can cover.

These examples also show why a data service must not absorb product assertions. If create_checkout_scenario checks that its own response contains SKU-17, it proves only that the setup contract echoed or returned that value. The browser assertion can still detect a broken projection, wrong tenant filter, stale cache, or rendering error. The first oracle protects arrangement. The second oracle protects the behavior a user sees.

Diagnose the owner before changing a wait

Start diagnosis by locating the first boundary that did not complete, not by reading the last exception class in isolation. Pytest naturally separates fixture setup, test call, and teardown in its terminal report. That phase is high-value evidence. A failure before yield belongs to data creation or fixture configuration. A failure in the test body belongs to browser interaction or product behavior until more evidence narrows it. A failure after the test body belongs to cleanup even when the product assertion passed.

The helper's messages identify its boundary without pretending to know the product cause. If creation returns HTTP 503, the final exception contains TestDataError: POST /test-support/scenarios/checkout returned HTTP 503. If a 201 document omits order_id and cleanup succeeds, it contains TestDataError: response field 'order_id' must be a non-empty string. Those messages come directly from the example code. Neither should be rewritten as an order-page failure because Selenium did not open the page.

Capture one structured event immediately after creation returns and another only after deletion returns. Include the owner, scenario ID, important resource IDs, and environment. Do not include authorization headers, passwords, full response bodies, or customer-like personal data. The generated email address may still be sensitive in some organizations, so the example logs user_id and leaves the email out. A random screenshot filename with no scenario ID is weaker evidence than a small, consistently shaped record.

The command below runs one node ID, shows live fixture logs, preserves the complete terminal output, and writes a JUnit report. It uses pytest's documented node ID selection syntax. The identifiers in any resulting log records are run-specific evidence, not measurements or performance claims.

Shell
#!/usr/bin/env bash
set -euo pipefail

mkdir -p artifacts
export TEST_RUN_ID="local-checkout-diagnosis"

set +e
python -m pytest \
  'tests/e2e/test_checkout.py::test_reserved_order_is_visible' \
  -vv -s \
  --log-cli-level=INFO \
  --log-cli-format='%(levelname)s %(name)s %(message)s' \
  --junitxml=artifacts/junit.xml \
  2>&1 | tee artifacts/pytest.log
pipeline_status=("${PIPESTATUS[@]}")
set -e

python - <<'PY'
from pathlib import Path

for line in Path("artifacts/pytest.log").read_text(encoding="utf-8").splitlines():
    if "TESTDATA " in line:
        print(line.split("TESTDATA ", 1)[1])
PY

if (( pipeline_status[0] != 0 )); then
  exit "${pipeline_status[0]}"
fi
exit "${pipeline_status[1]}"

The temporary set +e lets the filtering block run after a failing pytest invocation. Capturing PIPESTATUS immediately preserves both pytest's status and tee's status, and the final branch still returns a failure to the caller after printing the diagnostic records.

Suppose the filtered output contains a scenario.created record for order ord_812, followed by the browser timeout, then a scenario.deleted record for the same scenario. Those IDs are illustrative. The record proves ownership and teardown, but it does not prove the order was visible in the product. Check the screenshot or page source for ord_812, and check the application logs using that exact order ID. If the server rendered ord_812 but Selenium never found it, investigate locator scope, frames, shadow roots, or timing. If the server rendered another user's order, investigate tenant or session selection. If the application never returned the order at all, investigate the read path or setup readiness.

A near-miss often looks identical in the terminal: login failed, so the browser was redirected back to /sign-in; the orders locator then timed out. The scenario can be perfectly valid. Capture driver.current_url and a screenshot when the wait fails. A current URL on the sign-in page, an authentication error in the UI, or a missing session cookie points away from data creation. Do not rebuild the user until you know whether the credentials, cookie domain, or identity provider flow failed.

Another near-miss is an environment split. The service creates ord_812 in test environment A while APP_BASE_URL opens environment B. Both systems are healthy, every identifier is unique, and the order never appears. The scenario's environment field should match a known identifier for the application target before browser work begins. Avoid comparing only friendly names such as staging, which can point at different clusters. The exact environment fingerprint is application-specific, but it should be stable enough to reject cross-environment setup during fixture setup.

Selenium command logs help after the ownership question is answered. The official logging guide documents Python's selenium logger and its levels. Turn detailed logging on for a focused reproduction, then turn it back down. Remote command logs can expose URLs, element values, and other data your artifact policy may restrict. They also add volume that can bury the two test-data events you actually need.

Do not classify every missing element as a data problem. The pytest flaky-test guidance notes that higher-level tests depend on more state and that parallel failures can reveal ordering or cleanup dependencies. Use that as a hypothesis prompt, not an automatic verdict. Prove reuse by matching resource identities across owners. A timeout plus parallel execution is correlation, not proof of collision.

Good incident evidence answers four separate questions. Which owner requested setup? Which resource IDs did the service return? What page and product state did Selenium observe? What happened during deletion? When one question has no record, improve that boundary's instrumentation before adding retries. A retry produces a new timeline and may destroy the only state that explains the first attempt.

Roll the service into an existing suite safely

A mature suite rarely has one shared account. It has a spreadsheet of credentials, setup helpers nested inside page objects, environment seeds applied by CI, and tests that quietly depend on execution order. Replacing all of it in one pull request makes failures hard to attribute. Migrate by scenario family and keep the rollback boundary visible.

First, inventory mutation rather than files. Search for tests that add cart items, change profile settings, consume invitations, approve records, reset passwords, or delete content. Read-only tests using an immutable public catalog are lower risk than tests sharing a writable customer. Group tests by the state they change and the external systems they touch. This produces candidate scenario methods based on behavior, not a generic data model.

Second, choose one noisy family with a bounded domain, such as saved addresses or draft orders. Define the create response, readiness point, owner fields, deletion semantics, authorization, and retention limit with the application team. Test that service contract below the browser level. A useful contract test changes a server response or state and can genuinely fail. Checking that a hard-coded fixture contains the same hard-coded ID proves nothing.

Third, add typed fixtures without changing the page objects. Page objects should continue describing browser operations. The test requests checkout_scenario, signs in with its email, and passes its order ID where needed. Avoid a global current_scenario variable. Process-level globals make parallel ownership implicit and let a helper read whichever case wrote last.

Fourth, migrate a small set of tests and run them in both serial and existing CI concurrency. Compare failure categories, not invented pass-rate percentages. Look for setup failures, product assertion failures, and cleanup errors separately. Track orphan counts from the service's real records. If the organization has not collected those measurements, say so. Do not publish illustrative numbers as though a migration experiment produced them.

Fifth, wire the run coordinate at the CI entry point. It must remain the same for every shard of one logical run and change for a rerun that should own separate data. The exact CI variable differs by platform, so the portable script below requires the orchestrator to supply it instead of pretending one vendor-specific key is universal. Passing extra pytest arguments through "$@" lets an existing runner retain its approved concurrency plugin and flags.

Shell
#!/usr/bin/env bash
set -euo pipefail

: "${TEST_RUN_ID:?CI must provide one ID for this logical run}"
: "${APP_BASE_URL:?APP_BASE_URL is required}"
: "${TEST_DATA_BASE_URL:?TEST_DATA_BASE_URL is required}"
: "${TEST_DATA_TOKEN:?TEST_DATA_TOKEN is required}"
: "${TEST_ENVIRONMENT:?TEST_ENVIRONMENT is required}"
: "${E2E_USER_PASSWORD:?E2E_USER_PASSWORD is required}"

mkdir -p artifacts
python -m pytest tests/e2e \
  --junitxml=artifacts/junit.xml \
  --log-cli-level=INFO \
  "$@"

Sixth, make cleanup debt visible before expanding. A passed test with failed teardown is not a clean run. Preserve deletion failures by owner and scenario ID, and define who responds when reconciliation cannot remove a scenario. Apply a retention policy on the server as a final safety net, but do not use age-based deletion as the primary fixture. A sweeper cannot know that a long-running test still owns data unless the service records leases or run state.

Seventh, remove shared identities only after their last consumer migrates. Leaving one legacy test on the old account means it can still interfere with another legacy test, but it should not be able to touch newly isolated scenarios. Keep the old and new credentials visibly separate during migration. A fallback that silently switches to the shared account when the service is unavailable recreates the exact race and turns an infrastructure outage into false product results.

Rollback should disable the migrated test family or restore its previous explicit fixture, not make the new fixture secretly share state. If the test-support service is unavailable, report setup failure. That is operationally inconvenient, but it is honest. Running a checkout assertion against an arbitrary reusable customer answers a different question from the test that was reviewed.

Review the service alongside its consumers. A schema change that renames order_id should fail client validation. A fixture scope change from function to module should trigger questions about mutation. A new scenario field should have a diagnostic or test use, not merely mirror the database. A cleanup exception should remain visible in the report. These review checks protect ownership better than enforcing a large base class.

Accept the costs, and know when not to use it

Isolation spends resources. A shared customer created once per session is cheap. Function-scoped tenants, catalogs, and orders may increase API traffic, database volume, queue work, and teardown time in direct proportion to concurrency. That is not automatically a reason to share again. It is a capacity input. Measure setup and cleanup with real service telemetry, then decide whether a smaller isolation unit preserves the behavior being tested.

The service itself becomes production-like software. It needs authentication, authorization, versioning, observability, rate limits, and an owner. Because it can create privileged states quickly, exposing it to production is usually a larger risk than the browser automation it replaces. Network restrictions and a test-environment identity reduce that risk. Logging a powerful bearer token to make debugging easier does not.

Fast setup can also reduce coverage. A test that creates an already-paid order through a support endpoint never verifies the customer checkout flow. That is acceptable when the test covers invoice download, refund permissions, or order-history rendering. It is unacceptable when the claim is that a customer can purchase the item. Maintain a smaller set of end-to-end creation journeys through the real UI or public API, and use direct scenario setup for tests whose preconditions would otherwise dominate the run.

Do not use the service for the workflow it bypasses. A registration test should register through the product. A password-reset test should request and consume the reset path. An invitation-acceptance test should begin with an invitation, but the acceptance itself stays in the browser. The boundary belongs immediately before the behavior under test, not automatically at the beginning of every data lifecycle.

Do not create disposable copies of genuinely immutable reference data just to satisfy a pattern. A read-only country list, static help article, or versioned catalog snapshot can be shared when tests cannot mutate it and the environment deploy process controls it. Creating one copy per test adds cost without removing a race. Confirm immutability at the application boundary rather than assuming a test will behave politely.

Avoid a test-support endpoint when it would bypass the only supported consistency rules and the application team cannot maintain an equivalent domain operation. Direct database access from pytest is especially risky in systems with event-driven projections or encrypted fields. Until a safe setup path exists, a slower supported admin workflow may be more trustworthy. The inconvenience is evidence that the system lacks a controllable test seam, not permission to manufacture invalid rows.

Third-party state needs another decision. A payment sandbox, email provider, or identity tenant may impose account limits and cleanup behavior your team cannot change. Wrapping it in TestDataService does not create isolation. Use provider-supported idempotency and test resources where available, serialize only the narrow cases that truly cannot be isolated, and keep those constraints out of unrelated tests. A lock is a valid last resort for one external account, but a global suite lock trades away parallelism and can hide broader ownership mistakes.

Finally, do not let cleanup erase evidence. Immediate deletion is valuable for isolation, yet a failing case may need its state for investigation. Teams sometimes add a preserve-on-failure switch. That choice creates privacy, storage, and collision costs, so it needs a short retention window, an owner, and access controls. An alternative is to capture a sanitized diagnostic snapshot before idempotent deletion. Choose deliberately based on the data involved rather than leaving every failed scenario indefinitely.

The architecture earns its place when it makes ownership explicit, lets setup failures fail as setup, keeps browser assertions independent, and removes state reliably enough for parallel execution. If it cannot provide those properties for a scenario, keep that scenario on a narrower, supported setup path until the missing contract is built.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official docs.pytest.org reference

    docs.pytest.org

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official docs.pytest.org reference

    docs.pytest.org

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official docs.pytest.org reference

    docs.pytest.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Should Selenium tests create users through the UI?

Use the UI when account creation is the behavior under test. For unrelated journeys, an application-owned test-support API removes setup clicks, returns stable resource IDs, and lets the test begin at the state it actually needs.

Does a pytest yield fixture always run its cleanup code?

Not if setup raises before the fixture reaches `yield`. Once the fixture has yielded, pytest runs the code after it during teardown, so ambiguous create failures still need idempotency keys and a separate orphan-reconciliation plan.

Is a unique email enough to isolate a parallel browser test?

No. The user may still share a cart, tenant, promotion budget, order, inbox, or external account with another test. Treat the whole mutable scenario as the isolation unit and return every important resource ID in one handle.

Should cleanup treat every 404 response as success?

Only when the test-data API contract defines deletion of an absent resource as a successful final state. A 404 caused by the wrong environment, route, or authorization must remain visible, so validate those conditions separately.

Where should product assertions live when data is created by an API?

Keep product assertions in the browser test. The data client may validate its HTTP and response contract, but it must not decide that checkout, permissions, pricing, or rendering works merely because setup returned a resource ID.