Testing & Continuous Improvement

Add test automation to legacy code without a rewrite QA playbook

You inherited an untested codebase, and the tempting answer is a framework, a rewrite, or a cloud migration that promises a cleaner future. My position is narrower and more arguable: introduce testability by pinning today’s behavior at the edges, even when that behavior is ugly, because QA needs evidence before architecture gets a vote.

Your first tests should preserve behavior you distrust

A QA engineer in this situation should start by treating the existing application as a black box, because internal structure is usually the least reliable map of an untested system. The first useful suite is not elegant unit coverage; it is a set of characterization tests that says, “this is what production appears to do,” because that gives developers a safe boundary for later refactoring.

Inherited Untested Codebase Which Test Framework Can You Trust argues from the tool-selection side, but I would treat the framework as a late decision because the first constraint is where the code can be observed safely.

I would not begin by rewriting modules into “testable” shapes, because that changes behavior before you have an oracle that can tell whether the change was harmless. I would also avoid mass reformatting, dependency upgrades, and automatic lint fixes in the first testing branch, because those edits create noisy diffs that hide the one regression you are trying to catch.

Start with a small map of externally visible behavior. For a web app, that means HTTP status codes, redirects, authentication failures, database side effects, queue messages, and exported files. For a batch job, that means input files, output files, exit codes, logs, and records written. For a library, that means public functions and serialized outputs. This boundary-first approach is slower than writing isolated unit tests, but it is safer because legacy defects often live in the glue between components.

A practical starting target is 20 characterization tests for the riskiest workflows; that number is a value to tune, not a maturity score, because the point is to cover decisions that block change rather than to impress a dashboard. Measure the suite over 30 CI runs before trusting it, because a test that fails once every few runs is a production distraction disguised as protection. A reasonable early flake budget is 0 known intermittent failures, because a flaky safety net trains developers to ignore red builds.

Use risk language instead of coverage language in the first planning session. Ask where money, permissions, data deletion, billing, compliance reporting, or customer-visible messages happen. Branch coverage from JaCoCo 0.8.12, coverage.py 7.6 with –fail-under, or Istanbul in Jest 29 can help later, but early coverage percentages are weak evidence because legacy code often contains dead paths, unreachable branches, and defensive checks nobody understands.

A runnable harness beats a perfect framework decision

The first merge should prove that tests can run in the repository with one command, because a theoretical test strategy cannot catch a regression. Pick the least invasive runner that your team can execute locally and in CI. For Python, pytest 8.3 with coverage.py 7.6 is usually faster to introduce than unittest refactoring because it discovers simple tests without changing production code. For Java, JUnit Jupiter 5.10 is a safer default than a custom harness because IDEs, Maven Surefire 3.2, Gradle, and JaCoCo already understand it. For JavaScript, Vitest 2 wins when Vite is already present, while Jest 29 wins when the project already uses Babel, jsdom, or older CommonJS patterns.

The first harness can be deliberately unimpressive, because its job is to remove friction before it judges the product. This shell sequence creates a tiny pytest and coverage baseline that actually runs, and the –fail-under=1 threshold is intentionally low because the first gate should verify wiring rather than pretend the code is healthy.

python -m venv .venv
. .venv/bin/activate
python -m pip install "pytest==8.3.3" "coverage==7.6.1"
mkdir -p tests
cat > tests/test_smoke.py <<'PY'
def test_harness_runs():
    assert 2 + 2 == 4
PY
coverage run -m pytest -q
coverage report --fail-under=1

After that, replace the toy assertion with smoke tests that hit real seams. A CLI job can be driven through subprocess exit codes. A web application can be exercised with Playwright 1.46, Cypress 13, REST Assured 5.5, or SuperTest 7, depending on the stack. Playwright’s vendor documentation names 3 browser engines: Chromium, Firefox, and WebKit; that published number matters when browser compatibility is part of the risk, but it is unnecessary weight when your first risk is a JSON API returning the wrong price.

I would keep the first CI job under 10 minutes as a tunable ceiling, because slow inherited-code tests become optional in practice even when the YAML says they are required. GitHub Actions, GitLab CI, Jenkins, and Azure Pipelines can all run the harness, but the chosen system matters less than making the command identical locally and remotely. If developers run make test-characterization locally and CI runs a different script, failures become arguments about environments rather than defects.

Do not add mutation testing on day one. PIT 1.15 for Java, StrykerJS 8 for JavaScript, and mutmut 2 for Python are useful later because they reveal assertions that execute code without detecting wrong behavior, but they are expensive too early because mutation tools amplify runtime and noise before the baseline suite is stable. A starter mutation score of 40% can be a deliberate later floor, because the early goal is to expose vacuous tests rather than to punish teams for inheriting untestable paths.

Seams at process boundaries reveal more than seams inside classes

Legacy code often has business behavior spread across controllers, stored procedures, cron jobs, message handlers, and template helpers, so class-level tests can miss the real defect because the defect is in the coordination. Put the first seams where a user, scheduler, partner service, queue, database, or file system touches the application. That is where QA can observe behavior without negotiating every private method.

For HTTP services, capture behavior with OpenAPI 3.1 examples, Postman/Newman 6 collections, REST Assured, or Playwright API tests. For asynchronous edges, use Testcontainers 1.20 to start PostgreSQL 16, Redis 7, Kafka 3.7, or RabbitMQ 3.13 close to production behavior, because in-memory substitutes hide serialization, transaction, and ordering bugs. For third-party APIs, WireMock 3 can record and replay HTTP stubs, while Hoverfly can simulate latency and failures; both are more useful than hand-written mocks when QA does not yet know every contract.

Here is the explicit tradeoff I would force into the plan: ApprovalTests versus Pact. ApprovalTests wins when the legacy output is a document, report, email body, HTML page, or deeply nested JSON response, because a golden-master diff lets QA review the whole artifact without reverse-engineering every rule. Its cost is repository noise and review fatigue, because approved files change whenever timestamps, ordering, formatting, or generated IDs change. Pact v4 wins when independent services need consumer-provider contracts, because it prevents a provider change from silently breaking a consumer. Its cost is operational ceremony, because a Pact Broker, provider verification, version tags, and contract ownership are required or the contracts become stale confidence.

For databases, resist the instinct to mock the repository layer everywhere. A repository mock is fast, but it can approve SQL that never works against PostgreSQL, SQL Server, Oracle, or MySQL. A disposable database through Docker Compose or Testcontainers costs more runtime, but it catches collation, transaction isolation, default constraint, migration, and permission problems that mocks cannot represent. Use Flyway 10 or Liquibase 4 to prepare schema in tests, because a test database built by hand stops matching production as soon as the next migration lands.

For files and approvals, normalize volatile fields before asserting. Strip timestamps, GUIDs, random ordering, and environment-specific paths, because characterization tests should freeze business behavior rather than incidental machine noise. If a batch export currently produces columns in a strange order, approve that order first, because QA’s immediate job is to detect unintended change; you can open a separate refactoring ticket after the behavior is pinned.

Cloud migration should be forced through the same characterization net

I disagree with Legacy to Cloud Migration Strategy and Code Quality whenever migration becomes the justification for broad cleanup, because QA loses the ability to separate cloud defects from refactoring defects.

Move the codebase toward cloud operation by wrapping and observing it, not by rebuilding it. Containerize the existing application with Docker, pin runtime versions such as Node.js 20 LTS, Java 21, Python 3.12, or .NET 8, and then run the same characterization suite against the container. That step is unglamorous, but it is powerful because it reveals configuration drift before the architecture changes.

Cloud migration without a rewrite should create comparison points. Run the legacy deployment and the containerized deployment side by side, feed both the same sanitized inputs, and compare outputs, database writes, HTTP responses, and emitted events. If the system exposes HTTP, keep HTTP/1.1 semantics and status codes stable before touching routing or service decomposition, because clients often depend on accidental behavior such as 302 versus 307 redirects. If it emits events, preserve schema names and required fields before changing brokers, because consumers break on payload shape faster than they break on hosting location.

OpenTelemetry 1.30, Prometheus, Grafana, and structured JSON logs should be added before traffic moves, because QA needs traces and metrics to explain differences rather than merely report them. A measured p95 response time from the current system is more useful than a promised cloud improvement, because it gives the migration a regression line. For example, record p95 latency, error rate, queue lag, and job duration for at least 7 normal business days; that observation window is a practical sample choice, because weekly jobs and traffic cycles often expose behavior that a single test run misses.

Use release controls that preserve reversibility. A 1% canary is a tunable rollout starting point, because it limits blast radius while still exercising real infrastructure. Feature flags through LaunchDarkly, OpenFeature, Unleash, or a simple configuration table can route specific workflows to the new path, but the flag must be testable because an untested rollback switch is theater. Kubernetes readiness probes and liveness probes help after containerization, but they are not functional tests because a process can be alive while corrupting data.

Code quality gates should start as “no new damage” gates. SonarQube 10.6 can block new critical issues, ESLint 9 can enforce changed-file rules, mypy 1.11 can check typed Python modules, and SpotBugs 4.8 can catch Java defects, but applying every rule to the whole inherited codebase on day one creates a wall of legacy violations that nobody can fix safely. Set the first gate to changed code, because QA needs developers to improve touched areas without pretending the entire past can be repaired in one sprint.

The first useful plan is smaller than the inherited fear

Your first concrete move should be a branch that adds one test command, one CI job, and three characterization tests around the scariest workflow. Do not rename files, reformat modules, or upgrade dependencies in that branch, because the review should answer only one question: can this inherited codebase now tell QA when its existing behavior changes?