Uncategorized

Test Inherited Code Cheaply Without Faking Assurance

You have inherited a codebase with few trustworthy tests, and the next change needs a safe release. On a small budget, I would spend less time chasing coverage and more time making one changed behavior reversible and observable. Doing it properly is a larger investment, but the same release rule applies: a flag is useful only when you can tell whether the new path is failing and turn it off without making things worse.

A small budget should buy one reliable release path, not a coverage target

A percentage on a coverage report cannot tell you whether the next change is safe, because it counts executed lines rather than the behavior customers depend on. I would not start by setting a repository-wide coverage target: tests written to satisfy that target can consume the budget while leaving the changed path’s inputs, side effects, and failure modes unexamined. Read Inherited an untested codebase What QA advice is obsolete now before trusting a green build; then record what that build did not exercise.

Start with the change request, not the test directory. Trace one request through its HTTP endpoint, service call, database write, and any queued job. For each boundary, ask what must remain true if the new path is disabled. If an order update writes to PostgreSQL 16 and later sends a message, “the old screen still loads” is a weak check: the write may have changed shape, or the message may have been sent twice. A concise behavior map gives you somewhere to put the few tests you can afford.

For a worked example, suppose you time the existing GitHub Actions job at 14 minutes and find that it runs no tests for the endpoint being changed. That is an illustrative measured baseline, not evidence that the build is healthy. With a budget ceiling of 2 engineer-days for release preparation—a planning limit to adjust with your team—I would first add a request-level test for the old path, a test for the new path, and a test proving that disabling the flag selects the old path. Use pytest 8.x for the tests if the service is Python; use pytest-cov 5.x and coverage.py 7.x to locate unexercised code, not to declare the release safe.

Keep the tests close to the seam where the paths diverge. Mocking every dependency can make them fast but uninformative, because a mock will not reveal a PostgreSQL constraint violation. A small disposable database in CI costs setup time, yet it can check the transaction boundary that matters. If a full request test is too expensive to run on every commit, put the narrow database-backed suite on pull requests and record its runtime. The point is to purchase evidence about this release, rather than a prettier number for the whole repository.

A cheap flag wins only if turning it off really restores the old path

Environment-variable gate versus OpenFeature provider backed by LaunchDarkly is a useful comparison. An environment-variable gate wins when there is one deployment unit, a modest number of operators, and a restart or redeploy is an acceptable response to trouble. Its cost is operational delay: changing the value requires deployment coordination, and different instances can temporarily hold different settings. An OpenFeature provider backed by LaunchDarkly wins when operators need targeted exposure and changes without redeploying. Its cost includes provider configuration, access control, SDK behavior during outages, audit review, and a continuing service bill. Neither option repairs an unsafe code path.

For the cheap option, put the decision in one function rather than scattering environment lookups across controllers and jobs. This Python 3.11 example runs as written; NEW_PATH_ENABLED defaults off, and NEW_PATH_PERCENT defaults to zero:

import hashlib
import os

def enabled(account_id):
    if os.getenv("NEW_PATH_ENABLED", "0") != "1":
        return False
    percent = int(os.getenv("NEW_PATH_PERCENT", "0"))
    if not 0 <= percent <= 100:
        raise ValueError("NEW_PATH_PERCENT must be 0..100")
    bucket = int(hashlib.sha256(account_id.encode()).hexdigest()[:8], 16) % 100
    return bucket < percent

print(enabled("acct-42"))

The stable hash keeps an account on the same side of the gate for a given percentage, which makes repeat tests and gradual exposure more meaningful than a fresh random choice on every request. The percentage is still a setting to validate in deployment, not a guarantee about the exact fraction of a small customer population. Keep the account identifier out of flag-decision logs if those logs do not need it; a bucket and the selected path are usually enough to investigate a release.

The code also exposes the cheap option’s limit. An environment change will not alter a running process unless its configuration is refreshed, and a process reading the environment at request time does not make fleet-wide changes atomic. Document who redeploys, how long that takes, and how they confirm every instance has the intended value. Read Add feature flags to legacy code without rewriting everything before selecting an SDK; the cheaper choice is the one your team can operate and eventually remove.

Rollback is a data question before it is a switch question

A flag can select the old function while leaving behind data the old function cannot read. Before approving either flag option, ask the developer to show the old path running against data written by the new one. If a new database column is nullable and the old code ignores it, turning the flag off may be straightforward. If the new path rewrites an existing field into a new format, the switch may restore the old code but not the old behavior.

Use an expand-and-contract sequence for schema changes: add a compatible column or representation, let both paths tolerate it, move reads only after writes are understood, and remove the old representation in a later release. This costs extra code and migration time, but it preserves a usable rollback window. On a small budget, narrow the release instead of skipping compatibility work. A feature that requires an irreversible migration should not be presented to the release manager as “safe because it has a flag.”

Side effects need the same scrutiny. If both paths can publish a message, verify that one customer action cannot produce two messages during retries or a switch. If a background worker uses a cached flag decision, test what happens when the request path changes before the worker runs. An idempotency key at the side-effect boundary can reduce duplicate work, but only if the key is stable across both implementations. RFC 9110’s HTTP method semantics help frame request retries; they do not make an application’s email, payment, or queue operation idempotent by themselves.

Reserve 30 minutes as a provisional observation window for the first exposure only if that window contains enough relevant requests to be informative; it is a value to tune against traffic, not a universal waiting period. If this endpoint receives a handful of calls per day, wait for completed representative calls instead. A rollback drill should include disabling the flag, confirming the selected path, and reading a record produced by the new path through the old one. Those checks reveal more than a dashboard screenshot showing the flag is off.

Proper rollout needs decisions tied to signals, not more dashboards

On a limited budget, capture three things for the changed path: which implementation ran, whether the request succeeded, and whether its important side effect completed. Prometheus counters can separate old-path and new-path outcomes; OpenTelemetry spans can connect an HTTP request to a queued job. Keep flag state and path choice as low-cardinality attributes, because account IDs as metric labels can create an unmanageable number of time series. Grafana is useful for viewing the result, but a dashboard without an owner and a stop condition does not tell anyone what to do.

Begin at 1% of eligible accounts as an adjustable exposure setting if that produces enough traffic to inspect. Define the comparison before release: for example, compare new-path failures with old-path failures over similar requests, rather than treating every existing error as a regression. Choose a stop threshold with the service owner based on expected volume and harm. A threshold of 5 additional failed requests might be decisive for a rare, high-impact action and meaningless for a noisy endpoint; the number is a decision variable, not a benchmark.

Doing this properly adds controls that the cheap path often lacks: a named operator for each rollout, restricted permission to change targeting, an audit trail of flag changes, and an alert that reaches that operator. Unleash is another option for managed targeting, but self-hosting it transfers uptime and maintenance work to the team. Even with a dedicated flag service, test the SDK’s fallback value when its provider is unavailable. For an unproven new path, defaulting off is generally defensible because provider failure should not silently increase exposure.

Finally, give the flag a removal condition. Once the new path has been fully exposed, observed through an agreed period, and the old-path rollback window has closed, leaving both implementations in place makes future QA harder because every subsequent change has two behaviors to consider. Record a removal ticket when the flag is created, and close it only after tests and configuration no longer reference the retired path.

The first release decision should be written before the first flag is added

Tomorrow, take one pending change and write a one-page release decision: the old behavior that must survive, the side effect that could not be undone, the test that exercises each path, and the person authorized to stop exposure. Ask a developer to demonstrate the old path reading new-path data. If that demonstration fails, spend the small budget fixing compatibility or shrinking the change—not installing a more elaborate flag service.