Get in touch
Gabs
Blog

TDD with coding agents only works behind a required gate

A developer directing coding agents can keep a large codebase correct with a failing test as the spec and one required gate on main.

Gabriel EspinheiraSenior Full-Stack Engineer
  • Published
  • 10 minReading time
  • EngineeringCategory

Listen to this post· AI-narrated

0:00 / 11:03

An agent finishes a feature, writes the tests, runs them, and reports green. Both the code and the tests came out of the same session, so that green tells you almost nothing about whether the feature is right. That's the problem TDD with coding agents has to solve. How do you trust code you didn't type? A senior full-stack job posting from this month puts it as a requirement: "you understand what the code is doing, not just what the AI generated."

I'm Gabriel Espinheira. Since March 2026 I've built SharpOS as its only engineer, directing coding agents. It's the platform my studio, SharpHaw, runs its client work on: boards, pages, forms and support for each client, on web, macOS and iOS. It has 2,236 test files and one testing rule every agent follows. Below are the rule, the gate that enforces it, and the habits around both.

TL;DR: TDD with coding agents pays off when the failing test is the spec for the change and one required CI gate checks every pull request, outside the agent's session. Red-green inside the agent's own session proves little. The gate, and turning every incident into a new check, is what keeps a large codebase correct.

Does TDD still work when an agent writes the code?

Birgitta Böckeler tested exactly this in "TDD inside the agent loop - theater or actual value?" (opens in a new tab), published on martinfowler.com in August 2026. She had agents build small greenfield tasks with and without a TDD workflow, then had a model judge the results blind. Her verdict: "there was no clearly discernable difference" in quality, and the TDD runs used about three to eight times the tokens. Agents skipped or faked the red step, and some tests checked the code against itself.

Her sharpest line: "Watching a test go red is only proof of anything if someone is checking why it went red."

I agree with her. When the same agent writes the test, sees it fail and writes the code, nobody outside the session has checked anything. We part ways on the fix. She stopped asking agents for tests first. I kept the rule and moved the proof out of the session: a plan that defines done before any code exists, and a gate the agent has to pass to merge. Her eval used short greenfield tasks that end when the session ends. SharpOS outlives every session, and a test written today is still checking the next agent six months from now.

The rule I give every coding agent

The rule sits in the SharpOS AGENTS.md, the one file every agent runtime reads first. Here it is, word for word:

- **Test-driven:** write the failing test that defines done, see it fail,
  implement to green, refactor on green. No testable surface (copy, visuals,
  config)? Name the success check up front instead. Multi-step work gets a
  short plan with one verification per step.

Everything hangs on "defines done". An agent reads a prose description loosely, and it will happily decide that a half-built feature matches it. A failing test is a check it can't talk its way past. One SharpOS backend test is called "filters internal cards out of a client board workspace for viewers". Either the board query hides cards marked internal from a client's viewers, or the suite stays red.

The second sentence covers work with no honest unit test. A test that asserts a string of copy or a class name breaks on every edit and catches nothing a reader would notice. So for copy, visuals and config the rule asks for a named success check instead, decided before the work starts: a screenshot to compare, a page to load, a command that must exit zero.

Claude Code, or any other agent I point at the repo, gets the same rule, because each runtime's config file only points back to AGENTS.md.

Who writes the failing test?

Usually the agent writes it. The spec behind it comes from me. In SharpOS a feature starts as a plan in its own folder, plan-<context>.md, before any code, and contracts go in spec-<context>.md. On 15/10/2026 the repository tracked 278 plan files and 123 spec files.

A plan names what done looks like, step by step, with one verification for each step. Take the plan for checking the session on every write. Its build order opens with "1. Failing tests.", names the exact test file and the expectation to extend, and only then gets to the code. It also lists what the agent must not do, including "Loosening the check to rescue a runtime test whose identity has no session. Give that test a session [...] instead." That line exists because an agent under pressure will weaken the code to turn a test green.

The agent turns the plan into failing tests, and that's where the human checkpoint Böckeler asks for belongs: in the plan that decides what the tests must prove, before the agent is anywhere near the red step. The thinking TDD used to force on a human, about the interface and the edge cases, happens while writing the plan. A wrong plan gets caught before an agent has built three files on top of it.

One required gate the agent can't merge around

A rule in a markdown file is only a suggestion until something checks it. SharpOS CI runs 16 jobs on every pull request, and exactly one of them is a required check on main. That job, ci_gate, waits for twelve others: conventions, lint and types, the build, the frontend, backend and scripts suites, the same suites again on a non-UTC host, an end-to-end smoke test on the production build, a third-party integration catalog check, and coverage. Then it does one thing (trimmed from ci.yml):

ci_gate:
  if: always()
  needs: [changes, conventions, lint_types, build, test_frontend,
          test_backend, test_scripts, test_tz, test_tz_ui, e2e_smoke,
          composio_catalog, coverage_report]
  steps:
    - name: Require every gate to have passed or been skipped
      run: |
        node <<'NODE'
        const needs = JSON.parse(process.env.NEEDS)
        const failed = Object.entries(needs).filter(
          ([, value]) => !["success", "skipped"].includes(value.result),
        )
        if (failed.length > 0) process.exit(1)
        NODE

It's a few lines of Node with no checkout and no cache, so there's very little in it to break, and adding a new gate means adding one name to needs. Jobs skip only when the changes job finds a docs-only pull request.

An agent can still edit ci.yml or delete a test. The gate doesn't stop that, and I won't pretend it does. What it does is make those moves visible: a workflow change shows in the diff, and the coverage job compares the pull request's test counts with its base, so a test that disappears shows up as a drop in the count on the pull request.

bun run ci runs the same pipeline locally, in the same order, so an agent sees most failures before a pull request exists. Once the gates pass, CI deploys the pull request to its own Vercel and Convex previews and runs contract tests against them. Those hit the real deployment, so a test can't pass against a mock that drifted from the backend. Errors are part of the contract too: every REST and MCP failure returns a request ID and one of 11 typed error codes, and an agent that changes an error path breaks a test.

Every incident becomes a check

Agents forget everything between sessions, so the memory has to live in the suite and the rules. When something breaks in a way the gate didn't catch, the fix includes a new check, so it can't break that way twice.

On 08/06/2026 a SharpOS production deploy failed on a legacy settings row. Release v1.6.1 fixed it and added a gate that tests each release's schema against legacy rows before anything deploys.

The same habit runs through my other projects. On 08/10/2026 a release merge in Relay, my local AI studio app, reached main without its version commit, and the release automation refused to tag it. The fix added a workflow that checks every version pull request before merge, and the next release shipped about ten minutes later. In Fabricator UI, my open-source component library, the docs site build was killed with no error on an 8 GB build machine. That one isn't a CI check yet. It's a rule in the agent instructions: check the build in a container limited to 4 CPUs and 8 GB, because it needs about 7 GB today.

What the numbers show, and what they don't

A test count proves nothing about correctness on its own. I publish these to show the rule held across 104 releases of a platform my studio runs on every day:

  • 2,236 product test files, counted on 15/10/2026.
  • 39,770 unit tests (Vitest) and 140 end-to-end tests (Playwright) in the coverage report of 12/09/2026. The end-to-end figure is the number declared in the suite.
  • 94.6% line coverage across 103k measured lines, also from 12/09/2026.

The other numbers I watch come from reviews. Agents review the codebase on a schedule and file reports: 49 of them between 12/07 and 24/09/2026. A full security triage on 06/09/2026 gave every one of 190 security findings a verdict: 76 fixed with a regression test or proven false, and 114 open, each with a planned fix. I'd rather publish the open count than a tidy one. Those reviews catch what no single pull request shows.

The full engineering story is in the SharpOS case study.

Trade-offs I accept

Coverage doesn't block. The target is 95% per module, and since 23/07/2026 missing it warns on the pull request instead of failing it. A hard percentage invites tests written to move the number, and an agent will write those all day. I'd rather see the warning and decide.

Copy, visuals and config get a named check, never a test. A broken layout passes the suite, so the named check has to catch it, and sometimes it doesn't.

The tokens cost money. Böckeler measured the TDD runs at several times the cost, and running a full suite on every change costs time and compute I'd skip on a weekend prototype. On a platform my studio runs on every day, one regression costs more than the tokens.

A checklist for TDD with coding agents in your repo

You don't need SharpOS for any of this. You need a repository, a CI provider that can mark one check as required, and an instructions file your agents already read.

  1. Paste the rule into AGENTS.md, word for word, and point CLAUDE.md and every other runtime config at it.
  2. Write a plan before each feature, with one verification per step and a list of what the agent must not do to get green.
  3. Add one aggregate job like ci_gate that needs every other job, and make it the only required check on main.
  4. Give agents one local command that runs the same steps in the same order.
  5. Post the test-count change on every pull request, so a deleted test is as visible as a failing one.
  6. Run contract tests against a real preview of each pull request.
  7. When something breaks past the gate, put the new check in the same fix.

Böckeler's title asks whether TDD inside the agent loop is theater. On her evidence, mostly yes. Move the proof into a plan and a required gate, and TDD with coding agents is how I keep a large codebase correct with one human on it. If your team builds this way, or wants to, get in touch.

More writing

All posts