Skip to main content

Command Palette

Search for a command to run...

Fixing Slow & Flaky Frontend Tests

Updated
10 min read
Fixing Slow & Flaky Frontend Tests

After noticing a spike in failing Jest builds, I decided to investigate and found that slow and flaky tests were the main culprits. In this article, I go through some tips I discovered for making slow or flaky tests faster and more reliable.

There are two main sections:

So let’s dive in!

Improving slow tests

While researching and investigating our vast test suite, I noticed a few issues that were making tests run slowly. Here’s how we fixed them:

1. Render only what's necessary

Rendering large React components/pages was the most common reason for our tests to run slowly. Therefore, it's important to only render what is truly necessary when running each test.

If you're testing something quite isolated like max characters validation on an input field, you can just render a form (or even the input field itself) instead of the entire page. On the other hand, if you're asserting that an item is added to a table after filling out a form, rendering the parent component might make more sense.

Instead of rendering large tables or data grids in every test (e.g 50 rows), consider limiting the rows to just a handful.

2. Consider other React Testing Library queries:

It's important that we use the byRole query by default to help improve our accessibility testing. However, the byRole query is known to be slow in some scenarios. As per the guidance given by the Testing Library team, if necessary, it may be worth considering alternatives like byLabelText or byText if you start to face performance issues.

3. Consider using hidden: true to skip visibility checks

As mentioned previously, it's best to mimic real user experiences as much as possible. However, if needed, getByRole performance can be improved by setting the hidden option to true which avoids expensive visibility checks. Note that since it skips visibility checks, it will return all elements, even those that are not visible to the user.

getByRole('button', { name: 'Submit', hidden: true });

4. Use MSW (Mock Service Worker) to mock endpoints

Prevent real API calls during testing to eliminate network delays and improve speed.

For more information on how to mock endpoints in the frontend, see Mock service worker (MSW).

5. Avoid userEvent.hover where possible

Hover actions can slow down tests. If necessary, consider using userEvent.hover(button, { pointerEventsCheck: PointerEventsCheckLevel.Never }) as it skips multiple pointer event checks.

Using { pointerEventsCheck: PointerEventsCheckLevel.Never } also comes in useful when you want to hover over a disabled element to ensure a tooltip is displayed. Without setting this option, the test will fail with an error specifying that the element is disabled and we cannot interact with it.

Again, only consider this when you actually face performance issues. It’s always best to simulate real user interactions by default.

6. Optimize component source code

Optimize the source code of the components being rendered. If it’s slow or re-renders often within the browser, it’ll be even slower when running tests.

Complex tests are often a symptom of complex code

Given this 👆, it’s worth taking the time to refactor and simplify the code where possible.

7. Combine assertions

This may seem to be a bit of a contradiction, but sometimes it’s faster to write longer tests rather than multiple smaller ones.

Though we can run into timeouts when we have longer running tests, in most cases you can write fewer, longer tests which actually result in faster overall test suite runs. i.e one single test which takes 2 seconds vs 2 separate tests which take 1.5 seconds each due to setup time.

// BEFORE
it('disables the purchase button while loading the page', async () => {
  const purchaseButton = await screen.findByRole('button', { name: 'Purchase'});
  expect(purchaseButton).toBeDisabled();
});

it('allows the user to purchase stuff', async () => {
  // Fill in some required form fields...
  ...

  expect(purchaseButton).toBeEnabled();

  await userEvent.click(purchaseButton);
  expect(await screen.findByText('Purchased!').toBeInTheDocument();
});

// AFTER
// We can move the assertion from the first test into the second, which shortens test suite time and actually improves readability.
it('allows the user to purchase stuff', async () => {
  const purchaseButton = await screen.findByRole('button', { name: 'Purchase'});
  expect(purchaseButton).toBeDisabled();

  // Fill in some required fields...
  ...

  expect(purchaseButton).toBeEnabled();

  await userEvent.click(purchaseButton);
  expect(await screen.findByText('Purchased!').toBeInTheDocument();
});

8. Avoid using delay in userEvent

This has significant impacts on the performance of tests:

userEvent.setup({ delay: 1000 });

9. Avoid manually reading/writing to the DOM

This can be quite slow in general, especially in JSDOM. React is optimized to do this in the most performant way possible. Therefore, it's best to avoid doing this ourselves.

10. Avoid using regex to find elements

We found using regex instead of strings where unnecessary can add a minor performance overhead.

// ❌ Unnecessary use of regex and has a minor performance impact.
getByRole(‘button’, { name: /save/i })

// ✅ More performant and also prevents UX bugs.
getByRole(‘button’, { name: ‘Save’ })
💡
Opinion time: The first example also has the potential to cause a minor UX bug since if a button's text was changed from "Save" to "save" by mistake, it won't be caught when using regex. You can decide if that’s a big deal or not.

11. Consider .focus() and .paste() instead of userEvent.type

When simulating user typing in tests, userEvent.type can be slow as it carefully simulates each keypress event. For improved performance in cases where you don't need to test specific keyboard interaction behaviors, you can use .focus() and .paste() methods instead.

// ❌ Can be slow for longer input text
const descriptionInput = screen.getByLabelText('Description');

await userEvent.type(
  screen.getByLabelText('Description'),
  'This is a very long description text that would trigger multiple events'
);

// ✅ Faster alternative when you don't need to test keyboard behavior
const descriptionInput = screen.getByLabelText('Description');

// Focus on the input first
descriptionInput.focus();

// Paste the entire value directly
await userEvent.paste(
  descriptionInput,
  'This is a very long description text that would trigger multiple events'
);

12. Use fake timers for components with timeouts or debounce

When testing components that rely on timeouts or debounce, you should use Jest's fake timers to skip the actual waiting time.

🚨
It's important to also call runOnlyPendingTimers before switching to real timers to ensure all pending timers are flushed. If you don't progress the timers and just switch to real timers, the scheduled tasks won't get executed and you may get unexpected behavior.
it('searches and finds a user', async () => {
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });

  jest.useFakeTimers();

  render(<UsersTable />);
  const searchInput = await screen.findByRole('textbox', { name: 'Search users' });

  await user.type(searchInput, 'Jimmy');
  expect(await screen.findByText('Jimmy Mc Nulty')).toBeInTheDocument();

  // Flush any pending timers and switch to real timers to avoid unexpected behavior in subsequent tests.
  jest.runOnlyPendingTimers();
  jest.useRealTimers();
});

For more information on how to use fake timers, see:

Preventing flaky tests

What even is a flaky test? It’s a test that sometimes fails, but if you retry it enough times, it passes, eventually.

Outside of speeding up slow tests, here are a couple of other things we can do to increase the reliability of our tests:

1. Wait for an element to be visible before asserting or performing an action on it

This happens more often when testing UIs which rely on async data. Therefore, it's important to use findBy when working with elements that may appear asynchronously.

// ❌ This test can randomly fail as we don't wait for the element to be visible before asserting.
it('adds a user to the table', async () => {
  await userEvent.click(screen.getByRole('button', { name: 'Add user' }));
  expect(screen.getByText('User added!')).toBeInTheDocument();
});

// ✅ This test passes as we use findBy, which waits for the element first.
it('adds a user to the table', async () => {
  await userEvent.click(await screen.findByRole('button', { name: 'Add user' }));
  expect(screen.getByText('User added!')).toBeInTheDocument();

  // We can use getBy from here on, until another async action occurs.
  expect(screen.getByText('Jimmy Mc Nulty')).toBeInTheDocument();
  expect(screen.getByText('Detective')).toBeInTheDocument();

  // Here's another async action
  await userEvent.click(screen.getByRole('button', { name: 'Update user' }));

  // Need to use findBy here again after performing an async action above.
  expect(await screen.findByText('User updated!')).toBeInTheDocument();
});

2. Avoid asserting loading states on initial load

We often show skeleton loading states while waiting for async data to load. This results in a nice experience as the user knows that something is happening in the background. However, since we use MSW to mock data in our tests, fetching often completes so quickly that the query can’t find the “loading” element by the time the assertion takes place.

Instead, it’s best to find an element you know will be present when the page has completed all required fetching and is fully loaded. This gives you the same result, but is more robust as you are no longer dealing with random timings or render cycles.

// ❌ This assertion can randomly timeout and fail because the loading element never appears in the first place. The fetch completed so quickly!
expect(await screen.findByText('Loading...')).toBeInTheDocument();

// ✅ Instead, find an element you know will only appear when the UI is fully loaded.
expect(
  await screen.findByRole('button', { name: 'Purchase the thing' })
)).toBeInTheDocument();

But I want to test loading states…

If you are solely testing a loading component and are using MSW, you can make use of delay (or page url query params) to simulate a delay in the response, which will allow you to test the loading state in isolation.

it('shows a loading state', async () => {
  server.use(
    http.get('/users', async () => {
      await delay('infinite')
    })
  )

  await screen.findByText('Loading...');
})

3. Disable React Query retries

By default, in an error scenario, React Query will retry three times. This isn’t something you want to happen when running tests that intentionally check for error scenarios. Therefore, it’s best to turn off retries completely when running tests. The easiest way to do this is to create a custom wrapper which you can then reuse across all tests.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // ✅ turns retries off
      retry: false,
    },
  },
})

const wrapper = ({ children }) => (
  <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

See Turn off retries for more information.

4. Reset MSW handlers

When using MSW handlers directly in your tests, it's important to reset the handlers after each test to avoid side effects in subsequent tests. This can be done by calling server.resetHandlers() within the afterEach block.

afterEach(() => {
  server.resetHandlers();
});

it('shows the list of users', async () => {
  server.use(
    http.get('/users', () => {
      return HttpResponse.json({ data: users });
    })
  );

  render(<SomeComponent />);

  // Your test logic...
});

Another option is to add it to setupTests.ts which will run it for each test across your project.

5. Use advanceTimers instead of delay:null when using fake timers

As mentioned in the Testing Library documentation, using delay: null can lead to unexpected behavior in tests. Instead, use advanceTimers to control the timing of your tests more predictably.

6. Remove dead code and tests

It's important to continuously remove code and accompanying tests that are no longer customer facing. Otherwise, they unnecessarily take up valuable CI/CD time and resources.

7. Avoid testing implementation details

If we already have UI based tests to cover features, we likely don’t need to test its implementation details as well (e.g expect(function).toHaveBeenCalled()), which are generally less valuable and often require changes any time you refactor your code.

Measuring and identifying slow tests

We use jest-slow-test-highlighter to highlight slow tests. This runs automatically as part of each test run and will highlight any individual test that takes longer than a 5 seconds to run. The times will vary depending on the device it's being run on (your fast laptop vs a slower CI machine).

Another simple alternative is to use console.time, which is useful to determine exactly which line is causing bottlenecks in your test.

it('should perform some action', () => {
  console.time('test-duration');

  // Your test logic here

  // Logs the time spent on this test
  console.timeEnd('test-duration');
});

Other considerations

Here's a list of other things we considered, but they either didn't seem to make much difference, or we haven't gotten around to trying out yet:

  • Switching from Jest to Vitest.

  • Switch from babel-jest to swc/jest transformer.

  • Disable type checking in Jest by setting isolatedModules to true in jest.config.ts.

  • Switch from JSDOM to happy-dom.

  • Try different variations of Jest's --maxWorkers flag.

Final thoughts

After applying these fixes across our test suite, we saw a noticeable boost in speed and reliability, resulting in far fewer broken CI builds. Hopefully you can apply some of these same techniques to speed up your own tests and make them more reliable.

If you found this article helpful or have additional tips, please leave a comment below. 🙂

Want to see more?

I mainly write about real tech topics I face in my everyday life as a frontend engineer. If this appeals to you then feel free to follow me on BlueSky. 🦋

Bye for now. 👋