DEV Community

Cover image for 7 Advanced JavaScript Testing Patterns for Handling Edge Cases
Aarav Joshi
Aarav Joshi

Posted on

7 Advanced JavaScript Testing Patterns for Handling Edge Cases

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

JavaScript testing necessitates a methodical approach to handle complex scenarios. I've developed advanced testing strategies throughout my career that significantly improve code reliability. Let's explore seven testing patterns that address challenging edge cases.

Error Boundaries Testing

Error boundaries in React provide a safety net for component failures. Testing them requires deliberately creating failure conditions.

// Error boundary component
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return <h2>Something went wrong: {this.state.error.message}</h2>;
    }
    return this.props.children;
  }
}

// Test for error boundary
test('ErrorBoundary catches rendering errors', () => {
  const ProblemComponent = () => {
    throw new Error('Test error');
    return <div>Never renders</div>;
  };

  const { getByText } = render(
    <ErrorBoundary>
      <ProblemComponent />
    </ErrorBoundary>
  );

  expect(getByText(/Test error/)).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

I find testing error boundaries particularly valuable for applications with complex rendering logic. In a project managing financial data, error boundaries prevented crashed interfaces when unexpected data formats appeared.

Race Condition Detection

Race conditions are notoriously difficult to catch. Controlled timing in tests helps expose these issues.

test('handles concurrent requests correctly', async () => {
  // Setup a delayed resolver
  const createDelayedPromise = (value, delay) => {
    return new Promise(resolve => {
      setTimeout(() => resolve(value), delay);
    });
  };

  // Mock API with controlled timing
  const api = {
    getData: jest.fn()
      .mockImplementationOnce(() => createDelayedPromise('response1', 200))
      .mockImplementationOnce(() => createDelayedPromise('response2', 100))
  };

  // Function to test
  const fetchDataTwice = async () => {
    const request1 = api.getData();
    const request2 = api.getData();
    const result1 = await request1;
    const result2 = await request2;
    return [result1, result2];
  };

  const results = await fetchDataTwice();

  // Verify results are as expected regardless of timing
  expect(results).toEqual(['response1', 'response2']);
});
Enter fullscreen mode Exit fullscreen mode

This pattern helped me identify a critical bug in a payment processing system where overlapping API calls created duplicate transactions.

Locale-Sensitive Testing

Applications used globally must handle internationalization correctly. Testing across locales prevents regional bugs.

describe('internationalization handling', () => {
  const originalLocale = Intl.NumberFormat().resolvedOptions().locale;

  afterEach(() => {
    // Reset locale after each test
    mockBrowserLocale(originalLocale);
  });

  test('formats currency according to locale', () => {
    // Mock German locale
    mockBrowserLocale('de-DE');

    const formatCurrency = (amount) => {
      return new Intl.NumberFormat(navigator.language, {
        style: 'currency',
        currency: 'EUR'
      }).format(amount);
    };

    expect(formatCurrency(1234.56)).toBe('1.234,56 €');

    // Mock US locale
    mockBrowserLocale('en-US');
    expect(formatCurrency(1234.56)).toBe('€1,234.56');
  });

  // Helper to mock browser locale
  function mockBrowserLocale(locale) {
    const originalNavigator = global.navigator;
    global.navigator = {
      ...originalNavigator,
      language: locale
    };
  }
});
Enter fullscreen mode Exit fullscreen mode

While developing an e-commerce platform, this approach helped us identify critical sorting issues that appeared only in specific locales.

Timeout Testing

Reliable applications must handle network delays and timeouts gracefully. Timeout testing simulates these conditions.

test('recovers from API timeout', async () => {
  jest.useFakeTimers();

  // Mock a function that times out
  const fetchWithTimeout = (timeout = 5000) => {
    return new Promise((resolve, reject) => {
      const timeoutId = setTimeout(() => {
        reject(new Error('Request timed out'));
      }, timeout);

      fetch('/api/data')
        .then(response => {
          clearTimeout(timeoutId);
          resolve(response);
        })
        .catch(error => {
          clearTimeout(timeoutId);
          reject(error);
        });
    });
  };

  // Mock global fetch
  global.fetch = jest.fn(() => new Promise(resolve => {
    // This promise never resolves, simulating a hanging request
  }));

  const fetchPromise = fetchWithTimeout(1000);

  // Fast-forward time to trigger timeout
  jest.advanceTimersByTime(1500);

  await expect(fetchPromise).rejects.toThrow('Request timed out');

  jest.useRealTimers();
});
Enter fullscreen mode Exit fullscreen mode

This pattern proved vital in a real-time chat application I worked on, ensuring message delivery was reliable even on unstable connections.

State Machine Testing

Applications with complex workflows benefit from state machine testing. This ensures all possible transitions behave correctly.

describe('Order state machine', () => {
  // Define a simple order state machine
  const createOrderMachine = (initialState = 'pending') => {
    const states = {
      pending: ['processing', 'canceled'],
      processing: ['shipped', 'canceled'],
      shipped: ['delivered', 'returned'],
      delivered: ['returned'],
      returned: [],
      canceled: []
    };

    let currentState = initialState;

    return {
      getState: () => currentState,
      transition: (newState) => {
        if (!states[currentState].includes(newState)) {
          throw new Error(`Invalid transition from ${currentState} to ${newState}`);
        }
        currentState = newState;
        return currentState;
      }
    };
  };

  test('allows valid transitions', () => {
    const order = createOrderMachine();
    expect(order.getState()).toBe('pending');

    expect(order.transition('processing')).toBe('processing');
    expect(order.transition('shipped')).toBe('shipped');
    expect(order.transition('delivered')).toBe('delivered');
  });

  test('rejects invalid transitions', () => {
    const order = createOrderMachine();

    // Can't go from pending directly to shipped
    expect(() => order.transition('shipped')).toThrow('Invalid transition');

    // Can't go from canceled to any other state
    order.transition('canceled');
    expect(() => order.transition('processing')).toThrow('Invalid transition');
  });

  test('handles all possible valid paths', () => {
    // Testing all possible valid paths
    const validPaths = [
      ['pending', 'processing', 'shipped', 'delivered'],
      ['pending', 'processing', 'shipped', 'returned'],
      ['pending', 'processing', 'canceled'],
      ['pending', 'canceled']
    ];

    validPaths.forEach(path => {
      const order = createOrderMachine();
      let currentState = path[0];

      // Skip the first state (it's the initial state)
      for (let i = 1; i < path.length; i++) {
        expect(() => order.transition(path[i])).not.toThrow();
        currentState = path[i];
        expect(order.getState()).toBe(currentState);
      }
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

I implemented this in an order management system, catching numerous edge cases where orders could reach inconsistent states.

Partial Failure Testing

Systems must handle partial failures gracefully. Testing these scenarios prepares applications for real-world issues.

test('handles partial API response', async () => {
  // Mock an API that returns incomplete data
  const fetchUserData = async (userId) => {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  };

  // Mock implementation
  global.fetch = jest.fn().mockImplementation(() => 
    Promise.resolve({
      json: () => Promise.resolve({
        id: 123,
        // Missing expected fields like name, email
        lastLogin: '2023-01-01'
      })
    })
  );

  // Function that uses the API data
  const displayUserProfile = async (userId) => {
    try {
      const data = await fetchUserData(userId);
      return {
        displayName: data.name || 'Unknown User',
        contact: data.email || 'No email provided',
        id: data.id,
        lastSeen: data.lastLogin ? new Date(data.lastLogin) : 'Never'
      };
    } catch (error) {
      return { error: true, message: 'Failed to load user data' };
    }
  };

  const profile = await displayUserProfile(123);

  // Verify the function handles missing data gracefully
  expect(profile).toEqual({
    displayName: 'Unknown User',
    contact: 'No email provided',
    id: 123,
    lastSeen: new Date('2023-01-01')
  });
});
Enter fullscreen mode Exit fullscreen mode

This testing pattern helped us maintain service quality during a major API migration where backend services occasionally returned incomplete data.

Boundary Value Testing

Testing boundary conditions exposes many bugs. This approach focuses on edge cases in inputs and outputs.

describe('array processing function', () => {
  // Function that processes arrays with a size limit
  const processArray = (arr, maxSize = 1000) => {
    if (!Array.isArray(arr)) {
      throw new TypeError('Input must be an array');
    }

    if (arr.length > maxSize) {
      throw new RangeError(`Array exceeds maximum size of ${maxSize}`);
    }

    if (arr.length === 0) {
      return { result: [], status: 'empty' };
    }

    const result = arr.map(item => {
      if (typeof item !== 'number') {
        return 0;
      }
      return item * 2;
    });

    return { result, status: 'success' };
  };

  test('handles normal case', () => {
    expect(processArray([1, 2, 3])).toEqual({
      result: [2, 4, 6],
      status: 'success'
    });
  });

  test('handles empty array', () => {
    expect(processArray([])).toEqual({
      result: [],
      status: 'empty'
    });
  });

  test('handles maximum size array', () => {
    const maxArray = Array(1000).fill(1);
    expect(processArray(maxArray).result.length).toBe(1000);
  });

  test('rejects oversized array', () => {
    const oversizedArray = Array(1001).fill(1);
    expect(() => processArray(oversizedArray)).toThrow(RangeError);
  });

  test('handles non-numeric values', () => {
    expect(processArray([1, 'two', 3, null, undefined])).toEqual({
      result: [2, 0, 6, 0, 0],
      status: 'success'
    });
  });

  test('rejects non-array input', () => {
    expect(() => processArray('not an array')).toThrow(TypeError);
    expect(() => processArray(123)).toThrow(TypeError);
    expect(() => processArray(null)).toThrow(TypeError);
    expect(() => processArray(undefined)).toThrow(TypeError);
  });

  test('handles edge case values', () => {
    expect(processArray([Number.MAX_SAFE_INTEGER])).toEqual({
      result: [Number.MAX_SAFE_INTEGER * 2],
      status: 'success'
    });

    expect(processArray([Number.MIN_SAFE_INTEGER])).toEqual({
      result: [Number.MIN_SAFE_INTEGER * 2],
      status: 'success'
    });

    expect(processArray([Infinity, -Infinity, NaN])).toEqual({
      result: [Infinity, -Infinity, NaN],
      status: 'success'
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

This approach helped me catch unexpected issues in a data processing library that failed on certain numerical edge cases.

Testing edge cases requires creative thinking about potential failure scenarios. These patterns have saved my teams countless hours of debugging and prevented production issues.

The most successful testing strategies combine these patterns into a comprehensive approach. I've learned that investing time in thorough testing pays dividends through increased reliability and reduced maintenance costs.

I recommend starting with boundary value testing and gradually adding other patterns as your application complexity increases. Remember that tests should evolve alongside your code to maintain quality throughout the development lifecycle.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Heroku

Built for developers, by developers.

Whether you're building a simple prototype or a business-critical product, Heroku's fully-managed platform gives you the simplest path to delivering apps quickly — using the tools and languages you already love!

Learn More

Top comments (0)

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, valued within the supportive DEV Community. Coders of every background are welcome to join in and add to our collective wisdom.

A sincere "thank you" often brightens someone’s day. Share your gratitude in the comments below!

On DEV, the act of sharing knowledge eases our journey and fortifies our community ties. Found value in this? A quick thank you to the author can make a significant impact.

Okay