Blog Post

Mastering Frontend Testing: A Guide to Mocking Backends

February 27, 2024 Tom Hu

Front-end development often involves waiting for the backend to be ready before full testing can commence. This delay can impede progress and hinder the development process. Fortunately, there are strategies to overcome this challenge by mocking out the backend, enabling developers to test their front ends without waiting for a fully functional backend. Let’s explore various methods of mocking backends to help you make informed decisions on which method suits your testing needs.

Base Example

Let’s start with a simple scenario: a book library API. Our front-end needs to fetch all books by title, and the corresponding API endpoint is /books. Below is an example of a simple front-end code snippet:

async function fetchAllBooks() {
  try {
    const response = await fetch('http://localhost:3000/books');
    const allBooksData = await response.json();
    return allBooksData;
  } catch (error) {
    console.error('Error fetching all books:', error);
  }
}

Hard-coded into Front-end Code

The quickest way to mock out the backend is to hard-code API responses directly into the code. While simple and effective for instant feedback, this method has limitations. It can break the implementation with a real backend, making it destructive to the code and unsuitable for long-term use.

Example:

async function fetchAllBooks() {
  try {
    const allBooksData = [
      { 'id': 1, 'title': 'Introduction to Algorithms' },
      { 'id': 2, 'title': 'The C Programming Language' }
    ];
    return allBooksData;
  } catch (error) {
    console.error('Error fetching all books:', error);
  }
}

Notice that instead of calling the fetch, we insert dummy data in its place.

Using Saved Data from Production

To address the destructiveness of hard coding, consider using a local text or JSON file to store data. Using saved data in a file can be easy to set up and can make switching configurations easy.

While an improvement, it still poses challenges, as changes made to the file are not reflected dynamically in the application. There still needs to be code changes made to accommodate pulling data from a file as opposed to an API call. As a result, the code changes should not be merged into a repository.

Example:

const fs = require('fs/promises');

async function fetchAllBooks() {
  try {
    const data = await fs.readFile('books.json', 'utf-8');
    const allBooksData = JSON.parse(data);
    return allBooksData;
  } catch (error) {
    console.error('Error reading data from file:', error);
  }
}

books.json:

[
  { 'id': 1, 'title': 'Introduction to Algorithms' },
  { 'id': 2, 'title': 'The C Programming Language' }
]

Using Test Doubles

Test doubles offer a non-destructive way to mock out API calls, allowing developers to test scenarios without altering the actual code. These scenarios can be run over and over as opposed to as a one-off.

Although more challenging to implement, this method provides repeatability and a cleaner approach to front-end testing.

Example:

Assuming the original code, here is a test case using test doubles:

describe('Test fetchAllBooks', () => {
  let dispatcher;

  afterEach(() => {
    td.reset();
    setGlobalDispatcher(dispatcher);
  });

  beforeEach(() => {
    dispatcher = getGlobalDispatcher();
  });

  it('gets the correct books', async () => {
    const books = [
      { 'id': 1, 'title': 'Introduction to Algorithms' },
      { 'id': 2, 'title': 'The C Programming Language' }
    ];

    const mockAgent = new MockAgent();
    setGlobalDispatcher(mockAgent);

    const mockPool = mockAgent.get('http://localhost:3000');
    mockPool.intercept({
      path: '/books'
    }).reply(200, books);

    const response = await fetchAllBooks();
    expect(response).toMatchObject(books);
  });
});

Notice that we have hard-coded the books into the test case. We could have also applied a test file here instead. You can [read more about test doubles](https://about.codecov.io/blog/to-mock-or-not-to-mock-test-doubles-and-how-to-use-them/) if you have not used them.

Building Mock Data Using Factories

For more maintainable tests and handling of random data, consider creating factories. Although this requires an initial time investment, it pays off in the long run by providing a structured approach to generating mock data.

As opposed to test doubles, factories often produce objects that can be used across many test suites. They can also be used to automate database creation.

Example:

Let’s create a factory for books in a separate file, book.factory.js:

const faker = require('faker');

// Factory function to create a book with random data
function createBook() {
  return {
    id: faker.random.uuid(),
    title: faker.lorem.words(),
    author: faker.name.findName(),
    // Add more fields as needed
  };
}

module.exports = {
  createBook,
};

Now, our test may look like:

it('gets the correct books', async () => {
  const books = [createBook(), createBook()];

  const mockAgent = new MockAgent();
  setGlobalDispatcher(mockAgent);

  const mockPool = mockAgent.get('http://localhost:3000');
  mockPool.intercept({
    path: '/books'
  }).reply(200, books);

  const response = await fetchAllBooks();
  expect(response).toMatchObject(books);
  expect(response).not.toContainEqual(createBook());
});

Notice that the createBook() method can be called by any other test files that we have.

Use a Staging Database

Another approach is to point the API to a staging database, simulating a real-world environment. While more complex to set up, this method provides accurate data and is relatively simple to switch between environments. A staging (or other environment) database should be sanitized of PPI or other private data before use.

Example:

Before the front-end is served, we can dynamically populate the API URL based on the environment:

async function fetchAllBooks(apiUrl) {
  try {
    const response = await fetch(`${apiUrl}/books`);
    const allBooksData = await response.json();
    return allBooksData;
  } catch (error) {
    console.error('Error fetching all books:', error);
  }
}

To populate the apiUrl argument, add the following to the application code or the test setup:

const productionApiUrl = 'https://api.example.com';
const stagingApiUrl = 'https://staging.api.example.com';

const apiUrl = process.env.NODE_ENV === 'production' ? productionApiUrl : stagingApiUrl;

// Call the API function with the dynamic API URL
const allBooksData = await fetchAllBooks(apiUrl);

Which method should I use?

All of the above methods provide ways for front-end developers to test their code out without a working backend. Each has its pros and cons, and often, the methods can be combined. But by implementing some of these methods, development teams will be able to move faster and more efficiently.

Before we redirect you to GitHub...
In order to use Codecov an admin must approve your org.