Get a test suite around the code that never had one

Capture what the code does today as executable tests, so you can change it tomorrow without breaking production.

For anyone maintaining something they are afraid to change · 8 steps · 7 min

How it works today.

You open the file, read through two hundred lines of logic written by someone who left, and try to imagine what will break if you add a parameter. There are no tests. The function is called from three places you can grep for and probably two you cannot. You make the change in a branch, squint at it, and hope.

Every deploy is a small ceremony of dread. You check logs after the fact, wait for Slack to light up, and promise yourself you will write tests next time. Next time never comes because writing tests for code you do not fully trust means deciding what the correct behaviour is, and you do not know. So the module ossifies.

Before you start.

All of it has to be true, or step one fails in a way that is annoying to debug.

  • Read access to the repository containing the untested code.
  • A working local development environment where you can run the code and see output.
  • Write access to create a new branch and open a pull request, or approval from someone who has it.
  • An account with a vertical coding agent (Cursor, Windsurf, GitHub Copilot) or access to a code-aware LLM with repository context.
  • If the code touches external services or databases, a sandbox environment or the ability to mock those dependencies.

The steps.

  1. Run the code and capture real inputs and outputs

    Before you write a single test, observe what the module actually does. If it is a function, call it with production-like data in your local environment or sandbox. If it is an endpoint, send it requests and log the responses. If it is a batch job, run it on a small slice of real data. Save the inputs and outputs to a file or screenshot the terminal. You need ground truth for what today's behaviour is, warts and all.

  2. Identify the entry points and their contracts

    List every function, class method, or API route that outside code calls. For each one, note what it accepts (parameters, request shape, environment variables) and what it returns or mutates. If the code reads from a database or calls another service, write down those dependencies. You are building a map of the surface area you need to cover.

    Paste this
    I need to write tests for [file or module name]. List every public function, method, or endpoint that external code calls. For each one, describe: what parameters it accepts, what it returns, and what external dependencies (database, API, filesystem) it touches. Be specific about types and shapes.
  3. Generate characterisation tests for the happy path

    Ask the agent to write tests that assert the current behaviour you captured in step one. These are not correctness tests; they are change-detection tests. The agent will produce assertions based on what the code does now, which may include bugs. Do not merge these yet. The goal is a baseline that will scream if you accidentally change something.

    Paste this
    Write test cases for [function or module name] that assert its current behaviour. Use the following real inputs and outputs I observed: [paste your captured examples]. Cover the main happy path. Use [your test framework, e.g. pytest, Jest, JUnit]. Do not fix bugs or improve the code; lock in what it does today.
  4. Read every generated assertion and mark the lies

    Open the test file and read each assertion line by line. Compare it to your captured output from step one. If an assertion claims the function returns a sorted list but you saw unsorted results, that is a lie. If it asserts a 200 status but the real response was 201, mark it. Add a comment like # TODO: this assertion is wrong, observed [actual value] in testing. You are building a list of what to fix once the tests run green.

  5. Run the tests and fix the failures that are test bugs

    Execute the test suite. Some failures will be because the agent guessed wrong about imports, file paths, or how to instantiate a class. Fix those. Some failures will be because the assertion does not match reality and you marked them in step four. Fix those assertions to match the actual behaviour, even if that behaviour is broken. The goal is a green build that reflects today, not tomorrow.

  6. Add tests for edge cases the agent missed

    The agent wrote happy-path tests. You know the weird cases: the empty list, the null parameter, the user who sends a string where you expected an integer. Write those tests by hand or prompt for them explicitly. Run them and let them fail if the code does not handle the edge case. Do not fix the code yet. You are still in characterisation mode, documenting what breaks.

    Paste this
    Add test cases for [function name] that cover edge cases: empty input, null values, wrong types, missing required fields, and any boundary conditions like maximum length or zero. Assert the current behaviour, even if it is an unhandled exception.
  7. Check coverage and decide what you can live without

    Run a coverage tool (coverage.py, Istanbul, JaCoCo) against your new test suite. You will not hit one hundred percent and you should not try. Look for critical paths with zero coverage—authentication checks, payment logic, data mutations—and add tests there. Ignore code that only runs in legacy edge cases you plan to delete. Coverage is a map, not a score.

  8. Open a pull request with the tests and nothing else

    Commit only the test files and any minimal setup (fixtures, mocks, test dependencies). Do not refactor the code, do not fix the bugs you found, do not rename variables. The PR description should say: these tests assert current behaviour, including known issues marked with TODO comments. Once this merges, you have a safety net. Now you can change things.

What you keep.

Automating the typing does not move the accountability. These stay with a person.

  • Deciding which assertions are bugs versus features, because the agent cannot know if returning unsorted results was intentional.
  • Choosing which untested code paths matter enough to cover, because coverage is about risk and only you know what breaks the business.
  • Reading and approving every test before it merges, because generated tests that assert broken behaviour will fight you when you try to fix it later.
  • Maintaining the tests as the code evolves, because the agent wrote them once and will not remember why you skipped that edge case.

Once it works.

The first run is the demo. These are where the time actually comes back.

Run it on a loop

After each deploy, re-run the characterisation tests against production traffic in a sandbox to catch drift between what the tests assume and what real users send.

Fire it from an event

When a bug report comes in, write a failing test that reproduces it before you fix the code, then keep that test to prevent regression.

Put it on a schedule

Once a quarter, generate a fresh coverage report and compare it to the last one; if coverage dropped, someone added code without tests and you can catch it before it fossilises.

Hand off to another agent

Use the test suite as onboarding documentation—new engineers can read the test cases to learn what the module is supposed to do faster than reading the implementation.

The work this replaces.

These are the O*NET work activities this workflow covers, and the categories of tool that address them.

Addressed by vertical agents, runtime and sandboxes, eval and observability