Why AI Code Is So Hard to Test (And How to Fix It)
Why AI Code Is So Hard to Test (And How to Fix It)
The conversation usually goes the same way. A founder shows us an app their team built with Cursor or Lovable over a few weekends. It works. People are using it. Then they ask what sounds like a small question: "Can you add some tests before we launch the next feature?"
And the honest answer is almost never "sure, give us a day." It's "we can, but first we need to change how the code is put together."
That surprises people, so it's worth explaining properly. The problem with AI-generated code usually isn't that it's untested. It's that it's untestable — and those are two very different bills to pay.
Untested vs untestable
Untested code is code that could be verified, but nobody has got round to it. You write the tests, they pass, you move on. It's a chore, not a project.
Untestable code is code where there's nowhere to attach a test in the first place. You can't call the pricing logic on its own, because it only exists inside a click handler. You can't check the error path, because the only way to trigger it is to make the live API fail. You can't assert on the confirmation screen, because every element on it has a class name like css-1x9f3jd and no stable identity.
AI tools produce the second kind by default, and it isn't a bug in the tools. They're optimised to get you something that runs. Testability is a structural property you get from deliberate decisions — separating decisions from effects, keeping functions honest about their inputs — and none of those decisions make the demo appear any faster.
The four things that get in the way
Everything lives in one file
The classic AI output is a single component that fetches data, holds state, applies the business rules, formats the output and renders the markup. It reads fine. But there's no way to ask "does the discount calculation handle an expired voucher?" without mounting the whole component, faking a browser, and clicking through to the point where the question becomes relevant.
Every test you write against that shape is slow, brittle and mostly testing React rather than your business.
The logic is welded to the outside world
AI code calls the database from inside the component. It reads process.env in the middle of a function. It fires off an email and then works out whether it should have. There are no seams — no point where you can stand between your code and the thing it depends on and say "for this test, pretend the payment provider returned a decline."
This is the single biggest reason test suites on AI-built apps end up hitting real services, which means they're slow, flaky, and occasionally send real emails to real customers. We've seen it happen.
Nothing is deterministic
Look for new Date(), Math.random() and crypto.randomUUID() scattered through the logic and you'll usually find them. A test that passes today and fails on the first of the month isn't a test — it's a trap you've set for your future self. Same for anything that reads live data mid-calculation.
There's nothing for a browser to hold onto
End-to-end tools like Playwright drive the real UI, so they need something reliable to target. AI-generated markup tends to offer three bad options: auto-generated utility classes that change whenever the styling changes, text that changes whenever marketing changes, or deeply nested selectors that break when anyone touches the layout.
A test suite that fails for reasons unrelated to the bug you're looking for gets ignored within about a fortnight. Then it gets deleted. Flaky tests are worse than no tests, because they cost money and buy nothing.
What "making it testable" actually involves
Here's the part nobody puts in the sales pitch: this is refactoring work, and it takes real effort. Not months, usually, but not an afternoon either. Three changes do most of the heavy lifting.
1. Pull the rules out of the components
Anything that makes a decision — pricing, permissions, validation, eligibility, date maths — comes out into a plain function that takes arguments and returns a result. No fetching, no state, no rendering.
// Before: buried in a component, untestable in isolation
// After: a plain function you can call ten thousand times a second
export function calculateTotal({ items, voucher, now }) {
const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const valid = voucher && new Date(voucher.expiresAt) > now;
return valid ? subtotal * (1 - voucher.rate) : subtotal;
}
Note now being passed in rather than read from the system clock. That one habit removes an entire category of tests that fail at midnight.
2. Put a door on every dependency
Anything that talks to the outside world — the database client, the payment SDK, the email service — gets passed in rather than imported deep inside the logic. Then a test can hand over a fake that returns whatever scenario you want to check: the decline, the timeout, the malformed response the real API sends about once a week.
3. Give the UI stable handles
Add accessible roles and labels where they belong, and data-testid attributes on the handful of elements your critical flows depend on: the submit button, the error banner, the order total. It's a small, boring change that makes the difference between a Playwright suite you trust and one you mute.
Where unit tests earn their keep
Once the logic is extracted, unit tests become almost free. They run in milliseconds, so they run on every save, and they're precise: when one fails you know exactly which rule broke.
Point them at the things that would cost you money if they were wrong. Money maths, permission checks, anything with a deadline or a threshold, and every edge case a customer has already hit in production. Don't chase a coverage percentage — a suite that's 40% coverage on the parts that matter beats 90% coverage padded out with tests asserting that a heading renders.
Where Playwright earns its keep
Unit tests can't tell you that the checkout button is hidden behind a cookie banner on mobile, or that the redirect after login goes to a blank page in production. That's what end-to-end testing is for, and it's where AI-built apps break most often — because the failures live in the joins between pieces the AI generated separately.
You don't need many. Five to ten journeys that represent the paths you can't afford to lose:
- Sign up, verify, and land on the dashboard
- Log in, log out, and log back in on a fresh session
- Complete a payment and see the confirmation
- Submit the main form with valid data, then with rubbish data
- Whatever your top-earning flow happens to be
test('a new customer can complete checkout', async ({ page }) => {
await page.goto('/pricing');
await page.getByTestId('plan-pro-select').click();
await page.getByLabel('Email').fill('[email protected]');
await page.getByRole('button', { name: 'Continue to payment' }).click();
await expect(page.getByTestId('order-total')).toHaveText('£49.00');
});
Run them against a real build in CI before every deploy. The value isn't the day you write them — it's six months later, when someone asks an AI tool to "tidy up the checkout page" and the suite catches that the total silently stopped including VAT.
Can't the AI just write the tests?
Partly, and this is worth being precise about, because it's where teams lose weeks.
AI is genuinely good at writing tests once the seams exist. Give it a pure function and a clear description of the rules and it'll produce a decent set of cases, including some you'd have missed. That's a real productivity win and you should use it.
What it won't do is tell you the code needs restructuring first. Ask for tests on a tangled component and you'll get tests — usually ones that mock so much of the component that they only prove the mocks work. Worse, AI writes tests that assert the current behaviour, so if the bug is already in the code, you get a test that carefully locks the bug in place and goes green.
Tests are a specification of what the software should do. That judgement is still yours. The typing is the part you can delegate.
Where to start
If you're sitting on an AI-built app with no tests and a growing user base, don't try to fix everything. Do this instead:
- Write down your three critical journeys. The ones where failure means lost revenue or a support inbox on fire.
- Cover them with Playwright first. Even against messy code, E2E tests give you a safety net before you start refactoring — that's the point of them here.
- Extract the riskiest logic next. Usually pricing, permissions or anything touching customer data. Unit test it properly.
- Put both in CI. Tests that only run when someone remembers aren't tests.
- Then let AI help. Once the structure supports testing, generating additional cases is fast and genuinely useful.
None of this is glamorous, and none of it shows up in a demo. But it's the difference between an app you can keep changing and one everybody becomes quietly afraid to touch.
Need a hand with it?
Making an AI-built codebase testable is most of what we do at VibeGO. We'll audit what you've got, tell you honestly which parts need restructuring and which are fine, and get a suite in place that actually catches things — unit tests where they're cheap, Playwright where it counts, both running before every deploy.
Get in touch and tell us what you've built. We'll tell you what it would take to make it safe to change.
