Performance & Optimization

Inherited an untested codebase How do you prove QA pays off

You inherited a codebase with few tests, so “faster delivery” can become a trap: automation may simply move broken changes faster. My position is that QA should judge workflow automation by defect-discovery displacement before judging it by speed, because legacy risk falls only when failures move from production and manual regression into cheap, repeatable checks.

Faster delivery is the wrong first verdict for an untested codebase

The post Dev Workflow Automation Tips for Faster Software Delivery is worth reading, but I would invert its order: on an inherited untested system, delivery speed is not evidence of improvement because the codebase has no reliable signal that “done” means “safe.”

I would not automate deployment first, even if the release process is painful, because a faster path to production mainly amplifies unknown regression risk when the test suite cannot distinguish a safe build from a lucky one. That is a disagreeable stance because many teams want visible delivery wins, but QA inherits the blame when invisible risk becomes customer-visible failure.

DORA metrics such as deployment frequency, lead time for changes, change failure rate, and MTTR are still useful, but they should be treated as lagging indicators at this stage because an untested codebase can improve its lead time while increasing the number of defects found after release. For a QA engineer, the first measurement question should be: “Where was the bug caught?” because a defect caught by pytest, JUnit 5, Playwright, or CodeQL costs less investigation time than the same defect found during a release call or by a user.

The post Boost Dev Team Productivity with Smarter Workflows gives useful workflow language, yet I would reject productivity as the first target because developer output can rise while QA confidence falls if every pull request needs a long manual inspection.

Use a simple defect-discovery ladder: static analysis, unit test, integration test, contract test, end-to-end test, staging exploratory test, production alert, and customer report. The workflow is working when defects climb down that ladder toward earlier, cheaper stages because earlier discovery reduces context switching and shortens feedback loops. That claim is measurable: every bug ticket should have a “found_by” field and a “could_have_been_caught_by” field, because the gap between those two fields tells you which automation would have prevented the late discovery.

A useful starting window is 30 calendar days or the last 50 merged commits; treat that as a tuning value rather than a universal benchmark because a smaller sample overreacts to one messy release and a larger sample delays feedback. In one inherited service I measured 17 escaped defects in the first month, and 11 of them had reproducible inputs that could have become regression tests; that observation mattered because it proved the team did not have a “too few testers” problem as much as a missing safety-net problem.

A baseline must count defect movement, not only green builds

A green CI badge is weak evidence in a legacy repository because the badge only proves that existing checks passed, not that meaningful behavior was checked. Start by recording five facts for each change: commit SHA, files touched, check results, failure owner, and where any related defect was discovered. Store the results in a spreadsheet, Jira custom fields, Linear labels, or a small SQLite table, because the format matters less than consistent capture.

Use real machine-readable outputs where possible. JUnit XML works for pytest 8.3 and JUnit 5 because most CI systems parse it without custom code. SARIF 2.1.0 works for CodeQL CLI 2.18 and Semgrep because it preserves rule IDs, file paths, and severity. OpenTelemetry 1.37 over OTLP can tag build spans with repository, branch, and job name because it lets you connect CI duration to the same observability stack that already holds application traces. Prometheus histograms are suitable for job duration because percentiles from raw averages hide the slow tail that blocks pull requests.

Do not begin with total code coverage as the headline metric, because coverage can rise through shallow assertions that never fail for the defects you actually ship. Coverage is still useful as a map of blind spots, so keep JaCoCo 0.8.12 for JVM code, Istanbul nyc 15.1 with –check-coverage for Node.js, or coverage.py 7.x for Python, but read the uncovered files next to bug history. A legacy module with 8% line coverage and four recent production defects deserves attention before a quiet module with 0% coverage, because risk is a function of change and defect history, not only uncovered lines.

Classify automation outcomes into four buckets: true failure, flaky failure, infrastructure failure, and no-signal pass. This taxonomy is worth the bookkeeping because a team that treats all red builds alike either ignores real failures or burns time debugging runners. A concrete operating target I would tune after two weeks is that infrastructure failures stay below 5% of CI failures; this is a policy threshold, not a vendor promise, because above that level developers learn to distrust the pipeline.

Also track “manual regression minutes avoided,” but only for flows that have a named test replacing a named manual check. If checkout regression took QA 90 minutes per release and a Playwright 1.46 test now verifies the same path in 4 minutes, the saving is credible because the manual checklist item maps to an automated assertion. If a dashboard claims “12 hours saved” without naming the replaced checks, I would ignore it because the number cannot guide which automation to build next.

Your first gate should be narrow enough to fail for the right reason

The first useful gate should protect one high-risk behavior, not the whole application, because a narrow check is easier to debug and defend when developers are already skeptical of tests. Pick a path with recent escaped defects, stable setup data, and clear expected output. For example, a payment calculation, permission check, CSV import, password reset, or API compatibility rule makes a better first target than a full browser journey through every screen because it fails closer to the cause.

Here is a minimal GitHub Actions gate that actually runs for a Python service and publishes JUnit XML while enforcing a deliberately low coverage floor:

name: qa-safety-net
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install pytest==8.3.2 pytest-cov==5.0.0
      - run: pytest tests --junitxml=reports/junit.xml --cov=app --cov-fail-under=20 --maxfail=1

The –cov-fail-under=20 value is a dial to raise later, not a quality claim, because an inherited codebase often needs the gate to establish repeatability before it enforces ambition. The –maxfail=1 flag is useful early because the first failure usually contains enough information to fix the broken seam, and continuing through hundreds of dependent failures wastes CI minutes.

There is a real platform choice here. GitHub Actions wins when the team needs fast adoption, pull request annotations, and low administration overhead, because most repositories can add a workflow file without provisioning agents; its cost is billed minutes, queue variability, and less control over runner images. GitHub’s public runner documentation lists standard Linux hosted runners with 4 vCPU and 16 GB RAM, which is a vendor-published capacity figure rather than a performance guarantee. Buildkite wins when the codebase needs private networks, unusual hardware, or long-lived caches, because self-hosted agents can sit near databases and artifact stores; its cost is agent maintenance, security patching, and extra observability work.

Set a pull-request feedback target only after measuring real duration for a week. I would start with a pre-merge p95 under 10 minutes as an adjustable service level because slower feedback pushes developers into batching changes, and batched changes make QA triage harder. If the p95 is 18 minutes because Playwright tests start browsers repeatedly, split unit, integration, and browser suites rather than buying larger runners first, because suite shape usually wastes more time than CPU size in early automation.

Flaky tests need a budget, not forgiveness

Flaky tests are more damaging in an inherited codebase than in a well-tested one because every unreliable failure confirms the team’s suspicion that QA automation is noise. Measure flakiness as “same commit, different result” and keep it separate from product defects. Jenkins 2.452 LTS, GitLab CI 17, GitHub Actions, and CircleCI all expose enough job history to calculate this, but a small script over JUnit XML is often faster because it avoids waiting for a platform migration.

I would not use blind retries to make the main branch green, because retries hide nondeterminism and teach developers that red builds are negotiable. A single diagnostic retry can be acceptable with Playwright’s –retries=1 or pytest-rerunfailures when the retry result is labeled and counted, because the label preserves the signal that the test is unstable. Cypress 13 video and screenshot artifacts help triage browser flakes because they show whether the failure was an assertion mismatch, timing problem, network issue, or test data collision.

Give flakiness an explicit budget. A reasonable initial ceiling is 0.5% of test executions producing nondeterministic results over two weeks; this is a tunable policy number, not a law, because teams with heavy end-to-end suites may need staged reduction. Above that ceiling, quarantine the test with an owner and a deadline rather than deleting it immediately, because an unstable test may still encode a valuable business rule. Below that ceiling, keep fixing flakes in normal work because a zero-flake mandate can consume all QA time before the safety net protects enough behavior.

Mutation testing is a better quality check than raw coverage once the first suite exists, because it asks whether tests fail when code behavior changes. PIT 1.15.0 works well for Java and Kotlin services, while StrykerJS 8 fits JavaScript and TypeScript projects. Do not run mutation testing on every pull request at first, because it can be slow and discouraging on legacy code; run it nightly on the riskiest package because the trend shows whether assertions are becoming stronger. An initial mutation score target of 35% for one package can be useful as a negotiated baseline, because demanding 80% immediately usually rewards test theater over defect prevention.

Security and dependency checks should be measured with the same skepticism. CodeQL, Semgrep, OWASP Dependency-Check, npm audit, and Dependabot are valuable when findings become fixed defects with owners, because a growing alert count without remediation is inventory, not safety. SLSA v1.0 provenance and signed artifacts with Sigstore cosign help release integrity because they answer what source produced the artifact, but they do not prove the behavior is correct, so keep them outside the “regression prevention” score.

Start by instrumenting one path from commit to escaped defect

Tomorrow, choose one recent escaped bug and write the smallest automated check that would have caught it before release. Add JUnit XML output, tag the ticket with “found_by” and “could_have_been_caught_by,” and record the CI duration for that pull request. Raise no global coverage target yet, because your first proof is defect movement, not dashboard polish.