DEV Community

Manas Joshi
Manas Joshi

Posted on

Mocking Fetch API Calls in Vitest for Reliable Tests

Master Mocking Fetch API Calls in Vitest

Testing asynchronous code, especially functions that interact with external services via network requests, is a critical part of building robust applications. However, allowing unit tests to make real network calls introduces several problems: they are slow, unreliable due to network flakiness, and introduce external dependencies, making your tests non-deterministic. This is where mocking comes in, allowing us to simulate network responses and keep our unit tests fast, isolated, and reliable.

In this guide, we'll dive deep into effectively mocking the native fetch API using Vitest, focusing on practical examples and common pitfalls. We'll ensure your data-fetching logic is thoroughly tested without ever hitting a real server.

The Challenge of fetch in Unit Tests

The fetch API is a global function available in browsers and Node.js (with an experimental flag or polyfill) that initiates network requests. When you write a unit test for a function that uses fetch, you want to test your function's logic—how it handles different responses, errors, and data transformations—not the fetch API itself or the remote server's uptime. Directly calling fetch in a unit test would attempt to contact a real URL, which is counterproductive for isolation and speed.

Vitest, a modern test runner, provides powerful mocking capabilities that allow us to intercept and control the behavior of functions and modules, including global objects like fetch.

Building a Data Service

Let's start with a simple JavaScript module that uses fetch to retrieve user data. This is the code we want to test.

// src/dataService.js
export async function fetchUserData(userId) {
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error("Failed to fetch user data:", error);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

This fetchUserData function asynchronously retrieves data from a hypothetical API endpoint. It constructs the URL using the provided userId and uses the native fetch API to make the request. The function checks response.ok to determine if the HTTP status code is in the 200-299 range, throwing an error for non-successful responses. Finally, it parses the response body as JSON and handles any network or parsing errors.

Setting Up fetch Mocking with Vitest

To mock fetch, we'll replace the global fetch object with a Vitest mock function (vi.fn()). This gives us complete control over what fetch returns when called in our tests.

// __tests__/dataService.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchUserData } from '../src/dataService';

// Create a Vitest mock function for fetch
const mockFetch = vi.fn();

// Assign the mock to the global fetch object
global.fetch = mockFetch;

describe('fetchUserData', () => {
  beforeEach(() => {
    // Clear any previous mock calls and implementations before each test
    mockFetch.mockClear();
  });

  // ... tests will be added here ...

  // Optionally, restore the original fetch after all tests if needed
  // afterAll(() => {
  //   vi.restoreAllMocks();
  // });
});
Enter fullscreen mode Exit fullscreen mode

This block initializes the testing environment with Vitest, importing necessary utilities like describe, it, expect, and vi. A mock function, mockFetch, is created using vi.fn() and then directly assigned to global.fetch, effectively replacing the browser's native fetch API for all tests in this file. The beforeEach hook is crucial, as it calls mockFetch.mockClear() before every test, ensuring that call counts and mock implementations are reset, preventing state leakage between tests.

Writing Tests for Different Scenarios

Now, let's write tests that cover successful data fetching, HTTP errors, and network failures. We'll use mockResolvedValueOnce and mockRejectedValueOnce to control the mock fetch behavior for individual test cases.

// __tests__/dataService.test.js (continued)
// ... (previous setup)

describe('fetchUserData', () => {
  beforeEach(() => {
    mockFetch.mockClear();
  });

  it('should fetch user data successfully', async () => {
    const mockUser = { id: 1, name: 'John Doe' };
    // Simulate a successful fetch response
    mockFetch.mockResolvedValueOnce({
      ok: true,
      status: 200,
      json: () => Promise.resolve(mockUser),
    });

    const userData = await fetchUserData(1);

    expect(mockFetch).toHaveBeenCalledTimes(1);
    expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/users/1');
    expect(userData).toEqual(mockUser);
  });

  it('should handle API errors gracefully', async () => {
    // Simulate an HTTP 404 Not Found error
    mockFetch.mockResolvedValueOnce({
      ok: false,
      status: 404,
      json: () => Promise.resolve({ message: 'Not Found' }),
    });

    await expect(fetchUserData(2)).rejects.toThrow('HTTP error! status: 404');
    expect(mockFetch).toHaveBeenCalledTimes(1);
    expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/users/2');
  });

  it('should handle network failures', async () => {
    const networkError = new TypeError('Failed to fetch');
    // Simulate a network-level error (e.g., no internet connection)
    mockFetch.mockRejectedValueOnce(networkError);

    await expect(fetchUserData(3)).rejects.toThrow(networkError);
    expect(mockFetch).toHaveBeenCalledTimes(1);
    expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/users/3');
  });
});
Enter fullscreen mode Exit fullscreen mode

These tests meticulously cover different fetch scenarios. The first it block demonstrates a successful data retrieval by configuring mockFetch.mockResolvedValueOnce to return a mock Response object, complete with ok: true and a json method. The subsequent assertions verify that fetch was called exactly once with the correct URL and that the fetchUserData function returned the expected mock data. The second and third it blocks handle error conditions: one simulates an HTTP 404 error by setting ok: false and the other a network failure using mockRejectedValueOnce, both asserting that fetchUserData rejects with the appropriate error.

Common Mistakes and Gotchas

When mocking fetch (or any global object), there are several common pitfalls to avoid:

  • Not Resetting Mocks: Forgetting to call mockClear() (or mockReset()/mockRestore()) between tests is a frequent source of test flakiness. If a mock's call count or implementation from a previous test persists, it can lead to unexpected behavior in subsequent tests. Always ensure your beforeEach hooks handle proper mock isolation.

  • Incorrect Response Object Structure: The fetch API returns a Promise that resolves to a Response object. A common mistake is to mock fetch to directly return data (e.g., mockFetch.mockResolvedValueOnce(mockUser)). This is incorrect because the consuming code expects a Response object with properties like ok, status, and methods like json() or text(). Your mock must simulate this structure accurately, as shown in the examples, by having json: () => Promise.resolve(mockData).

  • Mocking Too Late: Ensure that global.fetch = mockFetch; is executed before any code that calls fetch within your test files. If the original fetch is accessed before your mock replaces it, your tests will still make real network requests.

  • Confusing mockResolvedValue with mockResolvedValueOnce: Use mockResolvedValueOnce when you want the mock to return a specific value for a single subsequent call and then revert to its default behavior (or the next Once value). If you use mockResolvedValue, the mock will always return that value for all subsequent calls until explicitly cleared or overridden, which might not be what you intend for different test cases.

Key Takeaways

Mocking the fetch API in Vitest is essential for writing isolated, fast, and reliable unit tests for your data-fetching logic. By replacing the global fetch with a controlled mock, you can:

  • Prevent real network requests: Isolate your code from external dependencies.
  • Control API responses: Easily simulate success, various HTTP errors, and network failures.
  • Speed up tests: Eliminate the latency of actual network calls.
  • Increase test determinism: Ensure tests always behave the same way, regardless of network conditions or server state.

Conclusion

Integrating fetch mocking into your Vitest workflow is a fundamental skill for any developer working with modern JavaScript applications. By mastering vi.fn(), global.fetch assignments, and the careful construction of mock Response objects, you empower yourself to write comprehensive tests that give you confidence in your application's data-handling capabilities. Start applying these techniques today to elevate the quality and reliability of your codebase.

Top comments (0)