Testing & Continuous Improvement

What Breaks First When QA Scales an Untested Codebase

You inherited an untested codebase, added a few useful checks, and now the suite has to run on every pull request. The first scaling failure is unlikely to be the test framework or the application’s raw speed. It is shared state: tests and measurements become misleading when concurrent runs cannot prove which data belongs to which execution. I would solve ownership before buying faster runners.

Shared records break a growing suite before the runner does

A single QA engineer can run a test against a familiar account, inspect the result, and reset the account afterward. Parallel execution removes that informal ownership. One worker updates the account while another asserts its balance; both tests may be individually correct, yet either can fail depending on timing. Retries hide the collision rather than fix it, because a second attempt often succeeds after the competing worker has finished.

Inherited Untested Codebase Which Test Framework Can You Trust asks a reasonable question, but I would postpone the framework verdict: pytest, Playwright, and JUnit cannot make assertions trustworthy when their inputs change underneath them. Start by recording what each check creates, reads, and changes. Include database rows, object-storage keys, cache entries, background jobs, email inboxes, and external sandbox accounts. A test that creates its own customer but reads a shared pricing record is only partly isolated.

Give every run an identifier and put it in test-created records wherever the application permits. In PostgreSQL 16, that might mean a unique tenant or customer key rather than a reused “qa@example.com” account. For an HTTP check, pass the identifier through an X-Test-Run-ID header if the service can log it; do not mistake that header for isolation, because logging a collision does not prevent it. Keep a manifest of records created by the run so cleanup can target owned data instead of issuing a broad DELETE against a shared environment.

I would not make retries the default response to failures, because they turn a data-ownership defect into an apparently green build. Preserve the first failure, its run ID, and the names of competing workers. If a retry is needed to keep delivery moving, report both attempts separately rather than treating the passing attempt as proof that the check is stable.

A useful starting experiment is to run the same small group of checks concurrently, then compare failures with their sequential results. Set 4 workers as an initial value to tune, not a capacity claim. If failures appear only under concurrency, inspect shared inputs before changing assertion timeouts. Timeouts cannot repair two tests that legitimately observe different versions of the same record.

Filesystem isolation is easy; database isolation sets the real limit

pytest’s tmp_path fixture gives each invocation a separate temporary directory, so it is a good way to verify that a runner can schedule independent work. The following POSIX-shell example installs pinned pytest 8.3.5 and pytest-xdist 3.6.1 versions, creates a runnable test, and executes it with four workers:

python -m venv .venv
. .venv/bin/activate
python -m pip install pytest==8.3.5 pytest-xdist==3.6.1
cat > test_isolation.py <<'PY'
import pytest
@pytest.mark.parametrize("case", range(8))
def test_case_has_own_file(tmp_path, case):
    path = tmp_path / "record.txt"
    path.write_text(str(case))
    assert path.read_text() == str(case)
PY
python -m pytest -q -n 4 test_isolation.py

The 8 cases are an example workload, not evidence that the application is safe to parallelize. This test protects files created through tmp_path; it says nothing about a shared PostgreSQL database, a Redis cache, or an application process that retains state between requests. That distinction matters because a fast, green runner demonstration can create false confidence precisely when the inherited system has the least documented behavior.

For database-backed checks, compare transaction rollback with a database per worker. Transaction rollback wins for tests that execute all relevant writes through one controllable database connection: setup is cheap, teardown is straightforward, and developers get quick feedback. It costs you coverage of writes made outside that transaction, such as a background worker using its own connection. PostgreSQL sequences also do not roll back, so tests that assume consecutive IDs can still interfere.

A database per worker wins when an API request, job queue, and background process must all observe committed writes. Name each database for its pytest-xdist worker ID, run migrations before tests begin, and destroy it afterward. The cost is startup time, storage, and more complicated handling of services that still point at a common cache or sandbox account. Docker Compose v2 or Testcontainers can start dependencies, but neither provides isolation merely by starting containers; each worker needs distinct connection targets or safely partitioned data.

Choose the cheaper boundary that matches the behavior under test. A unit-level repository check may fit transaction rollback. An end-to-end order flow that commits a record and waits for a worker to process it needs an environment where that worker cannot consume another test’s order. Before increasing pytest-xdist -n, confirm which boundary each check actually uses.

Latency gates fail when requests cannot be matched to their outcomes

Once functional checks run concurrently, it is tempting to add a performance threshold to the same shared environment. QA Can Benchmark an Inherited Codebase Without a Rewrite offers a useful starting point for obtaining a baseline, but I would not promote a baseline to a pull-request gate until test traffic can be distinguished from other traffic. Otherwise, a slower run might reflect a competing deployment, a background import, or extra fixture creation rather than a code change.

Start with a deliberately narrow transaction, such as creating one record and reading it back. Use k6 to label that scenario, pass a unique run ID in the request, and retain the response status and request duration together. If the service emits OpenTelemetry traces, propagate a W3C Trace Context traceparent header so an unusual duration can be connected to the server-side work that produced it. A trace is most useful here as an explanation of an outlier, not as a substitute for a latency distribution.

Collect duration as a Prometheus histogram when the application exposes one, and calculate p95 with histogram_quantile only after checking that the buckets give enough resolution near the threshold. Set a provisional p95 ceiling of 600 ms for the selected transaction as a value to tune against the team’s own baseline, not as a universal target. Keep error rate beside latency: a fast request that returns HTTP 500 is not a performance success.

Repeat the scenario under controlled conditions before interpreting movement. Make 30 consecutive runs a sampling goal rather than a statistical guarantee, and retain the raw results so the team can see whether a threshold is repeatedly crossed or just touched once. If two runs differ, compare their run IDs against deployment events, database waits, and job activity before blaming the code. This is slower than immediately adding a red CI gate, but it avoids training developers to ignore a gate whose failures QA cannot explain.

Do not combine every Playwright browser journey, API check, and k6 scenario into one headline latency number. They perform different work and have different sources of delay; a browser rendering pause should not be presented as a database regression. Keep a small set of named transactions with stable inputs, and report each transaction’s status and latency separately.

CI capacity becomes a diagnosis problem before it becomes a speed problem

After isolation is in place, more workers can still make the suite harder to operate. GitHub Actions may start several pull-request jobs at once, while pytest-xdist starts workers inside each job. Even if each job owns its local files, the jobs can compete for a staging database, rate-limited API, or finite pool of test accounts. Count concurrency across the whole path, not just the value passed to -n.

Put a small concurrency budget in writing: which jobs may share an environment, which require exclusive access, and what resource limits that decision protects. If the staging system cannot provide separate data namespaces, serialize the checks that mutate shared state and keep isolated checks parallel. Serializing one unsafe group costs elapsed time, but it is cheaper than increasing concurrency until failures become impossible to reproduce.

Make a failure report answer three questions without a meeting: what ran, what did it own, and what else was running? Publish JUnit XML from pytest for check-level results, attach relevant application logs, and include the run ID in artifact names. Record the commit, migration version, database target, worker ID, and deployment identifier. Those fields let QA distinguish an assertion failure from a fixture collision or an environment change; a screenshot alone rarely can.

Watch the queue as well as execution time. If a job spends most of its life waiting for an exclusive environment, another pytest worker will not improve delivery time. If execution dominates and failures remain attributable to one run, increasing parallelism is a reasonable experiment. Compare those two timings before paying for more runner capacity, because a bigger runner cannot remove contention at a shared downstream service.

Set a review trigger for flaky checks rather than quietly quarantining them. For example, treat 3 distinct first-attempt failures across 50 runs as a local investigation threshold to revise after observing the suite; the count is a policy choice, not a vendor benchmark. The investigation should classify each failure as product behavior, test data, environment contention, or unknown. “Unknown” is an acceptable temporary result if the run retains enough evidence to investigate next time.

The next run should prove who owns one record

On the next pull request, choose one check that reads or changes a shared record. Give its run a unique ID, create a fresh record, and log that ID alongside the assertion and server request. Run the check concurrently with itself. If the two executions cannot be separated in the resulting data and logs, fix that boundary before widening the suite or tightening a latency gate.