A field note on why tests are the only thing that makes old code safe to change, how to write them for a system you did not build, where AI compresses the mechanical work and what happens to a codebase once change becomes cheap.
Engineering teams working on legacy systems all eventually hit the same wall: a one-line change takes three weeks to ship. The line itself is trivial, but because the module has no tests, every engineer who opens the file spends the first week decoding it, the second week convincing a reviewer and the third week trapped in a manual regression cycle that exists solely because nobody trusts the code. That is the tax paid on untested legacy modules—weeks of verification for minutes of change.
Unit testing legacy systems is not hygiene work deferred until after feature delivery. It is the core mechanism that makes a legacy system safe to touch. A system that is not safe to touch never gets cleaner, because cleaning code is itself a change and change is what the organization fears most. This post outlines the exact process Kappal uses to modernize enterprise legacy systems safely: characterization tests first, seams second, behaviour over implementation, mocking only at the boundary you do not own and the organizational velocity that follows when change finally becomes cheap. It also shows where AI compresses the work at each step and where it must not.
Fear dictates the roadmap
The standard belief about legacy code, the one that keeps it legacy, has two halves. First, that fixing it properly means understanding all of it first. Second, that understanding all of it means either a massive rewrite or a year of reading. Both halves are wrong and both halves produce the same outcome: nothing changes.
At Kappal, we see this pattern across enterprises. Core systems keep the business running without pause, but they speak a dialect the current team has stopped reading. Roadmaps are full of modern features that must balance precariously on top of this brittle foundation. Leadership often dictates that there is no budget for tests on the old system because tests do not ship features and engineers eventually stop fighting that constraint.
New features ship on unsteady code and every release carries the scar of the release before it. Regressions are found by customers instead of a CI/CD pipeline and deployments happen only when somebody feels brave. Every engineer keeps a private list of files they will not open, believing the file itself is the problem. But the file is not the problem. The fear is the problem and the fear exists because the code cannot tell anyone what it does when modified.
You cannot fix what you cannot see break
A test is simply a machine that runs code and reports what it actually does. This means you do not need to fully understand a legacy system to start testing it; you only need to observe it. The correct way to start unit testing a legacy system is not to comprehend all of it first, but to lock in the behaviour it exhibits today using characterization tests, then improve the code one seam at a time under that lock.
The technique is called a characterization test and it is almost insultingly simple. You feed a function inputs, record what it produces today and lock those outputs in as expectations. You are not asserting the behaviour is "correct" by modern standards; you are asserting it is stable. In a legacy system, today's behaviour is load-bearing even where it looks flawed. Some other part of the business quietly depends on it: a date parsed a specific way, an amount rounded unexpectedly, an error swallowed silently. Characterization tests freeze these sacred parts first so that when you change the code, the test immediately flags any deviation. Only after the behaviour is locked do you ask which parts deserve to survive.
Without this approach, the first test suite a team writes usually fails in two predictable ways: it tests implementation instead of behaviour and it mocks collaborators the team owns. Both are code smells with strict corrections. A suite written incorrectly breaks every time the code improves, turning the test suite into a tax that punishes refactoring.
Unit testing starts with the file you are about to touch
The practical version of this is small. You do not begin by testing the whole system. You begin with the module on your current change's critical path. Write characterization tests for the specific function you must modify, covering the cases visible in the callers and the edge cases you fear. Then, make your change.
The test suite around that one function transforms a change you can only pray about into a change you can reason about. Legacy systems run on tribal knowledge: the engineer who knows a specific report breaks on a trailing newline, or the developer who remembers a discount field functions differently on Tuesdays. Characterization tests extract that tribal knowledge and commit it to the repository, where it runs on every pull request forever.
Introduce seams, not rewrites
A legitimate objection always arises here: "The function is eight hundred lines long, reads a file, hits a database, calls a static method and grabs the current time. How do you test that?"
The answer is our second modernization technique: you do not test the entire function. You first make a small part of it testable by introducing a seam. A seam is a refactor with no behaviour change, verified by the characterization tests you just wrote. You extract the pure calculation or core logic into its own function and you test that extraction. The file read, the database call and the system clock stay where they are, while the isolated logic, where the bugs usually hide, is placed under test.
The first seam in a legacy module is the hardest because the logic and I/O are typically welded together. But by extracting and testing incrementally, you slowly separate the testable parts from the untestable, shrinking the risky surface area with every commit. Some boundaries resist unit tests: stored procedures, generated reports, message queue consumers. Those earn integration tests instead and the principle is identical: test the behaviour the outside world can observe.
Test behaviour, not implementation
To keep tests valuable, ask this question of every test you write: If someone rewrote the internals of this function tomorrow and kept its behaviour identical, would this test still pass?
If it passes, you are testing behaviour. If it breaks, you are testing implementation. Implementation tests are easily recognizable: they assert that a private helper was called, check internal fields, or mandate the exact order of internal arguments. They are written by engineers who confuse what the code does with how the code does it.
A behaviour test survives a rewrite, enabling continuous improvement. An implementation test dies with the rewrite, protecting the code's current flawed shape rather than the business outcome. Assert strictly on what the caller observes: the return value, the state change, the exception raised, or the message sent to the boundary. The internal route is yours to change; the external contract is not.
Mock what you do not own
Mocking is a powerful tool that is frequently abused. The rule for Kappal engineering teams is sharp: mock the things you do not control and cannot run in a test (payment gateways, file systems, clocks, external APIs, databases). Never mock the internal code you own and are trying to improve.
When you mock your own class, your test asserts your assumptions about your own code. It will pass even when the real code is broken because the mock is just a dummy reflecting the test's desires. Mocks are intentional lies told to a test suite; use them only where the truth is unavailable. If your mock setup is longer than the code under test, or if you need to mock three layers deep, the function needs a seam, not more mocks.
Where AI earns its keep
Everything above is discipline and discipline is not fast. A legacy change takes weeks because the understanding, the tracing and the typing are enormous. That is where AI compresses the work and it is worth being exact about which parts, because the line between a draft and a decision is where these projects get hurt.
Code archaeology. The first week of a legacy change goes to decoding: tracing the call graph, finding every caller of the function, separating the branches that carry business rules from the ones that carry history. A model reads the repository in minutes and returns a map: callers, dependencies, the constants that encode policy, the date logic nobody documented, the magic numbers that turn out to be tax rates. It is a draft of understanding, not understanding itself and it still has to be checked against the running system. The difference is that the check becomes an hour of pointed questions instead of a week of open-ended reading.
Golden masters. Characterization tests are perfect model work: read the execution paths, generate the inputs a caller could send, record what the function returns today, write the suite. A team that would hand-write forty tests gets four hundred as a first draft, including the hostile inputs nobody thought to try. The rules from the sections above decide whether that draft is useful. A generated suite that asserts implementation detail or mocks the code the team owns is a faster version of the same tax, so generated tests go through review like any other change, with the same question: if someone rewrote the internals tomorrow and behaviour held, would this test still pass?
Seams. A seam has to be found before it can be cut and a dependency graph makes the candidates visible: the pure calculation welded to a file read and a database call. Once the seam is chosen, the execution is mechanical and mechanical is what a model does well: extract the logic, build the adapter, generate the mocks for the boundaries the team does not own, all under the golden-master lock. The engineer reviews the diff. The suite goes green or it does not.
None of this is autonomy. The model drafts the map, the tests, the extraction and the adapter. The engineer decides which behaviour is sacred, where the boundary belongs and what the contract promises. AI removes the weeks of tracing and typing. The earlier sections are what keep its output honest.
From one function to the whole system
The same compression applies one level up, where the unit of work stops being a function and becomes the system.
Dependency mapping. At repository scale the same analysis answers a different question: where the first boundary should be drawn. Dependency graphs surface coupling that never made it into a diagram: shared global state, hidden database links, the modules that always change together. Cyclomatic complexity and change frequency rank the hotspots and the ranking decides which module gets modernized this quarter and which one waits.
Pattern sweeps. Putting a repository layer in front of the data access, or moving construction behind dependency injection, is one decision and ten thousand edits. A model applies the rule consistently across the call sites while the engineer reviews the exceptions, which is the only part that needed a human in the first place. The same applies when a monolith gets cut: bounded contexts surface from the dependency map and the first drafts of a service skeleton, its OpenAPI contract and its client stubs fall out of the analysis.
Facades and translations. The strangler pattern needs an interface in front of the old system, translators that move data between the old shape and the new one and routing that sends each request to the right side while both run. That is plumbing and plumbing is where models are fastest. Replatforming gets the same treatment: an older Java stack moves to Spring Boot, a procedural script becomes a modular TypeScript service and a senior engineer reviews architecture and edge cases while the compiler and the suite check the rest.
Differential testing. Shadow traffic sends real production requests to both systems and compares the answers. Where they diverge, the model reads the logs and the state changes and points at the input where the two paths parted. That closes the loop without a flag day: a few thousand real requests prove the new code carries the same behaviour before the old code is switched off.
Where the code cannot leave the environment it runs in, the analysis runs there too. The pattern is the same either way: drafts at machine speed and tests, contracts and reviewers deciding what survives.
How Kappal modernizes legacy operations
This process is not theoretical. It is the methodology Kappal uses to modernize core legacy systems for enterprise clients in finance, manufacturing, agriculture and commerce, businesses that cannot afford operational downtime.
We do not advocate for massive, risky rewrites. Rewriting a system that holds the source of truth is how enterprises lose that truth. Instead, we target the modules that require modification, freeze their behaviour with characterization tests, introduce seams, execute the changes and hand back a module where the next change is cheap. This is the legacy strangler pattern applied at the function level, allowing our modernization engagements to ship continuously in phases rather than going dark for a year.
Because we run what we ship, we live with the tests we write. Our engagements run on an auditable cadence: code in repositories, weekly demos and phased go-lives. When we declare a codebase safe to change, the tests are the evidence.
The compounding return of safe code
The ultimate realization for engineering organizations is that unit tests do not just make the code run faster; they make the organization run faster.
Cleaning code is a series of changes and those changes only happen at scale when they are safe. Unused code gets deleted. Duplicated logic gets merged. Bizarre legacy edge-cases are documented via tests that explain exactly why they exist.
When a system is wrapped in reliable tests, a modification that previously demanded a three-week manual regression cycle can be confidently shipped in a single afternoon. Speed becomes a property of the team's relationship to the code. You do not need a multi-million-dollar rewrite to achieve this. You need characterization tests that lock in today's behaviour, seams that make tomorrow's code testable and a disciplined process to ensure every change makes the next one cheaper.
Kappal Engineering
Kappal Software · Building tomorrow's enterprise solutions
