You ask AI to write unit tests for your shopping cart moduleWhat is module?A self-contained file of code with its own scope that explicitly exports values for other files to import, preventing name collisions.. It produces a beautiful test file with 15 tests, all green. You feel great, until a user enters a quantity of -3 and your cart shows a negative total. None of those 15 tests checked for negative numbers. AI tested what the code does, not what it should do.
Unit tests are the foundation of any testing strategy, and they are also where AI assistance is most deceptive. The tests look comprehensive. The output is formatted perfectly. But the test cases themselves reflect the same blind spots as the code.
What is a unit testWhat is unit test?An automated check that verifies a single function works correctly in isolation, with all external dependencies replaced by fakes.?
A unit is the smallest testable piece of code, typically a single function, method, or component. A unit test verifies that this piece works correctly in isolation, without databases, APIs, or other modules.
function calculateDiscount(price, percentage) {
if (price < 0) throw new Error('Price cannot be negative');
if (percentage < 0 || percentage > 100) {
throw new Error('Percentage must be between 0 and 100');
}
return price * (percentage / 100);
}
test('calculateDiscount() applies percentage correctly', () => {
expect(calculateDiscount(100, 20)).toBe(20);
expect(calculateDiscount(50, 10)).toBe(5);
expect(calculateDiscount(0, 50)).toBe(0);
});
test('calculateDiscount() throws on invalid input', () => {
expect(() => calculateDiscount(-10, 20)).toThrow('Price cannot be negative');
expect(() => calculateDiscount(100, 150)).toThrow('Percentage must be between 0 and 100');
});Each test focuses on one aspect: correct calculation or error handling. This separation makes failures easy to diagnose. When AI generates this function, it will usually generate the first test but skip the second, it does not think to test its own error handling.
The AAA pattern
Every well-structured test follows three steps:
- Arrange: set up the test data and preconditions
- Act: execute the code under test
- Assert: verify the result matches expectations
test('shopping cart calculates total correctly', () => {
// Arrange
const cart = new ShoppingCart();
const item1 = { price: 10, quantity: 2 };
const item2 = { price: 5, quantity: 1 };
// Act
cart.add(item1);
cart.add(item2);
const total = cart.getTotal();
// Assert
expect(total).toBe(25);
});When AI generates tests, it usually follows this structure. What it gets wrong is the content of each step, it arranges trivial data, acts on the happy path, and asserts shallow properties.
add(2, 3), multiply(4, 5), formatName("Alice"). These tests pass but never stress the function. Real bugs hide in edge cases: add(Number.MAX_SAFE_INTEGER, 1), multiply(-0, Infinity), formatName(""). Always supplement AI-generated tests with edge case inputs the AI did not consider.Testing frameworks: Jest and Vitest
Jest is the most popular JavaScript testing framework. Vitest is the modern alternative, faster, native TypeScript support, and built for Vite projects.
import { describe, test, expect } from 'vitest';
import { add, subtract, multiply } from './math';
describe('Math utilities', () => {
test('add() returns sum of two numbers', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
expect(add(0, 0)).toBe(0);
});
test('subtract() returns difference', () => {
expect(subtract(5, 3)).toBe(2);
expect(subtract(3, 5)).toBe(-2);
});
});| Feature | Jest | Vitest |
|---|---|---|
| Speed | Moderate | Fast (native ESM) |
| TypeScript | Needs ts-jest config | Built-in support |
| Configuration | Heavy, lots of options | Minimal, uses Vite config |
| Ecosystem | Largest, most plugins | Growing, Jest-compatible API |
| Best for | Existing projects, Node.js | New projects, Vite/React |
Both use the same APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. (describe, test, expect), so switching between them is straightforward. When AI generates test files, check which framework it targets, mixing Jest globals with Vitest imports causes confusing failures.
Common assertions
Assertions are how you verify results. Here are the ones you will use most:
| Assertion | What it checks | Example |
|---|---|---|
toBe() | Strict equality (===) | expect(value).toBe(5) |
toEqual() | Deep equality (objects/arrays) | expect(obj).toEqual({a: 1}) |
toBeTruthy() | Truthy value | expect(value).toBeTruthy() |
toBeNull() | Null value | expect(value).toBeNull() |
toContain() | Array/string contains item | expect(arr).toContain(item) |
toThrow() | Function throws error | expect(fn).toThrow('msg') |
toHaveLength() | Array/string length | expect(arr).toHaveLength(3) |
toBeCloseTo() | Floating point near-equality | expect(0.1 + 0.2).toBeCloseTo(0.3) |
toBeTruthy() where toBe(true) is needed. toBeTruthy() passes for any truthy value, the number 1, a non-empty string, an object, even an empty array []. If your function should return specifically true, use toBe(true). Shallow assertions are a major source of false-positive tests that pass when the code is broken.Mocking dependencies
Unit tests should be isolated. If your function calls an APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. or database, you mockWhat is mock?A fake replacement for a real dependency in tests that records how it was called so you can verify interactions. (fake) that dependencyWhat is dependency?A piece of code written by someone else that your project needs to work. Think of it as a building block you import instead of writing yourself. so the test only exercises your logic.
import { vi } from 'vitest';
// Mock the API module
vi.mock('./api', () => ({
fetchUser: vi.fn(() => Promise.resolve({ id: 1, name: 'Alice' }))
}));
test('loadUserProfile formats display name', async () => {
const profile = await loadUserProfile(1);
expect(profile.displayName).toBe('Alice');
});Mocking lets you control the environment and test scenarios that are hard to reproduce, network failures, timeout errors, empty responses.
processOrder() calls calculateTotal() and applyDiscount(), and AI mocks both of those, your test verifies nothing, it only confirms that your function calls two other functions. Mock external I/O (APIs, databases, file system). Do not mock internal business logic.Testing async code
Modern JavaScript is full of async operations. Tests handle them with async/await:
test('fetches user data', async () => {
const data = await fetchUser(1);
expect(data.name).toBe('Alice');
});
test('handles missing user', async () => {
await expect(fetchUser(999)).rejects.toThrow('User not found');
});Always await the assertionWhat is assertion?A statement in a test that checks whether a value matches what you expected, failing the test if it does not. when testing rejected promises. Forgetting await makes the test pass regardless of what the promiseWhat is promise?An object that represents a value you don't have yet but will get in the future, letting your code keep running while it waits. does, the test finishes before the promise settles. When AI generates async tests, it sometimes forgets the await on rejects.toThrow(), creating a test that never fails.
What AI-generated unit tests typically miss
Here is a pattern you will see repeatedly. AI writes this test suite for a validateEmail function:
// AI-generated tests (looks complete, is not)
test('accepts valid email', () => {
expect(validateEmail('user@example.com')).toBe(true);
});
test('rejects email without @', () => {
expect(validateEmail('userexample.com')).toBe(false);
});
test('rejects empty string', () => {
expect(validateEmail('')).toBe(false);
});Three tests, all pass. But what about these cases?
// Tests AI should have written
test('rejects email with spaces', () => {
expect(validateEmail('user @example.com')).toBe(false);
});
test('handles null input', () => {
expect(validateEmail(null)).toBe(false);
});
test('handles undefined input', () => {
expect(validateEmail(undefined)).toBe(false);
});
test('rejects double dots in domain', () => {
expect(validateEmail('user@example..com')).toBe(false);
});
test('handles extremely long input', () => {
const longEmail = 'a'.repeat(1000) + '@example.com';
expect(validateEmail(longEmail)).toBe(false);
});The AI tested the obvious cases. The real bugs live in the non-obvious ones. This is not a one-off problem, it is the pattern with every AI-generated test suite.
Unit testing decision table
When reviewing AI-generated tests or writing your own, use this table to decide what to test:
| Input category | Examples | AI tests this? | You must test this |
|---|---|---|---|
| Happy path | Valid email, positive number | Almost always | Yes, as a baseline |
| Empty input | "", [], {}, null, undefined | Rarely | Always |
| Boundary values | 0, -1, MAX_SAFE_INTEGER | Rarely | Always for numeric functions |
| Type mismatches | String where number expected | Almost never | When function is public-facing |
| Special characters | Unicode, emoji, HTML injection | Almost never | For user-facing inputs |
| Concurrent/async edge cases | Race conditions, timeouts | Never | For async functions |
Unit testing best practices
| Do | Do not |
|---|---|
| Test one concept per test | Combine multiple assertions about different behaviors |
| Use descriptive test names | Name tests vaguely ('test user') |
| Test behavior (return values, side effects) | Test implementation details (private state) |
| Mock external I/O only | Mock internal logic you are trying to test |
| Keep tests independent | Share mutable state between tests |
| Test edge cases explicitly | Trust that happy-path tests are sufficient |
Unit tests are your first line of defense. They catch logic errors early, document expected behavior, and give you confidence to refactor AI-generated code. But only if you write them to challenge the code, not confirm it.