Why edge cases break your code
The average user is predictable. The real user pastes 'undefined' as text in a field, copies compound emojis that JavaScript counts as 4 characters, sends dates from year 9999, or uploads files named ../../etc/passwd. If your tests only cover the happy path, code explodes in production.
Edge cases aren't rare: they're inevitable at scale. With 10 users you never see unexpected null. With 10,000 users it happens 3 times a day. With 1M users, every possible invalid input will occur. The problem isn't probability, it's attack surface. Every unvalidated input is a bug waiting to manifest.
The most expensive bugs come from assumptions. You assume user.name always exists. You assume ID is positive number. You assume string has no special characters. A single unhandled null in critical function crashes the system. Tests with edge cases convert those assumptions into explicit verifications. It's not paranoia, it's accumulated industry experience.
Edge case categories that matter
Nullish: null, undefined, NaN. JavaScript has 7+ ways to represent 'nothing'. == null covers null and undefined, but NaN needs Number.isNaN(). Infinity and -Infinity are valid numbers but break normal arithmetic. Test divisions by zero, Math.log(0), Math.sqrt(-1).
Problematic strings: empty, spaces, line breaks, weird Unicode (zero-width, control chars, compound emojis). An emoji like 👨👩👧👦 is technically 7 code points but visually 1 character. string.length lies. [...string].length also lies with some cases. Use libraries like grapheme-splitter to count correctly.
Boundary numbers: Number.MAX_SAFE_INTEGER (2^53 - 1) is the last integer JavaScript handles without precision loss. Beyond that you need BigInt. 0.1 + 0.2 !== 0.3 is famous but still breaks financial calculations. Never compare floats with ===. Use tolerance: Math.abs(a - b) < Number.EPSILON. Or better: work with integers (cents instead of dollars).
How to structure edge case tests
Don't put 50 edge cases in a single test. If it fails, you don't know which input broke it. One test per edge case, with descriptive name: test('should handle null user id'), test('should reject negative amounts'). When it fails, the test name tells you exactly what's broken.
Use test.each or describe.each to parametrize: test.each([null, undefined, NaN])('handles %p gracefully', (value) => { ... }). Keeps DRY without sacrificing clarity. Output shows which specific value failed. In Jest, %p pretty-prints the value; in Vitest it's the same.
Prioritize edge cases by risk. Financial inputs (negative amounts, weird decimals) are critical—they can cost real money. Auth inputs (bypass with admin'--, SQL injection) are security. UI inputs (long strings breaking layout) are UX. Test critical first, cosmetic later. Not all edge cases have equal weight.
Advanced tools and strategies
Property-based testing with fast-check: instead of testing specific cases, you define properties that must hold for any input. Example: 'serializing and deserializing JSON must return identical value'. The library generates hundreds of random inputs (including edge cases) and verifies the property. Finds bugs you would never have imagined.
Fuzzing: tools like jsfuzz or jazzer.js generate massive invalid and mutated inputs to find crashes. Useful for parsers, deserializers, validators. If your code processes external input (APIs, uploads, forms), fuzzing finds vulnerabilities manual tests don't cover.
Mutation testing with Stryker: modifies your code (changes > to >=, && to ||) and re-runs tests. If tests still pass with mutated code, it means they're not covering those cases. Forces you to write better assertions. It's slow but exposes real coverage gaps. Run it in CI once a week, not on every commit.