Push to production on Fridays

Idan Entin - Oct 9 - - Dev Community

Achieve high confidence by using real data in your tests

Save view to the future

Writing automated tests is not an easy task, especially when you aim to simulate real-world scenarios. It is crucial to have tests that assure us our code changes won't break existing functionality. This confidence can be achieved by writing tests that replicate entire user flows and asserting on the resulting UI states.

To mimic production conditions as closely as possible, it is essential to run tests without mocking critical parts of the code. Meanwhile, controlling different test scenarios, especially network responses, is still necessary. To meet both needs, we use Mock Service Worker (MSW) to simulate network responses and data builders to generate realistic data inputs.

In this article, we'll explore how to integrate MSW and data builders into your functional tests to ensure they closely mirror production environments and behaviors.

The Importance of Real Data in Testing

Real data helps to uncover issues that could be missed when using static or mocked data sets. By using realistic data, we make our tests more robust and stress our application in ways that more closely resemble actual usage.

Using MSW and Data Builders for Mocks

Let's say this is what the server returns in production:

{
  "name": "John Doe",
  "email": "john.doe@bignlarge.com",
  "avatar": "https://bignlarge.com/avatars/jonny.png",
  "title": "Account Executive"
}
Enter fullscreen mode Exit fullscreen mode

We can use this data in a JSON file and return this with our mocked server:

import jsonData from './mocks.json';
import { server } from '@mocks/server';
import { rest } from 'msw';

test('should show fetched data', () => {
  server.use(
    rest.get('/endpoint', (req, res, ctx) => res(ctx.json(jsonData)))
  );
  // ... rest of the test (render, interact, assert)
});
Enter fullscreen mode Exit fullscreen mode

This works fine for that particular result. But what if our app treats a person with a different title differently? Or uses different email logic? This is where data builders really shine.

Creating Data Builders

Using the builder pattern (a simplified variation of it) allows us to create variations of test data with ease. Let's create a builder to build a Person:

import type { Person } from './api.types.ts';

function buildPerson({
  name = faker.person.fullName(),
  email = faker.internet.email({
    firstName: name.split(' ')[0],
    lastName name.split(' ')[1]
  }),
  avatar = faker.image.avatar(),
  title = faker.person.jobTitle(),
}: Partial<Person> = {}): Person {
  return { name, email, avatar, title };
}
Enter fullscreen mode Exit fullscreen mode

Testing Different Data Scenarios

Using the data builder function, we can now easily create different test cases:

import { render, screen } from '@testing-library/react';
import { buildPerson } from './mocks/data';
import Person from './Person';

test(`should render person with Us affiliation`, () => {
  const personOfOurCompany = buildPerson({ email: 'amit@gong.io' });
  render(<Person {...personOfOurCompany} />);

  const avatar = screen.getByAltText(personOfOurCompany.name);
  expect(avatar).not.toHaveClass('affiliation-them');
  expect(avatar).toHaveClass('affiliation-us');
});

test(`should render person with Them affiliation`, () => {
  const personOfOtherCompany = buildPerson({ email: 'not.amit@other.org' });
  render(<Person {...personOfOtherCompany} />);

  const avatar = screen.getByAltText(personOfOtherCompany.name);
  expect(avatar).not.toHaveClass('affiliation-us');
  expect(avatar).toHaveClass('affiliation-them');
});
Enter fullscreen mode Exit fullscreen mode

This allows us to test whatever is needed with different types of data. And if the component fetches this data, how would we test that? We can break the component into a simple one that just renders and a richer one that also fetches and then renders the simpler one. But we still want a full flow test, so we just feed the built data to our very own mocked server:

import { rest } from 'msw';
import { render, screen } from '@testing-library/react';
import { server } from '@mocks/server';
import { buildPerson } from './mocks/data';
import Person from './Person';

test(`should render person with Us affiliation`, async () => {
  const personOfOurCompany = buildPerson({ email: 'amit@gong.io' });
  server.use(
    rest.get('/endpoint/person', (req, res, ctx) => res(ctx.json(personOfOurCompany)))
  );
  render(<Person />);

  // Notice we now wait for the first render, because we need to wait
  // (like users wait) until the request gets resolved
  const avatar = await screen.findByAltText(personOfOurCompany.name);
  expect(avatar).not.toHaveClass('affiliation-them');
  expect(avatar).toHaveClass('affiliation-us');
});
Enter fullscreen mode Exit fullscreen mode

Now changes to Person as well as any inner component of it are covered by our tests. We don't need to know anything about how the implementation works (whether it uses Redux, Saga, react-query, SWR, useEffect , etc.), just that it fetches and eventually shows us the avatar with the expected affiliation. Need to see what happens when we fail to fetch the person? No problem:

import { rest } from 'msw';
import { render, screen, within } from '@testing-library/react';
import { server } from '@mocks/server';
import { buildPerson } from './mocks/data';
import Person from './Person';

test(`should render person with Us affiliation`, async () => {
  const personOfOurCompany = buildPerson({ email: 'amit@gong.io' });
  const errorMessage = 'something went wrong';
  server.use(
    rest.get('/endpoint/person', (req, res, ctx) => res(ctx.status(500, 'Internal Server Error'), ctx.json({ error: errorMessage })))
  );
  render(<Person />);

  const alert = await screen.findByRole('alert', { name: errorMessage });
  expect(within(alert).getByRole('button', { name: /retry/i })).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Handling Complex Data

With more complex data structures, we follow a similar approach. So, say we want to build a Company. We use the other builders to build the parts, and the company builder, then uses them:

import { faker } from '@faker-js/faker';
import { uuid } from '@mocks/common-data';
import type { Company, Person } from './api.types.ts';

export function buildCompany({
  id = uuid(),
  name = faker.company.name(),
  employees = buildEmployees({ howMany: 50, companyName: name }),
  ...otherOverrides
}: Partial<Company> = {}): Company {
  return { id, name, employees, ...otherOverrides };
}

export function buildEmployees({
  howMany = faker.datatype.number({ min: 20, max: 1000 }),
  companyName= faker.company.name()
}: { howMany: number; companyName: string } = {}) {
  return Array.from({ length: howMany }, (_, index) => buildPerson({ company: companyName }));
}

function buildPerson({
  name = faker.name.fullName(),
  company = faker.company.name(),
  email = faker.internet.email(name.split(' ')[0], name.split(' ')[1], `${company}.com`),
  avatar = faker.internet.avatar(),
  title = faker.name.jobTitle(),
}: Partial<Person> = {}): Person {
  return { name, email, avatar, title, company };
}
Enter fullscreen mode Exit fullscreen mode

Realistic Use and Reuse in Development

Builders can also enhance our development workflow by allowing us to see features within our web app using realistic data. Here's how we can set up handlers that can be reused across different components and stories:

import { rest } from 'msw';
import type { buildEntirePageData } from './mocks/data';

export function buildHandlers({ data = buildEntirePageData() }) {
  return [
    rest.get('/endpoint/person', (req, res, ctx) => {
      const queriedEmail = req.url.searchParams.get('email');
      const result = data.people.find(({ email }) => queriedEmail === email);
      if (!result) {
        return res(ctx.status(404, 'Not found'));
      }
      return res(ctx.json(result));
    }),
    rest.post('/endpoint/person', (req, res, ctx) => {
      const queriedEmail = req.url.searchParams.get('email');
      data.people = data.people.map(({ email, ...rest }) => {
        if (queriedEmail === email) {
          return { email, ...rest, ...(await req.json()) };
        }
        return { email, ...rest };
      });
      return res(ctx.status(200));
    }),
  ];
}
Enter fullscreen mode Exit fullscreen mode

Nice! A fully functional server to server our entire FE app!
These handlers can then be consumed in stories, tests, and during development.
Here's how we can use the network handlers in tests:

import { rest } from 'msw';
import { render, screen, within } from '@testing-library/react';
import { server } from '@mocks/server';
import { buildEntirePageData } from './mocks/data';
import { buildHandlers } from './mocks/handlers';
import PersonPage from './PersonPage';

test(`should show whatever's needed for my page`, async () => {
  const specificData = buildEntirePageData(withOrWithoutMyOverrides);
  server.use(...buildHandlers({ data: specificData }));
  render(<PersonPage />);

  // ... interact and assert on a full blown page
});
Enter fullscreen mode Exit fullscreen mode

Here's how you can use it in a story in Storybook (see the plugin):

export const StoryMeta = {
  parameters: {
    msw: {
      handlers: {
        // this will produce the dafault values
        users: buildHandlers(),
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Check out MSW's site for information on how to setup the server worker and provide the network handlers to it.

Caveats and Solutions for them

Using Faker is great for providing large sets of random, yet understandable data. However, it might create some inconsistencies when being run repeatedly, which can lead to flakiness in tests.
Here are some ways to mitigate this:

  1. Use a UniqueEnforcer library.
  2. Use your own list of unique names.
  3. Append the index when creating the lists.
  4. Use getAllBy* queries
    1. when getAllBy*()[0] matches the first item from a buildList()

Conclusion

Testing with real data using MSW and data builders not only enhances the accuracy of your tests but also provides greater confidence in the stability of the codebase. By simulating realistic user interactions and handling varying data states, you can detect issues early in the development cycle. You might even reach a confidence so high, that you'll ship to production right before the weekend 😏

Acknowledgements

This method of testing was inspired by the fantastic work of developers who have created invaluable tools and resources for the community. Special thanks to Kent C. Dodds for revolutionizing UI testing with Testing Library and for his extensive educational content. A heartfelt shoutout to Artem Zakharchenko for developing MSW, which greatly simplifies mocking network calls. Another huge thanks to the team behind Faker that allows us to create production-like datasets with ease.

Your tools and insights make the software development process smoother and more reliable. Thank you!

.
Terabox Video Player