You wrote tests. They pass. Coverage looks good. You ship to production and... it breaks.
The problem isn't that you didn't write tests — it's that you tested the expected inputs while your users sent the unexpected ones.
After cataloguing hundreds of production bugs, I've built a pattern library of edge cases that most test suites miss. Here's the collection, organized by type, with concrete examples you can apply today.
The Null/Undefined/Empty Family
This is the #1 source of production errors. For every function that accepts input, ask: "What if it's empty?"
// Your function
function getFullName(user) {
return `${user.firstName} ${user.lastName}`;
}
// Tests you probably wrote
test('returns full name', () => {
expect(getFullName({ firstName: 'Jane', lastName: 'Doe' }))
.toBe('Jane Doe');
});
// Tests you probably DIDN'T write
test('handles null input', () => {
expect(() => getFullName(null)).toThrow();
});
test('handles missing fields', () => {
expect(getFullName({ firstName: 'Jane' }))
.toBe('Jane undefined'); // Is this what you want?
});
test('handles empty strings', () => {
expect(getFullName({ firstName: '', lastName: '' }))
.toBe(' '); // A single space? Really?
});
The full "empty" family to test:
nullundefined-
""(empty string) -
" "(whitespace-only string) -
[](empty array) -
{}(empty object) -
0(zero — falsy but valid!)
The Dangerous Numbers
Numbers have more edge cases than most developers realize. JavaScript makes this especially tricky.
function calculateDiscount(price, percentage) {
return price * (percentage / 100);
}
// Are you testing these?
test('handles zero price', () => {
expect(calculateDiscount(0, 50)).toBe(0);
});
test('handles floating point precision', () => {
// 0.1 + 0.2 !== 0.3 in JavaScript
expect(calculateDiscount(10, 33.3))
.toBeCloseTo(3.33); // NOT toBe!
});
test('handles negative numbers', () => {
expect(calculateDiscount(-100, 50)).toBe(-50);
// Is a negative discount valid in your business logic?
});
test('handles percentage over 100', () => {
expect(calculateDiscount(100, 150)).toBe(150);
// Should this be allowed?
});
test('handles NaN', () => {
expect(calculateDiscount(NaN, 50)).toBeNaN();
// What should actually happen here?
});
The dangerous numbers checklist:
-
0and-0(yes, negative zero exists) -
NaN,Infinity,-Infinity -
Number.MAX_SAFE_INTEGER(9007199254740991) - Floating point:
0.1 + 0.2(usetoBeCloseTo) - Negative numbers where only positive expected
Strings That Break Things
Strings are deceptively complex. Unicode, special characters, and encoding issues cause some of the most confusing bugs.
function slugify(title) {
return title.toLowerCase().replace(/\s+/g, '-');
}
// Beyond the happy path, test these:
const edgeCases = [
['', ''], // empty
['a', 'a'], // single char
['Hello World', 'hello-world'], // normal
[' spaces ', '--spaces--'], // leading/trailing
['emoji \ud83d\ude80 test', 'emoji-\ud83d\ude80-test'], // unicode
['<script>alert(1)</script>', '<script>alert(1)</script>'], // XSS
['a'.repeat(10000), /* ? */], // very long
['\t\n\r', '---'], // whitespace chars
];
Strings to always test:
- Empty string and whitespace-only
- Single character
- Very long strings (10,000+ characters)
- Unicode: emoji, CJK characters, RTL text
- Special chars:
<,>,&,",',\,/ - Injection patterns:
' OR 1=1 --and<script> - Null bytes, newlines, tabs
Boundary Values: The Off-by-One Epidemic
The principle is simple: test at min, min+1, typical, max-1, max. Yet most test suites only test "typical."
function paginate(items, page, pageSize = 10) {
const start = (page - 1) * pageSize;
return items.slice(start, start + pageSize);
}
// Boundary tests
test('page 0 (invalid)', () => {
expect(paginate(items, 0)).toEqual([]); // Or should it throw?
});
test('page 1 (first page)', () => {
expect(paginate(items, 1)).toHaveLength(10);
});
test('last page with exact fit', () => {
const items = Array.from({ length: 30 }, (_, i) => i);
expect(paginate(items, 3)).toHaveLength(10);
});
test('last page with partial results', () => {
const items = Array.from({ length: 25 }, (_, i) => i);
expect(paginate(items, 3)).toHaveLength(5);
});
test('page beyond total pages', () => {
const items = Array.from({ length: 5 }, (_, i) => i);
expect(paginate(items, 100)).toEqual([]);
});
test('pageSize of 1', () => {
expect(paginate(items, 1, 1)).toHaveLength(1);
});
test('pageSize of 0', () => {
expect(paginate(items, 1, 0)).toEqual([]); // Or throw?
});
Common boundary categories:
- Pagination: page 0, 1, last, beyond-last
- Counts: 0 items, 1 item, exactly-at-limit, limit+1
- Money: $0.00, $0.01, sub-cent amounts ($0.001)
- Dates: midnight, end-of-day, leap year Feb 29, DST transitions
- String lengths: 0, 1, at-validation-limit, one-over-limit
Async Errors: The Silent Killers
Async code multiplies edge cases. Network failures, timeouts, and race conditions are hard to reproduce in tests but common in production.
// Testing async error paths
test('handles network timeout', async () => {
jest.spyOn(global, 'fetch').mockImplementation(
() => new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 100)
)
);
await expect(fetchUserData(1)).rejects.toThrow('Timeout');
});
test('handles empty response body', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.reject(new SyntaxError('Unexpected end of JSON'))
});
await expect(fetchUserData(1)).rejects.toThrow();
});
test('handles concurrent calls gracefully', async () => {
// Fire 10 simultaneous requests
const promises = Array.from({ length: 10 },
(_, i) => fetchUserData(i)
);
// None should throw unhandled rejections
const results = await Promise.allSettled(promises);
expect(results.some(r => r.status === 'fulfilled')).toBe(true);
});
Async edge cases to cover:
- Connection timeout vs. read timeout
- HTTP 4xx and 5xx responses (each separately!)
- Empty or malformed response body
- Concurrent calls to the same resource
- Retry after failure (does it actually retry?)
- Partial failure in batch operations
The Quick Reference Checklist
For every function you write, run through this mental checklist:
| Input Type | Edge Cases to Test |
|---|---|
| Any parameter | null, undefined |
| Strings | empty, whitespace, very long, special chars, unicode |
| Numbers | 0, negative, NaN, Infinity, float precision |
| Arrays | empty, single item, duplicates, very large |
| Objects | empty, missing fields, extra fields, circular refs |
| Dates | midnight, leap year, DST, timezone boundaries |
| Async | timeout, network error, empty response, concurrent |
| Files | not found, permission denied, empty, very large |
Building This Into Your Workflow
You don't need to memorize all of this. I built Test Forge — a Claude Code skill that automatically generates edge case tests from your source code. It analyzes your functions, identifies untested edge cases from the pattern library above, and generates ready-to-run test code.
Whether you use a tool or this reference guide, the key is shifting from "does my code work?" to "how can my code break?" That mindset shift alone will cut your production bugs in half.
What's the weirdest edge case bug you've encountered in production? I'd love to hear your war stories in the comments.
Top comments (0)