PL EN

Write testable React components

First, I must start with the problem of unstable tests. Don't pretend that some tests are unstable and that it's good enough to repeat the test. In the long run, this will lead to a decline in the team's confidence in the tests, a reduction in the quality of the test writing and maintenance process, and the potential for real bugs to slip through to production.

If a test is unstable, the team must find the cause and fix the problem. Allowing flaky tests leads to a degradation in code quality and a reduction in the business value of the product, especially when a edge case occurs in production.

How to recognize a poorly written component that is difficult to test?

A component that is difficult to test is usually characterized by one or more of the following components: the need for a long test setup, dependence on multiple vendors or contexts, the need to mock multiple libraries, reckless use of any type or hardcoded values for the test to succeed.

What does a good test look like?

The Arrange, Act, Assert structure (sometimes also called Given–When–Then) is a pattern for organizing tests. Its purpose is to separate test preparation, action execution, and result verification into three distinct steps, which improves the readability, predictability, and maintainability of tests. The name of the test should describe the behavior. If possible, a single test should focus on one action and one verification, avoiding situations where part of the assertion appears in earlier steps.

test('shows greeting message when user is logged in', () => {
  // Arrange
  const mockUser = { name: 'Test user' };
  render(<UserGreeting user={mockUser} />);

  // Act
  const greeting = screen.getByRole('heading');

  // Assert
  expect(heading).toHaveTextContent(mockUser.name);
});

Separate logic from UI

Business logic should be extracted from the component into separate functions or hooks so that they can be tested without rendering the DOM.

// not recommended
export const Counter = () => {
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);
  return <button onClick={increment}>Count: {count}</button>;
}
// better separation
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(c => c + 1);
  return { count, increment };
}

export const Counter = () => {
  const { count, increment } = useCounter();
  return <button onClick={increment}>Count: {count}</button>;
}
// test example
test('useCounter hook increments', () => {
  const { result } = renderHook(() => useCounter(1));
  act(() => { result.current.increment(); });
  expect(result.current.count).toBe(2);
});

Create “smart” and “dumb” components

Smart (or complex) components manage logic and state. They can fetch data, perform side effects, control props, and pass them to other components. Dumb components (also known as stateless or simple components) are only responsible for presenting the UI based on props, without business logic or data fetching. The division of components significantly supports the maintenance of tests, but also the entire application architecture (e.g., coupling).

Choose semantic tags over data-testid

Using WCAG selectors such as getByRole makes tests more resilient to implementation changes.

// avoid it
expect(getByTestId('submit-btn')).toBeInTheDocument();

// recommended
expect(getByRole('button')).toBeInTheDocument();

Composition over conditions

Avoid too many if conditions in a component. Instead, break cases down into separate components.

// avoid it
export const Status = ({ state }: StatusType) => {
  if (state === 'loading') return <Loading />;
  if (state === 'error') return <Error />;
  return <Data />;
}

Each component can be tested separately, reducing the number of variants in a single test.

export const Loading = () => <Loading />; 
export const ErrorPage = () => <Error />; 
export const SuccessPage = ({data}) => <Data data={data} />; 

Remember that testing individual components does not exclude integration testing. Breaking down logic into smaller components simplifies testing, but you still need to check how the elements of the application work together in a broader context. With integration testing, you can check whether a component renders correctly for a given state.

Don't just test happy paths

Tests must also cover specific situations such as errors, missing data, timeouts, etc. Testing only positive scenarios gives a false sense of security, because most errors occur in edge cases.

Mock only what you need to

Mocking dependencies in tests support isolating them, but only mock what actually destabilizes the tests, e.g., network API calls or external service dependencies.

Go back to articles