An inherited untested codebase does not need a rewrite before it can become safer. My position is more abrasive: QA should introduce tests around the current behavior before asking developers to improve the design, because a cleaner design without executable evidence is just a better-looking source of regressions.
The first tests should freeze ugly behavior because ugly behavior is still customer behavior
The tempting move is to ask for a cleanup sprint, but I would not start there because refactoring before characterization removes the only evidence you have about what the system currently does. In a legacy codebase, the first QA win is not elegance; it is repeatable observation.
I would treat Inherited Untested Codebase Which Test Framework Can You Trust as a useful provocation, but I would still choose a framework by the nearest executable seam because inherited systems punish elegant tool selection that cannot observe production behavior.
For a web application, that seam may be HTTP, so pytest 8.3 with requests 2.32, Playwright 1.49, or REST Assured 5.5 can capture behavior without touching internal code. For a JVM service, JUnit 5.11 plus AssertJ 3.26 and JaCoCo 0.8.12 may be enough if the build already exposes service classes. For a Node service, Jest 29.7, Vitest 2.x, SuperTest 7.x, and nyc 17 can give you fast coverage over handlers before anyone argues about architecture.
Do not chase 80% coverage as your first target because a high percentage over shallow assertions gives management confidence while leaving the riskiest branches unobserved. A better initial target to tune is 20 characterization tests across the top 5 user flows, because that creates a regression net around behavior people are likely to notice. The number is intentionally modest; it is a starting value, not a maturity score.
Write these tests as black-box checks until the code earns more precision. A test that says “the old export endpoint returns CSV with these columns” is valuable even if the controller is a 900-line function, because QA can now detect a broken export before a migration or dependency upgrade hides the cause.
mkdir -p tests
python -m pip install 'pytest==8.3.4' 'requests==2.32.3'
cat > tests/test_characterization_http.py <<'PY'
import os, requests
BASE = os.environ.get("BASE_URL", "http://localhost:8080")
def test_login_page_characterization():
r = requests.get(f"{BASE}/login", timeout=3)
assert r.status_code in (200, 302)
assert "text/html" in r.headers.get("content-type", "")
PY
pytest -q tests/test_characterization_http.py --maxfail=1
This is not beautiful, and that is the point. It runs against the system as it exists, it can be placed in CI, and it gives QA a repeatable check before the first “small cleanup” changes routing, headers, or authentication behavior.
A migration plan should follow the tests because cloud infrastructure multiplies hidden assumptions
I disagree with the optimism in Legacy to Cloud Migration Strategy and Code Quality whenever it implies migration can lead quality, because cloud platforms amplify untested assumptions about file paths, timeouts, clocks, queues, and process lifetimes.
Moving a legacy application to containers, Kubernetes, AWS ECS, Azure App Service, or Google Cloud Run before you pin behavior is risky because runtime differences will look like application bugs. A local server that silently writes to /tmp/reports may fail in an OCI image, and a batch job that assumes one long-lived process may behave differently when deployed behind a liveness probe.
I would not rewrite the application into microservices as part of the first cloud step because service boundaries created without tests turn one unknown system into several unknown systems connected by unreliable assumptions. The safer move is to wrap the current deployable unit with tests, telemetry, and release controls, then move one operational boundary at a time.
Use Docker 27 with a minimal Dockerfile only after characterization tests pass locally, because the container should preserve known behavior rather than define it. Use Docker Compose 2.29 or Testcontainers 1.20 when the system needs PostgreSQL 16, Redis 7.4, RabbitMQ 3.13, or Kafka 3.8 in repeatable test environments. Use Flyway 10 or Liquibase 4.29 to version schema changes, because cloud deployments make “someone ran a script manually” harder to diagnose.
A practical release gate can be small. One measured baseline I like is all characterization tests completing in under 15 minutes on the default CI runner, because a slower gate will be bypassed during urgent fixes. A stricter value may work later, but inherited systems usually need trust before speed.
Add OpenTelemetry 1.32 traces before major hosting changes because QA needs to compare old and new behavior by request path, status code, and latency. Export traces with OTLP over HTTP or gRPC, store logs with trace IDs, and capture p95 latency for the flows covered by tests. A p95 value is not a pass/fail truth by itself because legacy performance varies, but a measured jump from 400 ms to 1,800 ms after containerization is strong evidence that the migration changed runtime behavior.
Contract tests beat broad end-to-end tests when ownership is unclear
Inherited systems often fail at the seams between teams, vendors, scheduled jobs, and undocumented APIs. QA should test those seams before expanding browser automation because contract breaks are cheaper to localize than full journey failures.
Here is the explicit tradeoff. Pact 4.x wins when a legacy application consumes or provides APIs and you can name the provider and consumer, because Pact verification proves whether both sides still honor the same request and response shape. Its cost is social and technical: someone must publish contracts to a Pact Broker, wire provider verification into CI, and keep examples meaningful.
Playwright 1.49 wins when the highest-risk behavior is visible in the browser and no API seam is trustworthy, because it validates the user-facing result across Chromium, Firefox, and WebKit. Its cost is runtime and flake management: the vendor-published default test timeout is 30,000 ms, and a suite that leans on arbitrary waits will become noisy as soon as the environment slows down.
For an inherited untested codebase, I would start with Pact for stable service boundaries and add Playwright only for the few flows that prove business continuity, because UI tests give confidence but hide root causes when used as the main diagnostic tool. If the application has no API boundaries, use Playwright first, but cap it deliberately so it does not become the only safety net.
Track flakiness as a first-class metric. A sensible early tolerance to tune is less than 2 flaky failures per 100 CI runs, because a noisier suite trains developers to rerun instead of investigate. Store results as JUnit XML, publish them from GitHub Actions with actions/upload-artifact@v4 or GitLab CI artifacts, and make failures searchable by test name, commit, and environment.
Use SARIF 2.1.0 for static-analysis output when possible because GitHub code scanning and other tools can show findings inline without inventing a new report format. Add Semgrep 1.x rules for dangerous legacy patterns, ESLint 9 with a flat eslint.config.js for JavaScript, Checkstyle 10 or PMD 7 for Java, and Ruff 0.8 for Python. Static analysis is not a substitute for tests because it cannot confirm business behavior, but it catches repeatable hazards cheaply.
Quality gates should be narrow because broad gates punish the first team that tries
A QA engineer inheriting an untested system should resist enterprise-grade gates on day one because broad gates fail for old reasons that nobody can fix inside the current change. A gate that blocks every merge due to 3,000 historical lint warnings will be disabled, and a disabled gate is worse than no gate because it teaches the team to ignore automation.
Start with “new code only” policies. SonarQube 10.x supports quality gates on new code, and that matters because developers can improve the touched area without boiling the ocean. Sonar’s commonly used cognitive complexity threshold of 15 for a method is a vendor-default-style rule worth reviewing, not worshipping, because legacy domains sometimes require branching but still need tests around that branching.
Prefer metrics that show risk movement rather than moral judgment. Cyclomatic complexity from Radon 6, CRAP score from PHPUnit tooling, branch coverage from JaCoCo or Istanbul, mutation score from StrykerJS 8 or PIT 1.15, escaped defects, change failure rate, and mean time to restore are useful when trended. They are dangerous as isolated targets because teams can optimize the number while leaving the risky behavior untested.
Set one enforceable gate per stage. For example, pull requests might require the smoke characterization suite, dependency audit, and changed-file linting only. Nightly builds can run Playwright, mutation testing, container scans with Trivy 0.56, and OWASP Dependency-Check 10. A release candidate can require API contract verification, database migration rollback checks, and OpenTelemetry comparison against the last stable release.
The numbers should be explicit. A practical release budget I have used is zero failing smoke tests, zero failed Pact verifications, and no critical vulnerabilities with a known fix, because those failures map to production risk that QA can explain. A separate improvement target might be 5 percentage points of branch coverage growth per quarter on changed modules, because gradual growth avoids the false drama of a one-time coverage campaign.
Do not make mutation testing mandatory across the whole codebase at the start because StrykerJS or PIT can be slow and demoralizing on code that was never designed for isolation. Run mutation testing on newly extracted modules first, because a low mutation score there reveals weak assertions while the team still remembers the change.
The first safe change is a seam, not a redesign
Tomorrow, pick one production flow that support staff fear touching, run it through a tiny characterization test, and publish the result in CI. Then add one contract, one trace, or one changed-code gate around the same flow. That sequence gives QA leverage without pretending the inherited codebase is ready for a rewrite, a platform migration, or a perfect test pyramid.



