Quick Answer
AI-generated tests suffer from two compounding anti-patterns: over-mocking (replacing nearly every dependency with a fake so tests can't detect integration failures) and happy-path-only testing (writing tests for the one scenario where everything works perfectly, ignoring errors, edge cases, and unexpected inputs). Tests written this way pass confidently while the actual bugs hide in the paths that were never tested.
Why AI Tools Write Tests That Don't Catch Bugs
When you ask Cursor, Bolt, or Lovable to "add tests for this component," the AI optimises for tests that pass immediately without additional setup. The path of least resistance is to mock every external dependency (so no real database, API, or filesystem is needed) and to write a single test for the happy path (because that's the one described in the feature requirements).
The result is a test suite that achieves respectable line coverage - 70% or higher is achievable with this approach - but fails to catch the bugs that actually appear in production. According to a 2024 analysis by Testim.io, over 60% of production bugs occur in error-handling paths that were never covered by the test suite. These are precisely the paths AI tools skip.
The Google Testing Blog has documented this pattern extensively. Their research found that teams relying primarily on happy-path tests have a change failure rate 2-3x higher than teams with comprehensive error-path coverage, because regressions in error handling go undetected through the test suite and only surface when real users encounter real failures.
The Over-Mocking Anti-Pattern (TST-004)
Mocking is a legitimate tool - it's appropriate when you're testing a specific unit in isolation and want to control the behaviour of its dependencies. The problem is when mocking becomes reflexive: mock everything by default, without considering whether the interaction with the real dependency is exactly what should be tested.
// ❌ BAD - Over-mocking: every dependency is replaced, so nothing real is tested
import { vi, describe, it, expect } from 'vitest';
import { createOrder } from './orderService';
vi.mock('./database', () => ({
db: { insert: vi.fn().mockResolvedValue({ id: '123' }) }
}));
vi.mock('./emailService', () => ({
sendConfirmation: vi.fn().mockResolvedValue(true)
}));
vi.mock('./inventoryService', () => ({
checkStock: vi.fn().mockResolvedValue(true),
decrementStock: vi.fn().mockResolvedValue(true)
}));
vi.mock('./paymentService', () => ({
chargeCard: vi.fn().mockResolvedValue({ success: true, transactionId: 'tx_001' })
}));
describe('createOrder', () => {
it('creates an order', async () => {
const result = await createOrder({ items: [...], userId: '1', card: {...} });
expect(result.id).toBe('123'); // Tests only that the mock returned what we set up
});
});
This test cannot detect: database constraint violations, inventory race conditions, payment service error responses, email delivery failures, or any bug in the interactions between these services. The test is testing the mocks, not the code.
// ✅ GOOD - Strategic mocking: only mock the payment gateway (external, paid service)
// Use real implementations or lightweight fakes for internal services
import { describe, it, expect, vi } from 'vitest';
import { createOrder } from './orderService';
import { db } from './database'; // Real test database (or in-memory SQLite)
import { inventoryService } from './inventoryService'; // Real implementation
import * as paymentService from './paymentService';
// Only mock the external payment gateway to avoid real charges
vi.spyOn(paymentService, 'chargeCard').mockResolvedValue({
success: true,
transactionId: 'tx_test_001'
});
describe('createOrder', () => {
it('creates order and decrements inventory on success', async () => {
await db.seed({ products: [{ id: 'p1', stock: 5, price: 29.99 }] });
const result = await createOrder({ items: [{ productId: 'p1', qty: 2 }], userId: 'u1' });
expect(result.status).toBe('confirmed');
expect(await inventoryService.getStock('p1')).toBe(3); // Real decrement occurred
});
it('rolls back if payment fails', async () => {
vi.spyOn(paymentService, 'chargeCard').mockRejectedValue(new Error('Card declined'));
await db.seed({ products: [{ id: 'p1', stock: 5, price: 29.99 }] });
await expect(
createOrder({ items: [{ productId: 'p1', qty: 2 }], userId: 'u1' })
).rejects.toThrow('Card declined');
expect(await inventoryService.getStock('p1')).toBe(5); // Stock not decremented
});
});
The Happy-Path-Only Anti-Pattern (TST-005)
// ❌ BAD - Only the success case is tested
describe('getUserById', () => {
it('returns a user when found', async () => {
const user = await getUserById('user-123');
expect(user.name).toBe('Alice');
});
// Missing: user not found (404), database error, invalid ID format,
// deleted user, suspended user, insufficient permissions
});
// ✅ GOOD - Full range of cases including error paths
describe('getUserById', () => {
it('returns user data for a valid existing user', async () => {
const user = await getUserById('user-123');
expect(user).toMatchObject({ id: 'user-123', name: 'Alice', status: 'active' });
});
it('throws UserNotFoundError when ID does not exist', async () => {
await expect(getUserById('nonexistent-id')).rejects.toThrow(UserNotFoundError);
});
it('throws ValidationError for malformed IDs', async () => {
await expect(getUserById('')).rejects.toThrow('User ID cannot be empty');
await expect(getUserById(null as any)).rejects.toThrow(ValidationError);
});
it('returns null for soft-deleted users', async () => {
const result = await getUserById('deleted-user-456');
expect(result).toBeNull();
});
it('throws PermissionError when caller lacks read access', async () => {
await expect(
getUserById('private-user-789', { callerId: 'other-user' })
).rejects.toThrow(PermissionError);
});
});
The Testing Anti-Pattern Comparison
| Anti-Pattern | What AI Generates | What It Misses | Fix |
|---|---|---|---|
| Over-mocking (TST-004) | All dependencies mocked with hardcoded return values | Integration failures, real error responses | Mock only external/paid services; use real or in-memory implementations for internal deps |
| Happy path only (TST-005) | Single test for the success case | Error states, null inputs, boundary values, missing data | Add tests for every error path, edge case, and invalid input |
| Assertion-free tests (TST-008) | Test runs without throwing, no expect() calls | Everything - test always passes | Every test must have at least one meaningful assertion |
Assertion-Free Tests: The Silent Failure (TST-008)
A third pattern that AI tools produce is the assertion-free test - a test function that calls some code but never asserts anything about the result. These tests always pass because there is no condition that could cause them to fail:
// ❌ BAD - No assertions: this test always passes regardless of behaviour
it('processes the payment', async () => {
const result = await processPayment({ amount: 100, currency: 'USD' });
console.log(result); // Logging is not asserting
});
// ✅ GOOD - Meaningful assertions about actual outcomes
it('processes the payment and returns a transaction ID', async () => {
const result = await processPayment({ amount: 100, currency: 'USD' });
expect(result.success).toBe(true);
expect(result.transactionId).toMatch(/^tx_/);
expect(result.amount).toBe(100);
});
Vitest and Jest both support an assertion count guard: calling expect.hasAssertions() at the start of a test causes it to fail if no assertions ran. This catches both assertion-free tests and tests where the assertion is inside an async callback that never executed.
How to Improve Your AI-Generated Test Suite
- Audit your test files for over-mocking: Count the
vi.mock()andjest.mock()calls in each test file. If you're mocking more than 2-3 dependencies, ask whether each mock is truly necessary or whether a lighter-weight approach (spy, partial mock, or real implementation) would provide better coverage. - Add error-path tests for every happy-path test: For each test that verifies success behaviour, write at least one test that verifies error behaviour. What happens when the database is unavailable? When the user doesn't exist? When the input is invalid?
- Use expect.hasAssertions(): Add this call to the top of each test function to guarantee that assertion-free tests fail rather than silently pass.
- Run automated scanning: Tools like VibeDoctor (vibedoctor.io) automatically scan your codebase for over-mocked tests, happy-path-only patterns, and assertion-free test blocks (TST-004, TST-005, TST-008) and flag specific file paths and line numbers. Free to sign up.
- Add mutation testing: Tools like Stryker can automatically introduce small bugs into your code and check whether your tests catch them. If a test suite fails to catch mutations, it's a strong signal that coverage is superficial.
FAQ
When is mocking appropriate?
Mocking is appropriate for: external services you don't control (payment gateways, email providers, third-party APIs), services that are expensive or slow to run in tests (some databases, file systems on slow CI machines), services with side effects you don't want in tests (sending real emails, charging real cards), and when testing a specific unit's logic in isolation from its dependencies. Mocking internal application code - your own services, utilities, and modules - is almost always a sign of poor test design.
How many error-path tests should I have compared to happy-path tests?
A rough guide: for every function with 3-4 error conditions, you should have tests for each one. In practice, error-path tests often outnumber happy-path tests by 2:1 to 3:1 in well-tested business logic. The production bugs that frustrate users are overwhelmingly in error paths, edge cases, and boundary conditions - not in the carefully designed happy path that was considered during development.
What is the difference between a spy and a mock?
A mock replaces a function entirely with a fake implementation. A spy wraps the real function, allowing it to execute normally while recording calls for assertion. Spies are less invasive than mocks - vi.spyOn(module, 'functionName') lets the real function run unless you additionally call .mockImplementation(). For testing that a function was called with correct arguments without suppressing its real behaviour, a spy is almost always the better choice.
How do I test Next.js API routes?
Next.js App Router API routes (Route Handlers) can be tested by importing the handler function directly and passing mock Request objects. For integration-level testing, libraries like next-test-api-route-handler or simply running the Next.js dev server in tests and using fetch provide a more realistic test environment. The key is testing with real database connections (using a test database) rather than mocking all database calls.
Does TypeScript strict mode reduce the need for tests?
Somewhat, but not significantly. TypeScript's type system catches type-level mistakes at compile time - wrong argument types, null dereferences, missing properties. But it cannot catch logical errors, business rule violations, incorrect calculations, or runtime behaviour. Tests and TypeScript are complementary: TypeScript reduces the class of bugs that need testing, but does not replace the need for tests on behaviour.