jsmanifest logojsmanifest

5 Test Integrity Rules Every AI Agent Should Follow (Before They Break Your Tests)

5 Test Integrity Rules Every AI Agent Should Follow (Before They Break Your Tests)

Learn the 5 critical test integrity rules that prevent AI agents from creating self-validating tests. Includes TypeScript examples and research-backed best practices.

While I was reviewing some tests that Claude Code generated for me the other day, something made me pause. The tests were passing. The code coverage was great. Everything looked perfect on the surface.

Then I noticed it: the test was only testing itself, not the actual business logic.

The AI had created what I now call a "self-validating test"—a test that validates its own implementation rather than the requirements it's supposed to enforce. And here's the scary part: this pattern is becoming increasingly common as more developers rely on AI coding assistants to generate tests.

In this post, I'll walk you through 5 critical test integrity rules that every AI agent should follow when generating tests. These aren't just theoretical guidelines—they're hard-learned lessons from watching AI agents (and human developers) make the same mistakes over and over again.

By the end, you'll understand why test failures are signals, not obstacles, and how to teach your AI assistants to write tests that actually protect your codebase instead of giving you false confidence.

Quick Reference

Rule Key Question Red Flag
1. Requirements, Not Implementation "Does this test break if I refactor?" Test mirrors code logic
2. Never Change Tests Blindly "Did I investigate why this failed?" Test modified to pass without analysis
3. Meaningful Test Data "Would this catch a real bug?" Arbitrary values like 'x' or 'a@b.c'
4. Test Isolation "Can I run this test alone?" Shared variables between tests
5. Equal Scrutiny "Which is wrong: test or code?" Bias toward trusting one side

Let's explore each rule in detail...

Note: Examples in this post use Jest/Vitest syntax (describe, it, expect), but these principles apply to any testing framework—Mocha, Jasmine, AVA, Node's built-in test runner, etc. The concepts are universal.

How to Configure AI Agents to Follow These Rules

Before we dive into the rules, here's how to actually enforce them when using AI coding assistants:

Claude Code / Cursor / GitHub Copilot:

Add this to your project instructions, .cursorrules file, or .claude/CLAUDE.md:

# Test Integrity Rules (Non-Negotiable)
 
When generating or modifying tests:
1. Tests must assert business requirements, not implementation details
2. Never modify existing tests without investigating the requirement first
3. Use realistic test data that represents actual production scenarios
4. Ensure each test can run independently with no shared state
5. When tests fail, investigate both test and code with equal scrutiny
 
If a test fails, respond with: "Test failure detected. Investigating..."
Then analyze: (1) test assertion, (2) business requirement, (3) implementation

Why this matters: Without explicit instructions, AI agents default to "make tests pass" mode. This configuration shifts them to "investigate and validate" mode.

Now let's dive into each rule with concrete examples.

Rule 1: Tests Are Requirements, Not Implementation Details

Here's what makes this so critical: tests define how code SHOULD behave, not how it currently DOES behave.

When you let an AI agent write tests that simply mirror the implementation, you create a dangerous illusion of correctness. The tests will always pass because they're testing the code against itself—not against the actual business requirements.

Let me show you what I mean:

// ❌ BAD: Self-validating test
describe('calculateOrderTotal', () => {
  it('should calculate total', () => {
    const items = [{ price: 10 }, { price: 20 }]
    const result = calculateOrderTotal(items)
 
    // This just tests what the function currently returns!
    expect(result).toBe(items.reduce((sum, item) => sum + item.price, 0))
  })
})

This test is useless. It's testing the implementation against itself. If the business requirement changes—say, we need to add tax or apply discounts—this test tells us nothing about whether our code meets those requirements.

Here's the better approach:

// ✅ GOOD: Tests business requirements
describe('calculateOrderTotal', () => {
  it('should sum all item prices to calculate order total', () => {
    const items = [{ price: 10.99 }, { price: 20.50 }]
    const result = calculateOrderTotal(items)
 
    // Tests the actual requirement: sum should be 31.49
    expect(result).toBe(31.49)
  })
 
  it('should apply 10% discount for orders over $100', () => {
    const items = [{ price: 60 }, { price: 50 }]
    const result = calculateOrderTotal(items)
 
    // Requirement: $110 - 10% = $99
    expect(result).toBe(99)
  })
 
  it('should add 8% sales tax to the total', () => {
    const items = [{ price: 100 }]
    const result = calculateOrderTotal(items)
 
    // $100 + 8% tax = $108
    expect(result).toBe(108)
  })
})

The key difference? The second version tests concrete business requirements: discounts, tax calculations, and specific dollar amounts. If you change the implementation (say, from .reduce() to a for loop), these tests still verify the same business logic.

According to Martin Fowler's Testing Guide, tests should serve as executable specifications of your requirements. When tests are written as implementation mirrors, they fail at this fundamental purpose.

Quick check: If you can change your implementation and the test still passes with the new behavior, your test is testing requirements. If the test breaks when you change implementation details (like switching from .reduce() to a for loop), it's testing implementation.

When Implementation Details Actually Matter

Exception to the rule: Public APIs and interfaces that other code depends on.

// ✅ VALID: Testing public API shape (this IS the requirement)
describe('UserService API', () => {
  it('should expose required methods', () => {
    expect(typeof UserService.create).toBe('function')
    expect(typeof UserService.update).toBe('function')
    expect(typeof UserService.delete).toBe('function')
  })
 
  it('should return user object with expected shape', async () => {
    const user = await UserService.create({ email: 'test@example.com' })
 
    expect(user).toMatchObject({
      id: expect.any(String),
      email: expect.any(String),
      createdAt: expect.any(Date)
    })
  })
})

When the API contract itself is the business requirement (other services depend on it), testing the shape is valid. The difference? You're testing the interface contract, not how the internals work.

Rule 2: Never Change Tests to Make Them Pass (Without Investigation)

This is the cardinal rule—the one that separates trustworthy test suites from garbage collections of passing assertions.

When a test fails, it's trying to tell you something: "The code doesn't match the expected behavior." It does NOT mean: "The test is wrong, let me fix it."

I cannot stress this enough—AI agents (and honestly, many human developers) fall into this trap constantly. A test fails, they assume the test is outdated, change it to match the current code behavior, and move on. This is test fraud.

Let me show you a real scenario that happened in one of my projects:

// Original test (written based on security requirements)
describe('User.canAccessAdminPanel', () => {
  it('should return false for regular users', () => {
    const user = new User({ role: 'user' })
    expect(user.canAccessAdminPanel()).toBe(false)
  })
})
 
// The implementation (has a bug!)
class User {
  canAccessAdminPanel(): boolean {
    // BUG: This allows ALL users with any role!
    return this.role !== undefined
  }
}

The test fails. Here's what the AI agent tried to do:

// ❌ WRONG: AI changed the test to match broken code
it('should return true for users with any role', () => {
  const user = new User({ role: 'user' })
  expect(user.canAccessAdminPanel()).toBe(true) // Security hole!
})

Do you see the problem? The AI just introduced a security vulnerability by changing the test to match buggy code. The correct fix is to investigate and repair the implementation:

// ✅ CORRECT: Fix the code to match the requirement
class User {
  canAccessAdminPanel(): boolean {
    return this.role === 'admin' // Only admins can access
  }
}

The investigation checklist when tests fail:

  1. What is this test asserting? Understand the exact behavior being checked
  2. What's the business requirement? Check docs, product specs, git history
  3. Does the implementation match the requirement? Review the actual code
  4. Which is wrong: the test or the code? Make an evidence-based decision
  5. Fix based on evidence - with a clear explanation of why

Kent Beck's Canon TDD emphasizes the red-green-refactor cycle: when tests go red, you investigate why they're red before making them green. You don't just change the assertion to make it pass.

Real-World Cost of Ignoring This Rule

Let me tell you about a production incident that still keeps me up at night.

In 2024, a production bug at one of my client's e-commerce sites cost them $47,000 in lost revenue over a single weekend. The cause? An AI agent changed a test assertion to match buggy discount logic that gave users 100% off instead of 10% off.

The original test was correct:

describe('applyPromoCode', () => {
  it('should apply 10% discount for WELCOME10 code', () => {
    const total = applyPromoCode(100, 'WELCOME10')
    expect(total).toBe(90) // $100 - 10% = $90
  })
})

But when the implementation had a typo (discount = 1.0 instead of 0.1), the AI changed the test to match:

it('should apply 10% discount for WELCOME10 code', () => {
  const total = applyPromoCode(100, 'WELCOME10')
  expect(total).toBe(0) // AI changed this to match broken code!
})

The test passed. The code deployed. The discount code went viral on social media. Customers got free products. The bug ran for 72 hours before someone noticed.

Investigation would have taken 5 minutes. The bug cost $47,000.

This is why we investigate test failures—not change tests to make them pass.

Developer debugging code and investigating test failures

Real-world impact: In production codebases, blindly changing tests to pass has led to security vulnerabilities, data corruption bugs, and millions in lost revenue. According to recent research, 76% of developers are in the "red zone" with AI-generated code, experiencing frequent hallucinations and low confidence in shipping without human review.

When you treat test failures as investigation triggers rather than inconveniences, you catch real bugs before they reach production.

Rule 3: Test Data Should Be Independent and Meaningful

Have you ever seen tests like this?

// ❌ BAD: Meaningless test data
describe('validateEmail', () => {
  it('should validate email', () => {
    expect(validateEmail('a@b.c')).toBe(true)
    expect(validateEmail('x')).toBe(false)
  })
})

The test passes, but what does it actually tell you? Nothing about real-world email validation. The data is arbitrary: 'a@b.c' and 'x' don't represent actual user emails.

When AI agents generate tests with meaningless data, they create a false sense of coverage. The tests pass, but they don't validate real scenarios.

Here's the right approach:

// ✅ GOOD: Realistic test data from actual use cases
describe('validateEmail', () => {
  it('should accept valid email formats', () => {
    const validEmails = [
      'user@example.com',
      'john.doe@company.co.uk',
      'support+tag@service.io',
      'developer_123@tech-startup.com'
    ]
 
    validEmails.forEach(email => {
      expect(validateEmail(email)).toBe(true)
    })
  })
 
  it('should reject emails without @ symbol', () => {
    expect(validateEmail('notanemail.com')).toBe(false)
    expect(validateEmail('user.example.com')).toBe(false)
  })
 
  it('should reject emails with spaces', () => {
    expect(validateEmail('user @example.com')).toBe(false)
    expect(validateEmail('user@exam ple.com')).toBe(false)
  })
 
  it('should reject emails with invalid domains', () => {
    expect(validateEmail('user@')).toBe(false)
    expect(validateEmail('user@.com')).toBe(false)
  })
})

The difference is night and day. The second version uses realistic email addresses that you'd actually encounter in production. Each test case represents a real scenario: valid formats, missing @ symbols, spaces in emails, invalid domains.

This pattern is called data-driven testing, and it's incredibly powerful. Let me show you a real example from this very codebase:

// From packages/post/src/__tests__/regex.test.ts
// ✅ EXCELLENT: Data-driven tests with clear expectations
describe('regex file image matching', () => {
  const tests = [
    ['thumbnail.png', true],
    ['apple.jpg', true],
    ['apple thumbnail orange.jpg', true],
    ['.jpg', true],
    ['thumbnail', false], // No extension
    [' .jpg ', false], // Spaces around filename
  ] as const
 
  for (const [value, expectedResult] of tests) {
    it(`should return ${expectedResult} for "${value}"`, () => {
      expect(regex.file.image.test(value)).toBe(expectedResult)
    })
  }
})

This is beautiful. Each test case is explicit, meaningful, and documents edge cases. The test names are generated automatically to be descriptive. You can scan this and immediately understand what the regex should and shouldn't match.

According to the Software Testing Anti-patterns blog, using meaningless test data is one of the most common anti-patterns that leads to brittle, hard-to-maintain test suites.

The takeaway? Always use realistic, meaningful test data that represents actual scenarios your code will encounter in production. AI agents should pull from real-world examples, not generate arbitrary strings like 'x' or 'a@b.c'.

Rule 4: Tests Should Be Isolated (No Shared State, No Side Effects)

Here's a test pattern I've seen AI agents generate way too often:

// ❌ BAD: Tests depend on execution order and shared state
describe('UserService', () => {
  let userId: string
 
  it('should create a user', async () => {
    const user = await UserService.create({ email: 'test@example.com' })
    userId = user.id // Shared state!
  })
 
  it('should update the user', async () => {
    // Depends on previous test setting userId!
    await UserService.update(userId, { name: 'John' })
    const updated = await UserService.get(userId)
    expect(updated.name).toBe('John')
  })
 
  it('should delete the user', async () => {
    // Also depends on userId from first test!
    await UserService.delete(userId)
    expect(await UserService.get(userId)).toBeNull()
  })
})

This is a ticking time bomb. The tests work now, but here's what happens:

  1. Someone adds a .only() to the second test to debug it
  2. The test fails because userId is undefined (first test never ran)
  3. Someone changes test execution order
  4. Everything breaks

Tests should be completely independent. Each test should run in isolation, regardless of execution order. If you shuffle your tests randomly, they should all still pass.

Here's the correct pattern:

// ✅ GOOD: Each test is completely independent
describe('UserService', () => {
  beforeEach(async () => {
    await database.clear() // Clean slate for each test
  })
 
  it('should create a user with email', async () => {
    const user = await UserService.create({ email: 'test@example.com' })
 
    expect(user).toMatchObject({
      email: 'test@example.com',
      id: expect.any(String)
    })
  })
 
  it('should update user name', async () => {
    // Setup is explicit and contained within this test
    const user = await UserService.create({ email: 'test@example.com' })
 
    const updated = await UserService.update(user.id, { name: 'John' })
 
    expect(updated.name).toBe('John')
    expect(updated.email).toBe('test@example.com')
  })
 
  it('should delete existing users', async () => {
    // Each test creates its own test data
    const user = await UserService.create({ email: 'test@example.com' })
 
    await UserService.delete(user.id)
 
    expect(await UserService.get(user.id)).toBeNull()
  })
})

Notice how each test:

  1. Sets up its own data - no shared variables
  2. Has a clean database - beforeEach clears state
  3. Can run independently - doesn't rely on other tests
  4. Cleans up after itself - database cleared before next test

This pattern comes from this codebase's own test suite:

// From packages/post/src/__tests__/FilepathGroups.test.ts
// ✅ EXCELLENT: Proper cleanup of mock filesystem
afterEach(() => {
  mfs.restore() // Clean up after EACH test
})
 
describe('FilepathGroups', () => {
  it('should load file groups separately', () => {
    const fpGroups = new FilepathGroups()
    const result = fpGroups
      .register('thumbnail', (p) => p.includes('thumbnail'))
      .register('images', (p) => !p.includes('thumbnail'))
      .load({ filenames: ['thumb.jpg', 'img1.jpg', 'img2.jpg'] })
 
    // Each test gets its own instance, no shared state
    expect(result.groups.thumbnail.length).toBe(1)
    expect(result.groups.images.length).toBe(2)
  })
})

Perfect. The afterEach ensures the mock file system is restored after every test, preventing state leakage.

Martin Fowler's Practical Test Pyramid emphasizes test independence as a core principle. When tests share state, you get flaky tests—tests that pass sometimes and fail other times, destroying trust in your test suite.

The rule: Every test should be completely independent. If you can't run a single test in isolation and have it pass, you have a test integrity problem.

Rule 5: When Tests Fail, Treat Code and Tests as Equals

This is where everything comes together. When a test fails, you face a fundamental question: Is the test wrong, or is the code wrong?

Most developers (and AI agents) have a bias. They trust either the tests or the code more. But here's the truth: both tests and code are written by humans (or AI), and both can be wrong.

Failed tests indicate: "Code doesn't match expected behavior." They don't tell you which side has the bug—you need to investigate with equal scrutiny.

Let me walk you through a decision tree for handling test failures:

// Scenario A: Code changed
describe('calculateDiscount', () => {
  it('should apply 20% discount for VIP customers', () => {
    const price = calculateDiscount(100, 'VIP')
    expect(price).toBe(80) // Fails! Returns 90 instead
  })
})
 
// Investigation steps:
// 1. Review git history: What changed in the code?
//    Answer: Someone changed VIP discount from 20% to 10%
// 2. Check requirements: Is 20% still correct?
//    Answer: Yes, VIP should be 20% per product spec
// 3. Decision: Fix the code (it regressed)
 
function calculateDiscount(price: number, tier: string): number {
  if (tier === 'VIP') {
    return price * 0.8 // Restored to 20% off
  }
  return price
}

Now a different scenario:

// Scenario B: Requirements changed
describe('calculateDiscount', () => {
  it('should apply 20% discount for VIP customers', () => {
    const price = calculateDiscount(100, 'VIP')
    expect(price).toBe(80) // Fails! Returns 90
  })
})
 
// Investigation steps:
// 1. Review git history: Code intentionally changed to 10%
// 2. Check requirements: Did business change VIP discount?
//    Answer: Yes, new policy effective Jan 2026 reduced to 10%
// 3. Decision: Update the test (requirements changed legitimately)
 
it('should apply 10% discount for VIP customers (updated Jan 2026)', () => {
  const price = calculateDiscount(100, 'VIP')
  expect(price).toBe(90) // Updated to match new business rule
})

And here's the anti-pattern that AI agents love to use:

// ❌ HORRIBLE: AI agent's approach to test failures
describe('user validation', () => {
  it('should validate user age', () => {
    const user = { age: 15 }
    // @ts-expect-error AI added this to make test pass
    expect(validateAge(user)).toBe(false)
  })
})

The AI encountered a TypeScript error (maybe validateAge returns a different type than expected) and added @ts-expect-error to silence it. This is the testing equivalent of sweeping dirt under the rug.

The correct approach:

// ✅ CORRECT: Investigate root cause and fix
function validateAge(user: User): boolean {
  return user.age >= 18 // Returns boolean, as required by tests
}

According to Qodo's 2025 State of AI Code Quality report, 76% of developers are in the "red zone" with AI-generated code, experiencing frequent hallucinations and low confidence. This happens because AI agents don't investigate failures—they just patch over them.

Quality assurance testing and systematic verification process

The principle: When tests fail, investigate both sides with equal skepticism:

  • Are tests wrong? Check git history, verify original intent
  • Is code wrong? Review implementation against requirements
  • Are requirements ambiguous? Ask for clarification, don't guess

Once you start treating tests and code as equals, you catch real bugs instead of papering over them.

Red Flags: How to Spot Test Integrity Violations in Code Reviews

When reviewing AI-generated tests (or human-written ones), watch for these warning signs:

🚩 Red Flag #1: Tests that mirror the implementation

// Warning: Test contains the same logic as the code!
describe('sumArray', () => {
  it('should sum numbers', () => {
    const numbers = [1, 2, 3]
    expect(sumArray(numbers)).toBe(numbers.reduce((a, b) => a + b, 0))
  })
})

🚩 Red Flag #2: Arbitrary test data

// Warning: Meaningless values
describe('validateEmail', () => {
  it('validates', () => {
    expect(validateEmail('a@b.c')).toBe(true)
    expect(validateEmail('x')).toBe(false)
  })
})

🚩 Red Flag #3: Shared state between tests

// Warning: Variable shared across tests
describe('UserService', () => {
  let userId: string // Shared state!
 
  it('creates user', async () => {
    const user = await UserService.create({})
    userId = user.id
  })
 
  it('updates user', async () => {
    await UserService.update(userId, {}) // Depends on previous test
  })
})

🚩 Red Flag #4: Error suppressions

// Warning: Hiding type errors instead of fixing them
describe('validateAge', () => {
  it('validates age', () => {
    // @ts-expect-error
    expect(validateAge(user)).toBe(false)
  })
})

🚩 Red Flag #5: Tests changed in the same commit as implementation

# Warning: Test and code both modified - investigate carefully
git diff HEAD~1 --stat
+ src/calculateTotal.ts           | 10 ++--
+ src/__tests__/calculateTotal.test.ts | 8 ++--

When you see tests and implementation modified together, it's a yellow flag. Ask: "Why did the test need to change? Was it a requirement change or did we make the test match buggy code?"

Code review checklist:

  • Are test values realistic or arbitrary?
  • Do tests assert requirements or implementation?
  • Can tests run in any order?
  • Are test modifications justified in commit messages?
  • Are there any error suppressions (@ts-expect-error, @ts-ignore)?

AI Tool Configuration Reference

Here's how to apply these rules across different AI coding assistants:

Tool Configuration Location How to Apply These Rules
Claude Code .claude/CLAUDE.md or project instructions Add test integrity rules as project guidelines
GitHub Copilot .github/copilot-instructions.md Reference this post in instructions
Cursor .cursorrules file Add rules as system-level requirements
Codeium Workspace settings Configure in "Instructions for Codeium"
Windsurf .windsurfrules file Add as project-level rules

Example configuration snippet for any tool:

# Test Integrity Rules (Non-Negotiable)
 
When generating or modifying tests:
- Tests assert REQUIREMENTS, not implementation details
- NEVER change existing tests without investigating the failure
- Use realistic, meaningful test data
- Ensure test isolation (no shared state)
- Treat test failures as investigation triggers
 
Reference: https://jsmanifest.com/posts/5-test-integrity-rules-ai-agents-typescript

Pro tip: Copy this configuration into your project today. It takes 2 minutes and prevents hours of debugging production issues.

Conclusion

And that concludes the end of this post! Let's recap what we covered:

The 5 Test Integrity Rules:

  1. Tests are requirements, not implementation details - Assert business logic, not code mechanics
  2. Never change tests to make them pass (without investigation) - Test failures are signals
  3. Test data should be independent and meaningful - Use realistic scenarios, not arbitrary values
  4. Tests should be isolated (no shared state) - Each test runs independently
  5. When tests fail, treat code and tests as equals - Investigate both sides with equal scrutiny

The bigger picture? AI agents are incredibly powerful tools for generating tests, but they need these guard rails just like human developers. The difference is that humans can apply judgment and context—AI agents need explicit rules.

According to the research I referenced, 76% of developers are in the "red zone" with AI-generated code, experiencing frequent hallucinations and low confidence in shipping without human review. By applying these 5 test integrity rules, you can move out of that red zone and start trusting your AI-generated tests.

Take Action Today

Don't just read and forget. Here's your 3-step action plan:

Step 1: Audit Your Next PR

Review your next pull request using this checklist before merging:

  • ✅ Do tests assert business requirements (not implementation)?
  • ✅ Are test failures investigated before changing tests?
  • ✅ Is test data realistic and meaningful?
  • ✅ Can tests run in any order without breaking?
  • ✅ Are code and tests treated with equal scrutiny?

Step 2: Configure Your AI Tool Today

Copy the configuration snippet from the "AI Tool Configuration Reference" section above and add it to your project. It takes 2 minutes.

Step 3: Share with Your Team

Send this post to your team and discuss adopting these practices in your next code review session. Make test integrity a team standard, not just a personal practice.

The Bottom Line:

According to the research I referenced, 76% of developers are in the "red zone" with AI-generated code. By applying these 5 test integrity rules consistently, you can move out of that red zone and start trusting your AI-generated tests.

One production bug from a bad test costs far more than 5 minutes of investigation. Make investigation your default response to test failures.

I hope you found this valuable and look out for more in the future!

Frequently Asked Questions

Q: Should I apply these rules to AI-generated code only, or human-written tests too?

A: Both! These principles apply universally. AI agents just tend to make these mistakes more frequently and consistently, which is why they need explicit guardrails. But human developers fall into the same traps—especially when under deadline pressure.

Q: What if investigating every test failure slows down development?

A: Short-term slowdown, long-term speedup. Think about it: investigating a test failure takes 5-10 minutes. Debugging a production bug that escaped because you changed a test without investigating? That's hours or days, plus potential revenue loss and customer trust damage. The math is clear.

Q: Can I trust AI agents to write tests at all?

A: Yes, with proper guardrails. AI agents are excellent at generating test boilerplate, covering edge cases you might forget, and maintaining consistent test patterns. The key is configuring them with these rules upfront. Think of it like pair programming—the AI generates, you review with these rules in mind.

Q: What about legacy codebases with existing bad tests?

A: Refactor incrementally using the "Boy Scout Rule"—leave the code better than you found it. Apply these rules to all new tests starting today. When you touch an old test file, improve one or two tests while you're there. Over time, your test suite quality improves without requiring a massive refactoring project.

Q: How do I convince my team to adopt these practices?

A: Start with data. Share the $47,000 discount bug story from this post. Then propose a 2-week experiment: apply these rules to one project and measure the results. Track: (1) time spent investigating test failures, (2) bugs caught before production, (3) false positives from bad tests. The data will speak for itself.


Sources:

Image Attributions: