Testing a Legacy Project From Scratch With an AI Agent
Most engineers are already using AI in their day-to-day implementation work, which makes the interesting question not "should an agent help test a legacy codebase" but "in what order." Point an agent at a testing strategy for a codebase with no tests and tell it to go, and you'll get a pile of tests for whatever it found first: the same random-coverage problem that strategy exists to avoid, just produced faster. Speed is exactly the trap: an agent that's fast and unsequenced just gets you to a false sense of safety sooner. The sequence matters more with an agent involved, not less.
A Plain Context File Beats a Custom Agent
You don't need to build a custom agent for this. A general-purpose coding agent pointed at a plain rules file, a CLAUDE.md or AGENTS.md sitting at the repo root, gets you most of the way there. It's tempting to spend the first hour designing a specialized agent persona instead; resist it. That effort goes into tooling, not into understanding the fifteen-year-old codebase the agent is about to work in. The leverage isn't in the agent's configuration; it's in what that rules file says and, just as much, in when you write it. Writing it before the agent has touched the codebase means guessing at conventions you haven't verified yet. That's the mistake the rest of this sequence is built to avoid.
Build the Test Infrastructure Before You Write a Single Test
Start with the part that has nothing to do with the legacy code itself: a database, fixtures1, and a command that runs the tests. For most projects a file-based or in-memory SQLite database is enough: fast to reset between test runs, no container to manage. The prompt at this stage should be scoped tightly: set up a lightweight SQLite database for end-to-end tests, add a fixture-loading script, and wire up an npm run test:e2e command. Don't touch any application code yet.
That last constraint matters. An agent that's also touching application code while it builds test infrastructure is doing two jobs at once, and you won't be able to tell which one introduced a problem if something breaks. Keep infrastructure and application code as separate steps, even if the same session ends up doing both.
What comes out of that prompt is small on purpose: a database that resets in milliseconds and a fixture loader, nothing else:
import Database from "better-sqlite3";
import { readFileSync } from "node:fs";
export function createTestDatabase() {
const db = new Database(":memory:");
db.exec(readFileSync("schema.sql", "utf-8"));
return db;
}
export function loadFixtures(db: Database.Database) {
db.exec(readFileSync("test/fixtures/reservations.sql", "utf-8"));
}
Nothing here knows anything about the legacy application yet. It's infrastructure the rest of the sequence stands on.
Prove the Pipeline on the Endpoint That Can't Surprise You
Don't point the agent at the riskiest part of the system yet. Find the simplest endpoint you can: something with no branching, no concurrency, nothing interesting. Use it to prove the whole pipeline actually works: fixtures load, the test runs, the command reports the result correctly. This step isn't about the endpoint. It's about finding out whether steps 1 and 2 actually work before anything real depends on them.
Ask the agent to report back before it writes anything: list the endpoints in this codebase, ranked from simplest to most complex. Don't write any tests yet. Reviewing that list yourself, before a single line of test code exists, is cheaper than reviewing a test suite built on a wrong assumption about which endpoint was actually simple.
Once you've picked one, the next prompt is narrow on purpose: write one end-to-end test2 for this endpoint using the fixtures you just built, run it, and confirm it passes. One test. One endpoint. Confirm the whole chain works before asking for a second one. This step is doing double duty, too: it's the first time you see how the agent behaves when a test fails and it has to fix its own fixture instead of the application code. Better to notice that now, on something trivial, than discover it for the first time on the endpoint that actually matters.
Write the Context File After, Not Before
This is the step that's easy to get backwards. It's tempting to write the CLAUDE.md first, documenting the architecture, the conventions, the domain vocabulary, before the agent touches anything. Don't. A rules file written speculatively, before anyone has actually read the code, tends to describe the codebase you assume exists rather than the one that does, and it goes stale the moment reality disagrees with it. After steps 1 through 3, the agent has actually read the routing layer, the database schema, and the test setup you just built together. It knows things about this codebase neither of you could have written down speculatively on day one.
Now is when the rules file earns its keep:
## Testing conventions
- End-to-end tests live in `test/e2e/`, one file per endpoint
- Fixtures reset before every test run — see `test/fixtures/reset.ts`
- Domain vocabulary: a "slot" is a bookable unit, not a time range
## Before writing code
- Report your plan first. Don't write tests until the plan is reviewed.
That last rule is the one doing the real work: it's what turns "analyze before coding" from something you have to remember to ask for into something the agent defaults to.
Now Point the Agent at the Endpoints That Can Collide
With the infrastructure proven and the context file grounded in what actually got learned, this is where the real difficulty starts: the conflict endpoints, the ones the previous article argued you should test first by hand. With an agent, they still go last in the sequence, after the pipeline is proven, even though they're the ones that carry the actual risk.
They can also break an assumption step 1 quietly made. A test that exercises the race condition behind double-booking the same slot depends on real transaction locking and isolation behavior across concurrent connections, the exact thing SQLite's simplified locking model doesn't reproduce faithfully. The simple endpoint from step 2 never exposed that gap, because nothing about it depended on how the database handles two writers at once. Testing the race condition, by contrast, exposes it immediately. That's the signal to swap, just for these tests, to a containerized instance of whatever database runs in production, fixtures and all, rather than forcing the fast option to keep working.
This swap doesn't mean pointing the tests at the development database, or at production. It's a database that exists only for the test run: it comes up in a container running the same engine and version as production (via Testcontainers, or a docker-compose service dedicated to tests), gets torn down when the run ends, and never shares state with anything outside that run.
The first step after bringing it up is running the project's real migrations against it, the same ones applied in production. That has a useful side effect: it validates that the migrations work against the real engine, something SQLite can't check on its own. Then the same fixtures the SQLite tests already used get loaded, this time inserted against an engine with real foreign keys and constraints, which sometimes surfaces assumptions in the test data that SQLite let slide.
Resetting between tests is where this approach gets a bit more involved. The usual trick for fast tests, wrapping each test in a transaction and rolling it back at the end, doesn't work here: a race-condition test needs two real connections actually competing for the same resource, which means both need to commit for real so locking and isolation behave the way they do in production. A rollback-per-test transaction hides exactly the behavior you're trying to prove. Instead, the reset is a truncate of the affected tables before each race-condition test, run against a single container kept alive for the whole suite, not a fresh one per test, because spinning up the container is expensive enough that doing it per test would be unsustainable.
That friction (real migrations, fixtures with real constraints, manual resets) is exactly why it's reserved for the endpoints that actually need it. For everything else, SQLite is still the right call.
The prompt here stays deliberate, the same shape as step 2 but pointed at harder material: propose a test plan for the double-booking conflict on this endpoint. Don't write the test yet. Review the plan. Only then ask for the test itself.
An agent that writes code before it understands the codebase isn't saving you time. It's front-loading the code review you'll do later anyway.
Conclusion
An AI agent doesn't change what a legacy codebase with no tests needs: it still needs infrastructure before tests, simple before risky, and a context file grounded in what's actually true rather than what seemed likely on day one. What changes is how easy it is to skip straight to the interesting part and get a pile of coverage that doesn't protect anything real. The fix is the same fix as always: do it in steps, review the plan before the code, and let the agent earn the harder endpoints instead of starting there.
Footnotes
-
Test fixtures: known, pre-loaded data a test runs against, so its assertions can rely on a predictable starting state instead of whatever happens to be in the database. ↩
-
End-to-end test: a test that drives a request through the real system the way a client would, over HTTP, through the real handler, into the real database, and asserts on the response. ↩
Related articles
Get new articles in your inbox
Writing about pragmatic architecture, maintainable systems, and real-world engineering trade-offs. Published when ready.