How to write robust unit tests with AI without falling into Mocking Hell
You have just finished writing a complex payment processing service that integrates with five different external APIs and handles dozens of edge cases. To save time and get home early, you highlight the entire file, open your AI assistant, and type: "Write unit tests for this code."
Within 10 seconds, the AI enthusiastically spits out 300 lines of perfect-looking Jest code. You eagerly run `npm run test`, and your terminal lights up with green checkmarks. The test suite passes flawlessly, and your code coverage is over 95%. You close your laptop feeling incredibly productive.
Two weeks later, you rename a private helper method or slightly refactor how a database query is formatted. The actual business logic and the end result remain exactly the same, but suddenly, 40 tests fail. Confused, you look closely at the AI-generated tests and realize the horror of what happened: the AI mocked absolutely every single external dependency, internal function, and return value. The test wasn't actually verifying if the payment succeeded; it was just blindly checking if `internalFunctionB` was called with a specific string. You haven't written tests; you've written a fragile script that freezes your implementation details.
1. Deep Dive: What is "Mocking Hell"?
This scenario is what developers call "Mocking Hell." Just as generating boilerplate code requires strict architectural constraints, handing off testing to an AI without setting strict behavioral boundaries will result in an unmaintainable disaster.
The Trap of Implementation Testing
The true purpose of a unit test is to give you confidence that when you refactor code, the feature still works. However, when you paste your entire completed implementation into an AI and say "test this," the AI does not understand your broader business context. Its primary goal becomes achieving 100% line coverage. To do this quickly, it reads your code line-by-line and mocks out every dependency it encounters.
You didn't write a test, you wrote a straightjacket
Tests that verify how code is written rather than what it actually accomplishes are useless. If an AI generates a test that checks if `mockDb.save()` was called exactly once, you can never change your database library without rewriting 100 tests. These brittle tests break on every minor refactor and eventually get ignored by the team.
2. Behavior vs. Implementation: The Clear Difference
To fix this, you must constrain the AI. You need to force it to test outcomes (behavior) and completely ignore internal steps (implementation).
| Test Style | AI Generated Code Characteristics | Result on Refactor |
|---|---|---|
| Implementation-aware (Bad) | expect(mockDb.save).toHaveBeenCalledWith({ status: 'PAID' }) |
Breaks immediately if you change the DB saving logic or switch to a new ORM. |
| Behavior-driven (Good) | const order = await service.getOrder(id); expect(order.status).toBe('PAID') |
Passes smoothly. The internal DB logic changed, but the observable outcome remains correct. |
3. Step-by-Step Guide: Prompting for Behavior-Driven Tests
So, how do we stop the AI from mocking everything and force it to write robust, behavior-driven tests? Follow this workflow.
Step 1: Hide the Implementation (The Blackbox Approach)
The golden rule of AI testing is: Do not put your entire implementation code into the prompt. If you paste 500 lines of complex logic, the AI will inevitably mimic it. Instead, you must treat your code as a black box. Provide the AI only with the public Interface (the method signatures) and a list of business requirements.
Step 2: Constrain with Strict Prompts
When asking the AI to generate tests, use a heavily constrained prompt structure like this:
// The Terrible Prompt:
"Write tests for this file: [Pastes 500 lines of complex logic with 5 injected dependencies]"
// The Professional Prompt:
"Write unit tests for a PaymentService.
It exposes only one public method: `processPayment(orderId: string, amount: number)`.
Rule 1: Do NOT mock the internal database layer. Use the in-memory SQLite database provided in our setup.
Rule 2: Do NOT test or verify internal private methods.
Rule 3: Test ONLY the following 3 business outcomes:
- Should throw an Error if the amount is negative.
- Should update the order status to PAID on success.
- Should return a valid transaction receipt ID string."
Step 3: Provide "Real Fakes" instead of Mocks
AI struggles to write good mocks from scratch because it doesn't know the exact shape of your domain data. Left to its own devices, it will spam `jest.fn()` everywhere, resulting in messy setup code. To prevent this, explicitly instruct the AI to use "Fakes" instead of mocks. (A Fake is a lightweight, working in-memory implementation of an interface, unlike a mock which just pretends to work).
4. Edge Cases and Troubleshooting
Q. But shouldn't I always mock external 3rd-party APIs like Stripe?
Yes, calling a real external network in a unit test is a bad idea. However, even in this case, you shouldn't let the AI mock the raw `axios.post` call. Instead, you should create a thin abstraction layer (e.g., a `PaymentGateway` interface). In your test environment, implement a `FakePaymentGateway` that returns predefined responses. In your prompt, explicitly state: "Do not mock the network calls. Inject the provided FakePaymentGateway to test the success and failure paths."
Action Step: Inject your Fakes into the Prompt Window
Before asking the AI to write tests, paste your `FakeEmailSender` or `InMemoryUserRepository` code directly into the context window. Tell the AI: "Do not use Jest/Mockito mocking functions. Inject these Fake classes into the service." The AI will generate incredibly clean, maintainable setup code.
Conclusion: Building a real safety net
Stop letting AI write tests that simply echo your implementation. By restricting the AI's view to public interfaces, hiding internal logic, and enforcing the use of Fakes, you transform its output. Instead of generating brittle scripts that break every time you touch a file, the AI will build a bulletproof safety net that actually protects your architecture.