Why Unit Testing Matters More Than Ever With AI
Let's be honest: most of us have shipped code after clicking around for a minute and thinking, "yep, looks fine." I've done it more times than I'd like to admit. And most of the time it is fine. Until it isn't, usually at 2am, usually in a part of the app you didn't touch.
That's the whole case for unit tests in one sentence. They check the stuff you forgot to check.
Manual Review Is Great, But It Doesn't Scale
I'm not knocking manual review. Reading your own diff, clicking through the UI and having a teammate look it over all catch real problems. You should keep doing them.
But a human review only answers one question: "does this look right right now?" It says nothing about next month, when someone refactors a helper function and quietly changes what it returns. Nobody re-reads old code that "already works."
There's also the old rule of thumb that bugs get more expensive the later you find them. The exact multipliers people quote are shaky and rarely trace back to solid studies, and with CI/CD the gap is smaller than it used to be. But the direction still holds: a failing test on your laptop beats a bug report from a user.
Bugs love that gap. They hide in:
- Edge cases you didn't think to try (empty strings,
null, a leap day, a negative number) - Code paths that only run in rare situations
- Changes in one place that break something far away
- Behaviour that shifts slowly, so nothing looks wrong on any given day
A test doesn't get tired, doesn't skim, and doesn't assume "that part's fine." It runs the same check every single time, in about a millisecond.
A Tiny Example
Say you've got a little function that formats a price:
export function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}Looks fine. You try 1999, get $19.99, and move on. Here's what a few tests add:
import { describe, it, expect } from 'vitest';
import { formatPrice } from './price';
describe('formatPrice', () => {
it('formats whole dollars', () => {
expect(formatPrice(500)).toBe('$5.00');
});
it('formats cents', () => {
expect(formatPrice(1999)).toBe('$19.99');
});
it('handles zero', () => {
expect(formatPrice(0)).toBe('$0.00');
});
it('handles negative amounts', () => {
expect(formatPrice(-250)).toBe('$-2.50');
});
});That last test is the interesting one. $-2.50 is probably not what you wanted, and you'd never have noticed by eyeballing the happy path. Now you know, and you can decide what the right behaviour is. Once you fix it, the test makes sure it stays fixed.
AI Makes This Easier Than Ever
Here's the part that changed the game for me. The biggest excuse for skipping tests was always "I don't have time to write them." Writing tests is tedious. It's boilerplate, setup and thinking up edge cases.
AI is really good at exactly that stuff. Point it at a function and ask for tests, and you get a solid first draft in seconds, including edge cases you may not have considered. It's like having a pedantic coworker whose entire job is to ask "but what if this is empty?"
A few ways I like to use it:
- Generate tests for existing code. Great for legacy stuff nobody dares to touch.
- Brainstorm edge cases. Ask "what could break this?" and see what comes back.
- Write the test first. Describe the behaviour you want, get the tests, then implement.
- Lock in a bug fix. Reproduce the bug as a failing test before fixing it.
AI Writes Code Fast, So You Need a Safety Net
This is the flip side, and it matters. AI can generate a lot of code very quickly. It also makes confident mistakes, and the code often looks completely reasonable. Reading through hundreds of generated lines and spotting the one subtle bug is exactly the kind of thing human eyes are bad at.
Tests turn that around. Instead of trusting that the code is right, you've got something that proves it behaves the way you expect. When an AI tool rewrites a function, the tests tell you right away if it broke something. No arguing about it, no guessing.
This isn't just my gut feeling. A survey of bugs in AI-generated code found that functional bugs are the most common problem, meaning code that looks syntactically fine but quietly gives the wrong output. Numbers on exactly how buggy AI code is vary a lot from study to study (a lot of them come from vendors measuring different things), so I won't quote a figure. But "looks right, behaves wrong" is exactly what tests are for.
One caution: don't blindly trust AI-written tests either. Read them. Researchers have found that buggy code can mislead the LLM writing its tests, so the tests end up blessing the bug. A test that always passes, or that just mirrors the implementation, is worse than no test because it gives you false confidence. Make sure each one would actually fail if the code were wrong. A quick trick is to break the code on purpose and watch the test go red. That's the idea behind mutation testing, and research on it points out that high coverage doesn't guarantee a suite actually catches faults.
Start Small
You don't need 100% coverage or a fancy setup. Pick the logic that would hurt the most if it broke (parsing, calculations, validation, anything touching money or user data) and cover that first. Wire npm test into your CI so it runs on every push, and you've got a safety net that works while you sleep.
Manual review tells you the code looks right today. Automated tests tell you it's still right six months from now, after you and a few AI assistants have changed everything around it. With the cost of writing them now close to zero, there's really no good excuse left.