Software Design & Development

Add regression tests to legacy code without rewriting it QA playbook

You inherited an untested codebase, so the unpopular move is to slow delivery on purpose for a few days. Do not start with a rewrite, a full CI/CD transformation, or a heroic end-to-end suite. Start by turning today’s behavior into executable evidence, because unknown behavior is the real production dependency you cannot replace by planning.

Your first delivery improvement is a safety harness, not a pipeline

Case Study: Accelerating Software Delivery With DevOps is persuasive only after a team can tell whether faster releases are safer releases, because speed without an oracle simply moves defects from your branch to users faster.

That position annoys some delivery managers, because “DevOps” often gets translated into “ship more often.” In an inherited untested codebase, QA should translate it into “detect smaller changes sooner.” The first win is not Kubernetes, Argo CD, Jenkins 2.479, GitHub Actions, or GitLab CI 17 by itself. The first win is a repeatable check that fails for a reason a human can understand.

I would not rewrite the application to make it testable, because a rewrite discards the only working specification you have: the messy behavior currently in production. Rewrites also create parallel defect streams, since the old system keeps changing while the new one tries to catch up. A QA engineer is better positioned to interrupt that trap than anyone else, because QA sees the distance between “intended behavior” and “behavior customers already depend on.”

Make the current system observable before you make it elegant. Add Git commit SHAs to logs, capture HTTP status distributions, and label releases with build numbers. If the app already exposes Prometheus metrics, add a low-cardinality counter such as checkout_quote_failures_total; if it does not, start with structured JSON logs and a Grafana Loki query, because logs require less application surgery than metrics in older systems.

Use delivery metrics, but keep them brutally local. DORA names 4 metrics—deployment frequency, lead time for changes, change failure rate, and failed deployment recovery time—and that vendor-neutral count is useful because it prevents teams from pretending that build speed alone proves quality. For your first month, add two QA-facing measures: escaped defect count and flaky test rate. They matter because an inherited system can look faster while silently becoming less trustworthy.

Set a small constraint that management can understand. As a starting value to tune, require every production change to pass one characterization test, one static check, and one deployability check before merge. That is intentionally modest, because a gate that blocks every change in week one will be bypassed by week two.

Coverage is a misleading first target when nobody trusts the assertions

Code coverage is useful later, but it is a poor first north star because unreviewed assertions can cover the wrong behavior with impressive percentages. A 70 percent line coverage target may sound mature, but as a policy number it can reward shallow tests because executing a line says nothing about checking the business consequence of that line.

Start with risk coverage instead. Pick the flows that produce money movement, permission changes, irreversible data updates, or noisy support tickets. Name them in plain language, then attach one executable check to each. A QA engineer can often identify these paths faster than a developer, because bug reports, support notes, and release incidents reveal where the code hurts people.

For a Python service, pytest 8.3.x and requests can create a thin characterization check against a running environment. The test below is not beautiful, but it runs and it protects a visible contract without requiring a rewrite:

import os, requests

BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000")

def test_checkout_quote_contract_survives_change():
    payload = {"sku": "ABC-123", "quantity": 2}
    response = requests.post(f"{BASE_URL}/checkout/quote", json=payload, timeout=3)
    assert response.status_code == 200
    body = response.json()
    assert set(body) >= {"subtotal", "tax", "total"}
    assert body["total"] == body["subtotal"] + body["tax"]

That check should run with BASE_URL=https://staging.example.com pytest -q, because the first useful tests for a legacy application often exercise deployed behavior rather than isolated design. Later, you can move inward with unit tests, but premature unit-test purity is expensive because tangled code forces you to mock implementation details you barely understand.

Use real tools, but assign each one a narrow job. SonarQube 10.6 can flag duplicated blocks and risky complexity, but it should not be treated as a quality judge because static analysis cannot know whether legacy behavior is intentional. ESLint 9 with –max-warnings=0 can stop new JavaScript lint debt, but it should be scoped to changed files at first because cleaning the whole repository converts QA risk reduction into archaeology. JaCoCo 0.8.12 for Java or nyc 17 with Istanbul instrumentation for Node.js can report coverage, but their reports should support conversations rather than set the initial merge rule.

Capture baseline numbers before changing gates. If the first measured median CI time is 28 minutes, do not immediately demand five-minute builds, because the team will remove useful checks to satisfy an arbitrary target. A practical first ceiling to tune is 15 minutes for presubmit feedback on changed areas, because developers still remember their change at that interval and QA can investigate failures before context disappears.

The first MVP should be a testable release slice, not a smaller product plan

Building Smart: The Power of MVPs and Rapid Iteration should be treated as a warning against giant test programs, because rapid iteration is impossible when the feedback mechanism takes longer to build than the change itself.

For an inherited codebase, the MVP is not a product feature. It is a minimal protected path from commit to deploy. Pick one ugly journey that matters, then make it observable, testable, and releasable without changing its design. This is controversial because it feels too small, but small is the point: a narrow release slice proves the team can improve the system while the system is still alive.

A good first slice has three properties. It crosses at least one real boundary, such as HTTP plus database. It has visible user or operational value, such as creating an order, resetting a password, or generating a report. It fails loudly when broken, because silent checks create false confidence. OpenAPI 3.1 helps here because it lets QA describe request and response expectations without reading every controller. Pact 4 can protect consumer-provider contracts when multiple services are involved, because it catches incompatible changes before integrated environments become the only test oracle.

Do not begin by containerizing the entire stack, because Docker 27 and Docker Compose are useful only when the team understands what must be reproduced. Containerizing an unknown system can preserve accidental complexity with a cleaner command line. Instead, containerize the smallest dependency that blocks repeatable tests. Testcontainers 1.20 is a strong fit for databases and message brokers, because it creates disposable dependencies during tests without forcing a full platform redesign.

Make the first slice visible in CI. GitHub’s vendor-published default job timeout is 360 minutes, but accepting that default is reckless for feedback checks because a stuck test can consume most of a workday before anyone learns anything. Set an explicit timeout such as timeout-minutes: 20 for early gates, because failure speed matters more than theoretical completeness during the first stabilization phase.

Security and compliance checks belong in the slice only where they protect the touched path. OWASP ASVS 4.0.3 can guide authentication and session checks, but applying the entire standard as a first sprint mandate will stall delivery because legacy systems rarely meet modern controls uniformly. A better first QA move is to add one authentication regression and one authorization regression around the chosen journey, because access-control bugs are high-impact and often easy to characterize.

Characterization tests beat browser tests until the user interface is the risk

The explicit trade-off is this: characterization tests versus browser end-to-end tests. Characterization tests win when the business rule is buried in legacy code, because they freeze observed inputs and outputs with less setup. Their cost is ambiguity, because they can preserve a bug if QA records behavior without checking it against support history or product intent.

Browser end-to-end tests with Playwright 1.49 or Cypress 13 win when layout, routing, authentication redirects, or JavaScript state are the risk, because only a real browser exposes those failures reliably. Their cost is maintenance, because selectors, timing, test data, and third-party scripts change more often than core domain rules. Selenium 4 is still viable for teams with existing WebDriver infrastructure, but it usually costs more operational attention than Playwright because browser binaries, grids, and waits need more care.

Most inherited codebases need characterization first. That claim is disputable, but the reason is practical: QA can usually write an HTTP, database, CLI, or approval-style check before the team can stabilize a full browser test environment. ApprovalTests for Java, .NET, or Python can help when the output is large, because the reviewer approves a captured artifact instead of hand-writing dozens of assertions. Snapshot testing in Jest 29.7 can help for stable components, but it is risky for noisy output because large snapshots encourage blind updates.

Keep the first browser suite tiny. A reasonable initial budget is 3 critical browser scenarios, chosen as a planning limit rather than a maturity benchmark, because more UI tests will slow diagnosis before the team has reliable data factories. Tag them with something like @smoke and run them after deployment to staging, because browser checks are better at catching integration surprises than guiding every local commit.

For lower layers, add mutation testing later. StrykerJS 8 or PIT 1.17 can reveal whether assertions are meaningful, but mutation testing is expensive in legacy systems because it runs many altered versions of the code. Use it on extracted modules after you have seams, not on the whole application during the first rescue phase.

QA should own the first gate because developers are too close to the code

The first gate should be designed by QA and implemented with developers, not the other way around, because the inherited risk is behavioral rather than architectural. Developers know where the code is ugly, but QA knows which ugliness matters to release confidence. That division is not political; it prevents refactoring preferences from masquerading as customer protection.

Define the gate in plain terms. A merge can proceed when the changed path has a characterization check, the smoke suite passes, no new high-severity static issue appears, and rollback instructions exist. Rollback can be a feature flag, a database down-migration, a previous container image, or a documented manual step. The format matters less than rehearsal, because untested rollback plans fail under incident pressure.

Feature flags are useful, but they are not a substitute for tests because a bad flag can expose the wrong cohort or leave dead code behind. Unleash, LaunchDarkly, or OpenFeature 1.0 can control exposure, but QA should require flag state to be visible in logs and test data, because hidden runtime configuration makes failures hard to reproduce.

Do not make “all tests pass” the only rule, because flaky tests teach teams to rerun instead of investigate. Track flakiness as failed-then-passed without code change, and quarantine only with an owner and expiry date. A quarantine limit of 7 days is a tunable operating rule, not a universal law, because some legacy defects need coordination but indefinite quarantine is just deletion with nicer wording.

Finally, publish the gate results where release decisions happen. A GitHub Actions check, GitLab merge request widget, Jenkins badge, or Slack notification is useful only if someone acts on it. QA should write the failure message as if a tired engineer will read it at 5 p.m., because unclear failures become social pressure to bypass the system.

Tomorrow, choose one production path that scares you, write one characterization test against its current behavior, and run it in CI with a strict timeout. Do not ask for permission to redesign the codebase first. Ask for agreement that this path should not break silently again, because that is the smallest promise a legacy delivery system can keep.