Skip to content
0degrees.ai
Tooling

Test-Driven AI Coding: Closing the Red-Green Loop

How to feed failing tests directly to your AI coding assistant and use TDD as a feedback loop that keeps AI-generated code correct from the first run.

Josh 7 min read

Most developers I know use AI coding tools in one of two modes: ask the AI to write code, then maybe ask it to write some tests; or write the tests first, then write the code themselves. There’s a third mode that’s better than both, and it’s underused: write the tests first, then give the failing tests directly to the AI as the prompt.

This post is about that feedback loop — how to use TDD structure to make AI-generated code more reliable, how to prompt with test failures, and what to do when the AI games your tests instead of solving the real problem.

Why failing tests are better prompts than prose

When you ask an AI to implement a feature in plain English, the model has to make assumptions. What edge cases matter? What should it return when the input is empty? What’s the error behavior? Prose descriptions leave gaps, and models fill gaps optimistically — producing code that handles the happy path and silently ignores everything else.

A failing test is a different kind of specification. It’s executable, exact, and falsifiable. The model can’t argue with a failing assertion the way it can reinterpret an ambiguous requirement. The test says exactly what inputs to use and exactly what output is expected. That precision is worth more than any amount of description.

// Instead of: "Write a function that normalizes email addresses"
// Give the AI this:

import { normalizeEmail } from "./email";

test("lowercases the address", () => {
  expect(normalizeEmail("User@Example.COM")).toBe("user@example.com");
});

test("trims surrounding whitespace", () => {
  expect(normalizeEmail("  user@example.com  ")).toBe("user@example.com");
});

test("rejects addresses without an @ sign", () => {
  expect(() => normalizeEmail("notanemail")).toThrow("Invalid email");
});

test("handles subaddress plus-notation as valid", () => {
  expect(normalizeEmail("user+tag@example.com")).toBe("user+tag@example.com");
});

Run these. They fail — normalizeEmail doesn’t exist yet. Paste the test file and the failure output into your AI session. Ask for an implementation that makes them pass. The model now has a concrete contract, not an interpretation problem.

The prompt format that works

When you paste a failing test run, structure the prompt so the model knows the job:

Here are my tests. They all fail — the function doesn't exist yet.
Write an implementation that makes all four pass.
Don't modify the tests. Only create the implementation.

Test file: src/lib/email.test.ts
[paste test file]

Current test output:
[paste the failure output from npm test / jest / vitest]

The “don’t modify the tests” instruction is load-bearing. Without it, models will occasionally rewrite a test that’s hard to satisfy rather than implement the thing the test requires. You want the tests to stay fixed and the implementation to move.

The failure output matters too. If you just paste the test file without the actual error messages, the model has to infer what’s failing and why. The output tells it exactly where things stand.

Why I started doing this — Josh: I used to describe features in prose and then write tests to verify what the AI gave me. The tests kept catching things the description didn’t cover — edge cases I forgot to mention, error paths I’d assumed were obvious. Eventually I realized I was writing the tests anyway, just in the wrong order. Moving them to the front of the process changed the output quality noticeably. The first implementation now makes the tests pass more often than not, instead of being the starting point for a revision loop.

The red-green-refactor AI loop

Classic TDD is red (write a failing test), green (write minimum code to pass), refactor (clean up without breaking the tests). AI slots naturally into the “green” step:

  1. Red: Write your tests. Run them. Confirm they fail for the right reason — not because of a missing import or a typo, but because the logic doesn’t exist yet.
  2. Green (AI): Give the AI the failing tests and the failure output. Ask for a minimal implementation.
  3. Verify: Run the tests yourself. Don’t take the model’s word for it. If anything’s still red, paste the new failure output back and ask for a fix.
  4. Refactor: Once tests are green, ask the AI for a refactor pass — or do it yourself. Either way, the test suite catches regressions.

Step 3 is the one people skip. The AI says “this should work” and the temptation is to trust that. Run the tests. The model has no running environment; it generates plausible code, not verified code. Two minutes of running the suite catches what confident-sounding output hides. See Evaluating AI-Generated Code Before It Ships for more on why you can’t skip this.

When the AI games your tests

Occasionally the model will produce an implementation that passes the tests without solving the actual problem. This usually happens when the tests aren’t complete enough:

// This test suite can be gamed
test("returns discount price", () => {
  expect(applyDiscount(100, 0.1)).toBe(90);
});

// The AI could write this and all tests pass:
function applyDiscount(price: number, rate: number): number {
  return 90; // hardcoded
}

The fix is the same fix TDD practitioners have always used: write more tests. Add boundary cases. Add multiple inputs. If the AI’s implementation looks suspicious, add a parametrized test:

test.each([
  [100, 0.1, 90],
  [200, 0.25, 150],
  [50, 0.5, 25],
  [0, 0.1, 0],
])("applyDiscount(%d, %d) = %d", (price, rate, expected) => {
  expect(applyDiscount(price, rate)).toBe(expected);
});

A hardcoded return can’t survive a parametrized test. If you suspect the model produced a shortcut, throw more cases at it. Legitimate implementations welcome the extra tests; shortcut implementations break on them.

Using type errors as a test harness

In a TypeScript project, type errors are a second class of “failing test” that the AI can work against. When you have a function signature but no implementation, the compiler complains about mismatches. Those errors are exact and machine-readable, making them ideal AI fuel:

// You've defined the type contract
interface CartItem { id: string; price: number; quantity: number; }
interface CartSummary { subtotal: number; itemCount: number; }

// The stub exists but the implementation is wrong
function summarizeCart(items: CartItem[]): CartSummary {
  // TODO
  return {} as CartSummary; // TypeScript error: missing fields
}

Run tsc --noEmit and paste the error output along with the type definitions:

Implement summarizeCart so that TypeScript is satisfied and the logic is correct.

Types:
[paste CartItem and CartSummary interfaces]

Current tsc output:
[paste the type errors]

Also make this test pass:
[paste your test]

Combining type errors and failing tests gives the model two independent correctness signals. An implementation has to satisfy both — the type checker and the assertions — which narrows the space of valid outputs considerably.

Incremental tests for incremental implementation

One of the advantages of the AI-as-green-step approach is that you can grow the implementation incrementally, adding constraints as you go:

Round 1: Give the AI three basic tests. Get a working baseline.

Round 2: Add two edge-case tests. Paste just the new tests and the failure output — the implementation already exists, so the model is now patching, not rewriting.

Round 3: Add a performance or resource constraint if one exists — “should run in O(n) time” or “should not make more than one network call per invocation.” Write a test that exercises the constraint and let the model address it.

This is cheaper than handing the AI a 12-test spec upfront. The model handles three tests cleanly, where it sometimes cuts corners when given more. Each round is short, the feedback is immediate, and you can see exactly where complexity entered the implementation.

Property-based tests as specifications

For functions that transform or validate data, property-based tests are even more expressive specifications than example-based tests. Libraries like fast-check (TypeScript/JavaScript) or hypothesis (Python) generate inputs automatically:

import fc from "fast-check";
import { normalizeEmail } from "./email";

test("normalized email is always lowercase", () => {
  fc.assert(
    fc.property(fc.emailAddress(), (email) => {
      expect(normalizeEmail(email)).toBe(normalizeEmail(email).toLowerCase());
    })
  );
});

test("normalization is idempotent", () => {
  fc.assert(
    fc.property(fc.emailAddress(), (email) => {
      const once = normalizeEmail(email);
      const twice = normalizeEmail(once);
      expect(once).toBe(twice);
    })
  );
});

Pasting failing property tests into an AI session forces the model to think about invariants, not just examples. An implementation that cheats a fixed input set can’t satisfy “works for all valid email addresses.” The spec is now a constraint over the whole input space, not a list of specific cases to handle.

Connecting tests to the prompt cycle

The practical insight is simple: tests aren’t just for verification. They’re specifications precise enough for a machine to implement against — and that’s exactly what an AI coding assistant is.

Writing tests first isn’t about discipline for its own sake. It’s about giving the AI a contract instead of a description. The model works better against exact assertions than against prose requirements, and you get code that’s verified rather than plausible from the start.

This approach complements the context practices in Managing Context in Long AI Coding Sessions: when you’re deep in a feature, a suite of green tests is the clearest possible signal that the session is on track, and a new red test is the clearest possible signal of what to fix next.

The feedback loop — write test, run it, paste the failure, implement, verify — takes maybe ten minutes to internalize. After that it becomes the default.

[ Related ]

Keep reading