Skip to content

(Don't) test your Jotai atoms

If you use Jotai for state management in your React application, you may want to test your atoms. Let's discuss possible scenarios where testing atoms is necessary and how to do it.

Published
Aug 23, 2024
Updated
Aug 23, 2024
Reading time
5 min read

Code Examples

I'm using React Testing Library and User Event in this article. The concepts are applicable to other testing libraries as well.

Don't test atoms

Don't test your Jotai atoms — at least, not directly. Let me explain why.

When testing React components, focus on testing UI behavior and output instead of directly testing specific state management implementations like Jotai atoms. By testing components that consume atoms, you indirectly test atom behavior. This decouples UI testing from state management libraries, making tests more resilient to changes. The goal is to ensure correct user behavior, regardless of internal state management.

For example, if the user clicks on the button to increment a counter, it doesn't matter if the value that is being incremented lives in a useState, a Jotai atom, or some fancy new signal. What matters is that new value is reflected in the UI — and that's what you should test.

test('click on increment button updates the count' async () => {
  const user = userEvent.setup();
  render(<Counter />);

  await user.click(screen.getByRole("button", { name: "Increment" }));

  // We don't care how the counter value is updated,
  // we just care about the latest value in the UI.
  expect(screen.getByText("Counter: 2")).toBeInTheDocument();
});

When to test atoms

There are of course exceptions. Sometimes you want or need to test your atoms.

Insufficient UI state

Your component may interact with global state that is shared across multiple components. In some cases, the UI alone of a single component may not fully reflect the changes made to this state. Therefore, it is important to test not only the UI but also the proper updating of the state to ensure the integrity of other components that rely on it.

In some cases, it may be ideal to have integration tests that cover multiple components to validate interactions between components and ensure that the state is properly updated and shared across components. However, there are situations where writing additional tests on a lower level in the component hierarchy can be beneficial. These tests can be faster and easier to write, providing a more granular level of testing for specific components.

In that scenario, it may be useful to actually test the state in Jotai. You can create a store in your test and pass it to the Provider that wraps your component. After acting on your component, read the atom value from the store and test it.

test("some test", async () => {
  // Arrange
  const user = userEvent.setup();
  const store = createStore();

  render(
    <Provider store={store}>
      <ComponentUnderTest />
    </Provider>
  );

  // Act
  // some interactions ...
  await user.click(someButton);

  // Assert
  // Verify the atom state
  expect(store.get(someAtom)).toBe("foo");
});

Complex atoms

Most atoms are implicitly tested by testing components. But sometimes you may have more complex atoms that do a lot of things, like an atom with a (complex) reducer:

const counterReducer = (state, action) => {
  switch (action.type) {
    case "increment":
      return state + 1;
    case "decrement":
      // Value should not go below 0
      return Math.max(0, state - 1);
    case "reset":
      return 0;
    default:
      return state;
  }
};

const counterAtom = atomWithReducer(0, counterReducer);

Most of the functionality can be covered by component tests, but there are certain scenarios that cannot be fully tested at the component level. For example, in the counter example mentioned above, we want to ensure that the counter value never goes below 0. However, it may not be possible to cover this branch in a component test.

In the case of the counter component, it is likely that the decrement button will be disabled when the counter value is at 0, preventing the user from reaching this branch. While this ensures that the UI component works correctly and prevents the app from entering an invalid state, there is still a possibility that in the future, someone may add a new counter component without disabling the button. In such a scenario, if the reducer does not handle this case properly, the app could end up in an invalid state.

This is where testing the atom itself becomes useful. By testing the state management directly, you can cover cases like ensuring the counter value never goes below 0, even if the UI component does not enforce it. For atoms with reducers, you can directly test the reducer logic without relying on Jotai:

test("does not decrement when 0", () => {
  const decrementAction = { type: "decrement" };
  const state = counterReducer(0, decrementAction);

  expect(state).toBe(0);
});

If you prefer to test the atom directly, you can use a store. This approach also works for other types of atoms, like an atom with a custom setter function:

test('does not decrement when 0', () =>
  const store = createStore();

  store.set(counterAtom, { type: 'decrement' });

  expect(store).toBe(0);
});

Alternatively, you can test it in a custom hook as recommended by the Jotai docs:

test("does not decrement when 0", () => {
  const { result } = renderHook(() => {
    const [value, dispatch] = useAtom(counterAtom);
    return { value, dispatch };
  });

  act(() => result.current.dispatch({ type: "decrement" }));

  expect(result.current.value).toBe(1);
});

By testing the atom directly, you can catch edge cases and ensure the integrity of your state management, providing more robustness to your application.