How to Write Unit Tests that AI Can Actually Fix

Hướng dẫn chi tiết về How to Write Unit Tests that AI Can Actually Fix trong Vibe Coding dành cho None.

How to Write Unit Tests that AI Can Actually Fix

In the era of Vibe Coding, the bottleneck of software development has shifted. We are no longer constrained by how fast we can type syntax, but by how effectively our AI agents can navigate the logic we’ve established. When you prompt an agent to “refactor the payment logic” or “add a new field to the user profile,” the agent relies on your existing test suite as its primary source of truth.

However, there is a recurring nightmare in this workflow: the AI gets stuck. You watch the terminal as your agent tries to fix a failing test, fails, retries with a slightly different approach, fails again, and eventually throws its hands up in a loop of “I apologize, I am having trouble reconciling the test expectations with the implementation.”

The problem isn’t usually the AI’s reasoning; it’s the semantic distance of your tests. Most unit tests are written for humans to skim, but they are not architected for an LLM to debug. If your tests are brittle, over-mocked, or cryptically named, you are essentially giving your AI agent a map with no legend.

This article explores the advanced patterns required to write “AI-Fixable” unit tests—tests that serve not just as guardrails, but as high-signal prompts that guide an agent toward a successful fix in a single turn.


Core Concepts: The Mechanics of AI Reasoning

To write tests an AI can fix, we must first understand how an agent “sees” a test failure. When a test fails during an autonomous implementation plan, the agent typically follows this cycle:

  1. Ingestion: The agent reads the test file and the corresponding implementation file.
  2. Error Analysis: It captures the stdout/stderr of the test runner (e.g., Vitest, Jest, Pytest).
  3. Hypothesis: It compares the “Expected” vs “Actual” values and looks at the stack trace to find the line of failure.
  4. Action: It modifies the code to align “Actual” with “Expected.”

The “Fixability” of a test depends on how much noise is present in this cycle.

1. The Error Message is the Prompt

In Vibe Coding, the error message generated by your test runner is literally a prompt fragment. If your test says expect(result).toBe(true), and it fails with Expected: true, Received: false, the AI has zero context on why it should have been true. It might try to fix the logic by simply hardcoding a return true.

An AI-fixable test uses descriptive assertions: expect(user.hasAccess).toBe(true, "Users with 'admin' role should have access to the dashboard"). Now, the error message becomes: AssertionError: Users with 'admin' role should have access to the dashboard. Expected: true, Received: false. This gives the AI the exact business rule it needs to implement.

2. The Context Window Constraint

AI agents have a finite context window. If a unit test requires reading 15 different helper files, three mock factories, and a global setup script just to understand what is being tested, the agent’s reasoning capabilities will degrade. AI-fixable tests prioritize Locality of Reference. Everything required to understand the test should be visible within the test function or the immediate file.

3. Intent vs. Implementation

Brittle tests check how a function works (e.g., “did it call this specific internal method?”). Robust, AI-fixable tests check what the function produced. If an AI refactors a function and changes the internal method calls but the output remains correct, a “how” test will fail, confusing the AI. It will think it broke the feature when it actually improved the code. Always test the public contract, not the private mechanics.


Practical Example: From Brittle to AI-Fixable

Let’s look at a common scenario: a service that calculates discounts for an e-commerce platform.

The “Bad” Test (Human-Readable, AI-Confusing)

test('calc disc', () => {
  const m = { getItems: () => [{ price: 100 }] };
  const s = new DiscountService(m as any);
  expect(s.calculate(10)).toBe(90);
});

Why the AI fails to fix this:

  • Cryptic Names: m, s, calc disc provide no semantic clues.
  • Hidden Logic: What does 10 represent? Is it a percentage? A flat amount? A customer ID?
  • Ambiguity: If the AI changes the calculate signature to accept a Coupon object, it has no idea how to update this test because the intent is obscured.

The “Good” Test (AI-Fixable)

describe('DiscountService.calculatePercentageDiscount', () => {
  it('should reduce the total price by the given percentage for a single item', () => {
    // Arrange
    const mockCartRepository = { 
      getCartItems: () => [{ id: 'item_1', price: 100, quantity: 1 }] 
    };
    const service = new DiscountService(mockCartRepository);
    const discountPercent = 10;
    const expectedTotal = 90;

    // Act
    const actualTotal = service.calculate(discountPercent);

    // Assert
    expect(actualTotal).toBe(expectedTotal, 
      `Calculation failed: A ${discountPercent}% discount on $100 should result in $${expectedTotal}`
    );
  });
});

How this solves the Vibe Coding problem: If the AI agent introduces a bug that results in $95, the error log will read: Calculation failed: A 10% discount on $100 should result in $90. Received: 95. The AI immediately realizes: “Ah, the math logic is off,” rather than “I don’t know what these numbers mean.”


Best Practices & Tips

1. The AAA Pattern is Mandatory

AI agents are trained on structured data. The Arrange-Act-Assert (AAA) pattern is the “Standard Grammar” of testing. When you clearly separate these phases with comments, you allow the AI to “hook” into the specific stage of the test.

  • Arrange: Setup data.
  • Act: Execute the function.
  • Assert: Check results.

2. Use Semantic Factories, Not Manual Mocks

Manual object mocking (as any or { ... }) is a nightmare for AI agents because it lacks type safety and context. Use factory functions that describe the state.

  • Bad: const user = { id: 1, role: 'admin' };
  • Good: const user = createAdminUser({ withExpiredSubscription: false }); The name of the factory function tells the AI exactly what kind of entity it is dealing with.

3. Avoid “Magic Numbers”

Never use literals like 42, true, or 100 without assigning them to a named variable. const MINIMUM_AGE_FOR_PURCHASE = 18; is a prompt. 18 is just a number. When the AI sees the variable name, it understands the business constraint.

4. Deterministic Mocking (The “Mocking Hell” Filter)

AI agents often struggle with complex nested mocks (e.g., mocking a database driver that mocks a socket). If your test requires more than two levels of mocking, it’s likely too complex for an agent to fix reliably. Tip: Use “In-Memory” versions of your dependencies instead of deep mocks. An in-memory SQLite database is much easier for an AI to reason about than a mocked Prisma client with 50 chained methods.

5. Snapshot Testing: The AI’s Best Friend and Worst Enemy

Snapshots are great for detecting regressions in UI or large data structures, but they are “opaque” to AI. If an AI changes a component and the snapshot fails, the AI will often just “update the snapshot” without checking if the change was actually correct. Fix: Use Selective Snapshots. Instead of snapshotting a whole page, snapshot a specific piece of state: expect(summary).toMatchSnapshot("Order summary should include tax and shipping").

6. Provide “Negative” Test Cases

AI is naturally biased toward success (the “Happy Path”). To make an AI fix logic properly, you must provide tests for failure states:

  • “Should throw an error if the credit card is expired.”
  • “Should return an empty list if no results are found.” These negative tests prevent the AI from “over-fixing” a bug by creating a solution that works for the happy path but breaks the error handling.

Advanced Technique: Self-Documenting Test Suites

One of the most powerful things you can do for an AI agent is to include a README-testing.md or a DESIGN.md in your project that explains your testing philosophy. AI agents like Gemini CLI can read your project’s context.

Tell the agent:

“In this project, we use the AAA pattern. If a test fails, prioritize checking the ‘Expected’ message in the assertion. Do not mock the database; use the mock-db utility. All currency calculations must be tested with decimal.js logic.”

By codifying your “Vibe” into a markdown file, you provide a high-level heuristic that the AI uses to filter its own hypotheses when a test fails.


Conclusion: Tests as the Language of Collaboration

In Vibe Coding, we are moving away from the era where “the code is the documentation.” We are entering an era where the tests are the requirements.

Writing unit tests that an AI can fix requires a shift in perspective. You are no longer writing tests just to prove to yourself that the code works. You are writing a specification for an autonomous agent. By reducing semantic distance, using descriptive assertions, and maintaining strict structural patterns like AAA, you transform your test suite from a collection of hurdles into a sophisticated navigation system.

When your tests are AI-fixable, your development velocity doesn’t just increase—it compounds. You can issue a high-level directive, walk away, and return to a codebase where the logic has been updated, the tests are passing, and the integrity of the system is verified by a suite of clear, actionable, and intelligent “guard-prompts.”

The next time you write a test, ask yourself: “If I gave this to a junior developer who only had 30 seconds to look at it, could they fix the code?” If the answer is no, your AI agent won’t be able to either. Write for intent, and the AI will handle the implementation.