React Testing Questions That Trip Up Engineers

27 June 2026
#testing#interviews#react

Most frontend engineers, I would hope, write tests every day. Even though now with AI the writing might be more prompting and asking AI to write tests, they're still a crucial part of our work day. And most frontend engineers are very familiar with the patterns for writing frontend tests (such as querying for elements with screen.getByRole, rendering with render(), testing hooks with renderHook()).

But there are some questions which can trip people up when talking about how they test React apps. These can often come up in interviews. They can be a good way to gauge how much people really write their own tests & understand what is going on.

To be honest, I don't like going in-depth about testing when interviewing candidates. It is easy to learn and you don't learn much after one or two questions. But it is a big green flag if you can tell someone really knows their way around writing tests in Jest or Vitest and understands what they're trying to do in a test.

This blog post is intended for mid/intermediate level. A senior frontend engineer who has worked with React apps should be able to nail most of these quite easily.

I'm also assuming that React Testing Library is the main tool (but not only) way that the interview is expecting you to test things!

The test that never fails

How does a test that never fails cause problems? And when might a test never fail?

This isn't about writing a good test that passes.

This is about writing a test that never fails - because it is written in a bad/incorrect way. One that is:

  • testing something that cannot ever break,
  • testing a feature in such a wrong way that it cannot fail
  • or when the test is over mocking.

If you get asked something like this, I find it is best to give a good example of when this happens and then explain why it is so bad.

Testing something that can never break (because the unit under test won't change)

There are a few ways this can come up. The most common is testing that hard coded text is rendered. If there is no conditional way that it either could or couldn't render, there is almost no value in it.

For example in this test:

const Header = () => {
  const triggerModal = useWelcomeModal();
  return (
    <div>
      <h1>Hello!</h1>
      <button onClick={triggerModal}>Open welcome modal</button>
    </div>
  );
};

I would argue very strongly that the following is a pointless test:

expect(screen.getByText('Hello!')).toBeInTheDocument()

It can never fail with your code, and it is just noise adding no value.

But there are cases you would want to use that exact assertion...Show details

Important If however you had a parent component that had some conditional logic, then it absolutely makes sense to run that exact same assertion:

const ParentComponent = () => {
  const user = useUser();
  if (user.isNewUser) return <WelcomeHeader />;
  return <Header />;
};

In this case, you want to verify that Header (which renders "Hello!") appears only when user.isNewUser is false, and is absent when user.isNewUser is true.

test('renders Header when user is not a new user', () => {
  useUserSpy.mockReturnValue({ isNewUser: false });
  render(<ParentComponent />);

  expect(screen.getByText('Hello!')).toBeInTheDocument(); // << exact same assertion as above
});

test('does not render Header when user is a new user', () => {
  useUserSpy.mockReturnValue({ isNewUser: true });

  render(<ParentComponent />);

  expect(screen.queryByText('Hello!')).not.toBeInTheDocument(); // << same, except checking the text is NOT there
});

In the case above then you would want to assert that the text is only visible when the user is not a new user.

Testing something that can never break (because the test is broken)

Another way that a test can never fail is when the test itself is written in a way that it will not fail even if something in the unit you're testing (the component) changes...

That could be because of incorrectly used assertions - in the example below container is always truthy:

test('component renders', () => {
  const { container } = render(<MyComponent />);
  expect(container).toBeTruthy(); // This will always pass
});

Or just general bugs - this isn't making an assertion about what was rendered, just that the button element is equal to itself:

test('button exists', () => {
  render(<Button>Click me</Button>);
  const button = screen.queryByRole('button');
  expect(button).toBe(button); // always true
});

Testing something that can never break (because of over use of mocks)

When you over use mocks, you can make your tests borderline pointless.

In the example below we are asserting that the mocked api.getUser function returns mockData. All we are testing is that the jest.spyOn() worked correctly, and we're not testing our app at all:

test('API returns user data', async () => {
  const mockData = { id: 1, name: 'Jane' };
  jest.spyOn(api, 'getUser').mockResolvedValue(mockData);
  const result = await api.getUser();
  expect(result).toEqual(mockData); // Just testing the mock
});

Or in this example the title doesn't match what we're asserting. The test will always pass because we are asserting the form was not submitted...

test('form submits data', () => {
  const mockSubmit = jest.fn();
  render(<Form onSubmit={mockSubmit} />);

  expect(mockSubmit).toHaveBeenCalledTimes(0); // << bug! doesn't match intention of this test title
});
Another gotcha: testing for negationShow details

Another gotcha to be aware of is when testing that something did not render (see testing for negation in Vitest or Jest).

You could put the following assertion on absolutely any test in your codebase and it will pass:

render(<SomeComponent />);

expect(screen.queryByText('this is never in the dom')).toBeNull(); // << will pass on 100% of any React test ever

Asserting when things are not present is always prone to false positives.

My favourite way to get around this: use test.each([true, false])(...) so the same test (with a minor condition) asserts the element is both present and absent. The present branch proves the query can find it, which is what makes the absent branch trustworthy.

test.each([true, false])('tests that x appears only when y - testing %s condition', isPresent => {
  render(<Component showTitle={isPresent} />);

  const result = screen.queryByText('something');

  if (isPresent) expect(result).toBeInTheDocument();
  else expect(result).toBeNull();
});

Remember: you want tests to fail when something changed (otherwise where is the value in it)

Why these tests which can never fail are bad:

These sorts of tests have problems such as:

  • they give you false confidence about your tests and their code coverage (yes it is covering some of your app, but it is a test with very little value).
  • more to maintain, more to update in the future
  • more tests to run, slower test runs
  • And the time wasted on them could be spent on writing good quality tests that give you more value

There are two ways to avoid writing these sorts of tests:

  • Write your tests first (so they fail initially), then do the implementation (then the tests should pass)
  • After writing your tests, delete the implementation. Do any of your tests continue to pass? If so they're likely not testing correctly
    • Note: this is more a hypothetical, and should not be done as part of your writing test process. But it can prove if a test is testing the correct thing

What does "testing implementation details" really mean?

This is a classic interview question, as it is one of the mantras chanted for good testing practices. I thought about not including it here as it is quite a fundamental topic - but if you are using this blog post as a way to practice for a frontend engineer interview, then I thought it might help you rehearse this.

The way that I define testing implementation details is that if you did a refactor of the internal code, but the user interacted in the same way, would your tests break? If so, you are probably testing implementation details.

Your tests, in a perfect world, should only interact with public methods. For backend code this is often easier (you can often just think about public methods on classes or exported functions - although this is often too simple of a way to decide what needs testing). For frontend apps, the public API is the interactive components and what gets rendered.

As a very contrived example, let's say you had a counter:

const Counter = () => {
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

If you had a test which, for some unknown reason (other than to make this blog post a bit simpler), you spyOn React.useState like this:

test('counter calls setState when incremented', () => {
  const setStateMock = jest.fn();

  // DO NOT DO THIS - just for a simple blog post example!
  jest.spyOn(React, 'useState').mockReturnValue([0, setStateMock]);

  render(<Counter />);
  const button = screen.getByRole('button', { name: /increment/i });
  fireEvent.click(button);

  expect(setStateMock).toHaveBeenCalled();
});

(A better example would be if you were testing against Redux internal store values, but that is a bit more code to show)

If you now refactor the internals to use useReducer, or redux/zustand etc, this test completely breaks as we were testing implementation details, even though for a user nothing changed so the tests should continue to pass!

(If they continue to pass without editing the tests, then we can be confident our refactors of internal implementations were safe!)

If however you did a (much more normal looking - no weird useState mock, and testing only using public interfaces) test like this:

test('counter increments when button is clicked', () => {
  render(<Counter />);
  const button = screen.getByRole('button', { name: /increment/i });

  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  fireEvent.click(button);

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

You can refactor the internals as much as you like and the test will continue to pass.

Just test public methods?Show details

A slight red flag to avoid when answering this: don't answer saying "don't test private methods". While it is often correct, it is too simple (and doesn't relate directly to frontend React components).

If you get in the habit of every single public method on classes needs a test you can often end up testing too much in isolation and potentially over testing every single component by itself. You can often get more value by integration tests in the places that use the components. They of course (internal implementation details) will be using the public methods.

Lightning round of React Testing Library questions - standard FE job interview questions about tests that always come up

If you are being interviewed for a React based job and they want to question you about writing tests, there are a few topics which are quite straightforward but always come up.

  • Questions about getByText() vs getByRole() vs getByLabelText() etc. You should be familiar with most of the query functions in RTL
  • getBy vs queryBy vs findBy and when to use them (or on RTL docs ). If you use React Testing Library, this should be second nature for you. Make sure you know the differences.
  • userEvent vs fireEvent (official docs links ) - again this should be simple to answer if you use RTL often.
  • What is waitFor() used for? Bonus points to talk about how you can often remove a waitFor and replace your getBy... with an await findBy....
  • Should you use data-testid to find elements? The correct answer is normally "as a last resort" or "as an escape hatch". The reason: It is better to use matchers like .getByRole() as that is how a real user finds elements on the page (looking for something that looks and acts like a button, for example).
  • Should you use snapshots? Very subjective. Make sure to have an opinion. They can easily be overused, very good in certain situations. Make sure you have experience playing with them. If you are a big fan, I'd question if you've ever maintained a large codebase with heavy use of snapshots for a long period of time, as they often become a source of many useless tests.

What is act() actually doing?

What is the act() function and when do you need to use it in React tests?

You can write tests for React apps and never use act() directly.

You might see the "not wrapped in act()" warning - but if you ignore it your tests can still pass.

Almost all codebases end up with some of those warnings in their CI/CD output. Sometimes 1000s of lines!

It is a code smell, and you should avoid the warnings.

But what is act() actually doing?

It lets you ensure that any queued up state changes are run (and any required re-renders finished).

The danger of ignoring those not wrapped in act() warnings is that you might be asserting what is rendered on the screen, but at the next re-render (after state changed) the user might see something else. This gives you a false positive.

Important to note (this can trip people up in interviews): Using E2E tests (Playwright or Cypress), or using Vitest Browser Mode avoids the need for act() due to it really re-rendering in the browser and the async behaviour of almost all actions and assertions in those tests.

Other important things to know about act()Show details

There are two important things to know as well:

  1. the userEvent functions like .click(), or the waitFor() do NOT require being wrapped in act() as internally they do it themselves
  2. there is more than one place to import act() from (React Testing Library, and either from react, or from react-dom/test-utils on older versions of React). Use the one re-exported by React Testing Library.

If you want to find out more, I wrote a really in-depth article explaining absolutely everything you need to know about the act() function here.

How would you test a debounced input? Often harder than it first looks

What is a debounced input?Show details

A debounced input delays the execution of a function (like an API call) until after the user stops typing for a period of time. For example, with a 300ms debounce, the search function only runs 300ms after the user's last keystroke.

This prevents multiple API calls after every keystroke, and just sends one after the user has stopped typing.

Say you have this component:

Note: this is not a good debounce implementation, but here just for simplicity
import { useState, useCallback } from 'react';
import { debounce } from 'lodash';

const Search = ({ onSearch }: { onSearch: (q: string) => void }) => {
  const debouncedSearch = useCallback(debounce(onSearch, 300), [onSearch]);

  return <input placeholder="Search..." onChange={e => debouncedSearch(e.target.value)} />;
};

It is a red flag if you try to test this by typing in the text input and then just asserting that the onSearch prop was called (because it won't work due to the time delay!)

It is another red flag if you type and then use await waitFor(() => expect(onSearch).toHaveBeenCalled()). If you do this, then the debounce is running in real time, and it is almost certain the interviewer is not expecting this approach.

In an interview there are a few things to remember to think about and the interviewer is probably keen to hear you talk about:

  • fake timers
  • how to set up and configure those fake timers with userEvent
  • how you would use act()

The correct way to test this is to use fake timers. They work very similarly in both jest and vitest (I have a lesson on fake timers and fake system time here).

One gotcha here though is that because you are using fake timers, the userEvent interactions almost never work, so you have to configure userEvent.setup first.

Show how to test that debounced component with RTL and fake timersShow details

Here is how you would test that component correctly:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Search } from './Search';

beforeEach(() => {
  // set up jest to use fake timers, so setTimeout etc won't really run
  jest.useFakeTimers();
});

afterEach(() => {
  // clean up - technically this is optional but good practice
  jest.runOnlyPendingTimers();
  jest.useRealTimers();
});

it('calls onSearch after the debounce delay', async () => {
  const onSearch = jest.fn();
  // because userEvent itself uses fake timers, we need to configure it so it can run
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });

  render(<Search onSearch={onSearch} />);

  // type as normal...
  await user.type(screen.getByPlaceholderText('Search...'), 'react');

  expect(onSearch).not.toHaveBeenCalled(); // still debouncing

  // wrap the advance of timers in act() as we expect state changes here
  act(() => {
    jest.advanceTimersByTime(300);
  });

  // by now the debounced function would have executed
  expect(onSearch).toHaveBeenCalledWith('react');
});

When does mocking cause problems in your tests?

This is a very open ended question but really important one! Over mocking is probably one of the biggest causes of hard to maintain or low value tests so it is a great topic to discuss and can often get a good feel if someone has lots of experience maintaining a large test suite by their level of opinions on over mocking.

Note: Depending on the level of experience, if it is clear they have not worked much with tests I might downgrade this just down to what is the difference between jest.mock(), jest.spyOn(), and the jest.fn() function (or the vitest equivalents). They are quite basic things that should be easy to talk about.

This question can be interpreted in a few ways because of the different ways to mock in Jest or Vitest.

  • mocking of imports with jest.mock('some-import')
  • using jest.spyOn() like jest.spyOn(module, 'someFn') and then setting mock data.
  • using the jest.fn() function (which the spyOn also returns) to set mock return value, assert functions were called etc
  • and also just manually creating dummy data and passing/injecting it into the thing you're testing like the snippet below
// Instead of testing the real onSubmit behaviour
<Form onSubmit={mockSubmit} />;

// Or mocking a hook's return value
jest.mock('./useAuth', () => ({
  useAuth: () => ({ user: { id: 1, name: 'Mock User' }, isAuthenticated: true }),
}));

Either way - the problems happen because if you are mocking too much you are not testing the real app.

Sometimes test files can get crazy and you end up with so many mocks, sometimes with their own versions of the implementation, that you end up testing more of your mocks than real code.

It is so easy for mocks to diverge from the real implementation.

And mocks (like the first point in this blog post) can often be set up so they never fail.

There are times that you should mock though

Mocking things that aren't in your code is good practice, for example browser APIs, third-party modules, or things like API call responses.

What should you avoid putting in E2E tests

What kinds of things should you avoid over-testing in E2E tests, and why are they better suited for unit or integration tests?

I am skipping another classic question - "what is the difference between unit, integration and e2e test". Make sure you can clearly explain this one as it comes up in a lot of interviews the second the topic moves to tests.

End-to-end tests (E2E) test your app with a real browser, often with Playwright or Cypress runners.

E2E tests have a lot of value. They are often the only test that really proves your application is even working. They are a critical part of a suite of tests.

But generally E2E tests are more suitable for:

  • the critical flows in your app
  • the general happy and sad paths
  • or to test things which are impossible in normal Jest or Vitest tests (like Web APIs) - although with Vitest Browser Mode you can often get the best of both.

You don't get great value in E2E tests if you over test in them.

Why?

  • They are much slower to run. E2E tests can often (but not always) be significantly slower to run, while Jest/Vitest tests run (often, but not always) much faster
  • They are more flaky because network issues, timing problems, or environmental differences can cause failures. It is often needed to re-run on failures to avoid a flaky test failing the entire CI/CD pipeline
  • They are (often) more time consuming to maintain because when they break, debugging requires running a real browser and investigating across multiple layers
  • They are harder to debug (especially when they fail only on CI/CD)

Regular tests in Jest/Vitest (or Vitest Browser Mode) are often more suitable for detailed testing of components and edge cases.

Things to avoid:

  • don't test every combination of a component.
    • Test the main happy path, maybe a sad (error) path.
    • But it is generally good practice to test the other combinations in regular tests in Jest/Vitest with React Testing Library or Vitest Browser Mode.
    • It can be much faster to write the tests and easier during local development
  • avoid having to seed a database with specific data.
    • An example is a list of orders page... If you are calling a real API, and you need to assert a specific number of transactions on each page, it causes so many headaches.
    • This sort of stuff is best to leave to regular (non-E2E) tests

Testing a component that uses IntersectionObserver - where would you even start?

How would you test a component that uses IntersectionObserver, and what challenges does it present?

What is IntersectionObserverShow details

The IntersectionObserver API allows you to observe when an element enters or exits the viewport (or another element). Often used for lazy loading images or infinite scroll.

An interviewer asking this question is often listening for the mention that jsdom (the mock environment your tests run in with React Testing Library) has no IntersectionObserver support. If you use it in a test you will get a ReferenceError: IntersectionObserver is not defined error.

Once you've established that, you have a couple of ways to test something that uses it (and you will need to figure out which direction to take the conversation):

  • Run it in a real browser to give access to all Web APIs
  • mock it in jsdom (or whatever environment you run your tests with)

Potential bonus points (depending on interviewer): there are premade mocks for most common Web APIs, and jsdom-testing-mocks is the main one for this and a few other Web APIs (like ResizeObserver or matchMedia).

Saying that, you probably also need to have an idea of how to actually mock it from scratch.

Full code to set up a mock Intersection Observer in Jest/Vitest for use in React Testing LibraryShow details

You can use something like this:

export function mockIntersectionObserver() {
  const observers = new Map<Element, IntersectionObserverCallback>();

  class MockIO {
    constructor(private callback: IntersectionObserverCallback) {}
    observe = (el: Element) => observers.set(el, this.callback);
    unobserve = (el: Element) => observers.delete(el);
    disconnect = () => observers.clear();
  }

  // bare `new IntersectionObserver()` resolves against the global
  vi.stubGlobal('IntersectionObserver', MockIO);
  // Jest: Object.defineProperty(globalThis, 'IntersectionObserver',
  //   { writable: true, configurable: true, value: MockIO });

  const triggerIntersection = (el: Element, isIntersecting: boolean) =>
    observers.get(el)?.(
      [{ isIntersecting, target: el } as IntersectionObserverEntry],
      {} as IntersectionObserver
    );

  return { triggerIntersection };
}

Some red flags would be if the answer turns to mocking scroll positions in jsdom. Mocking is ok here as it is an external API which you have no control over.

Sometimes tests are about making compromises. And these Web APIs are always going to have some compromise when testing in React Testing Library.

Testing drag and drop - a question no one really prepares for in an interview

How would you test drag and drop functionality in a React component?

Unless you have just implemented drag and drop from scratch and written tests for it recently, describing how you would test some drag and drop functionality is quite a tricky question to nail in an interview.

If the feature is using an existing library (such as @dnd-kit) then there isn't much value in testing the library itself, so you can get away with mocking it.

But, let's say you do want to use it. The trick is to think less 'drag and drop' and more about other accessible ways to write tests. Then you can trigger the library's drag and drop handler function to test your app code handles the moving around correctly.

This isn't perfect. There would be bugs you miss by only testing keyboard interaction. But the trade off of a clean test that is easy to maintain is usually worth it in this sort of situation.

If you have implemented any interactive widget correctly, then keyboard users should be able to use it too. And libraries such as @dnd-kit have great keyboard support built in, and using fireEvent.keyDown(...) is, in my opinion, the cleanest way to test this sort of behaviour.

it('moves an item down the list with the keyboard', () => {
  render(<SortableList items={['A', 'B', 'C']} />);

  const handle = screen.getByRole('button', { name: 'Drag A' });

  act(() => handle.focus());
  fireEvent.keyDown(handle, { key: ' ', code: 'Space' }); // pick up
  fireEvent.keyDown(handle, { key: 'ArrowDown', code: 'ArrowDown' }); // move down
  fireEvent.keyDown(handle, { key: ' ', code: 'Space' }); // drop

  expect(screen.getAllByRole('listitem').map(li => li.textContent)).toEqual(['B', 'A', 'C']);
});

If you are pushed to test the mouse or tap behaviour (after all, most users do use a mouse or tap) then to prove it really works I would say you have to use a real browser (in an E2E test or Vitest Browser Mode).

Red flags:

  • trying to simulate the drag in React Testing Library with mouseDown → mouseMove → mouseUp. In jsdom it won't work, because there is no real layout for the library to read positions from
  • another red flag is claiming there is no point testing it, because it is an external library. You still want to test your behaviour around the library's actions (i.e. your code), not the library's internals.

Like with most things in testing, again it is a bit of a compromise to write a test which has good value but with as few trade-offs as possible.

That's it!

Ok, that's it. None of these are trick questions, and almost all of these are quite easy once you have experience writing a lot of tests. With AI of course things have changed a little, as AI is more than capable of writing tests (although see my previous blog post about code smells in AI generated tests for React apps...)

Got an interview coming up? Reach out after and let me know what sort of questions about frontend testing came up, I'd be interested (and will update the blog!)

Found this useful? Share this article

TwitterLinkedIn Facebook

🎯 Become the best at testing FE apps:
Get the HowToTestFrontend.com newsletter

Stop wasting hours debugging flaky tests.

Join hundreds of developers who get practical, battle-tested testing strategies straight to their inbox.

Every issue includes real-world code examples, Vitest best practices (including Vitest Browser Mode), testing patterns that actually work, and e2e testing strategies you can implement immediately.

The #1 resource to keep up to date with new FE testing trends and news

Sent every 2-3 weeks. No spam - just useful tips that make your tests better

I have courses on how to test frontend apps. Want to join?

Interactive lessons that help you ship with confidence in your tests, and become the expert on your team at understanding how to test frontend apps correctly.

Join now