DEV Community

Ryu0705
Ryu0705

Posted on

The Edge Cases Your Tests Are Missing: A Pattern Library

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?
});
Enter fullscreen mode Exit fullscreen mode

The full "empty" family to test:

  • null
  • undefined
  • "" (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?
});
Enter fullscreen mode Exit fullscreen mode

The dangerous numbers checklist:

  • 0 and -0 (yes, negative zero exists)
  • NaN, Infinity, -Infinity
  • Number.MAX_SAFE_INTEGER (9007199254740991)
  • Floating point: 0.1 + 0.2 (use toBeCloseTo)
  • 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
];
Enter fullscreen mode Exit fullscreen mode

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?
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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)