DEV Community

Teddy MORIN
Teddy MORIN

Posted on • Originally published at morintd.hashnode.dev on

React Testing Library Configuration for Productive Unit Testing

Cover

Too often, I join a new React project where unit tests are lacking, both in amount and quality. There are a few causes, but the one I want to discuss today is the poor test environment.

Indeed, testing requires skills, thoroughness, and is definitely time-consuming (even if thats worth it!). If testing is more painful than necessary, it becomes a signal to avoid writing tests altogether.

Environment

With React, the tools I recommend are Jest and React Testing Library. Nothing fancy here; they are the de-facto standard in the community.

React Testing: Understand and Choose the Right Tools

Save time and effort with React and React Native by choosing the appropriate testing tools.

favicon morintd.hashnode.dev

Example

To demonstrate how to write great tests, in a good environment, we need a component to test, right? Lets use a common functionality: a counter.

Ill start with the following component, inside its own Counter.tsx file.

import { useState } from 'react';

const Counter = () => {
  const [counter, setCounter] = useState(0);
  const decrement = () => setCounter((count) => count - 1);
  const increment = () => setCounter((count) => count + 1);

  return (
    <div>
      <h1>Counter: {counter}</h1>
      <button onClick={decrement}>-</button>
      <button onClick={increment}>+</button>
    </div>
  );
};

export default Counter;

Enter fullscreen mode Exit fullscreen mode

I would like to specify my application entrypoint is still App.tsx. It will be modified later in this article to demonstrate our configuration!

import Counter from './Counter';

function App() {
  return <Counter />;
}

export default App;

Enter fullscreen mode Exit fullscreen mode

Tests

A complete repository can be found on GitHub.

React testing library

After following the introduction and adding jest-dom, I set up the first version of my test, as shown below:

import { fireEvent, render, screen } from '@testing-library/react';

import Counter from '../Counter';

describe('Counter', () => {
  it('Should display a default value', () => {
    render(<Counter />);
    expect(screen.queryByText('Counter: 0')).toBeInTheDocument();
  });

  it('Should increment', () => {
    render(<Counter />);
    fireEvent.click(screen.getByText('+'));
    expect(screen.queryByText('Counter: 1')).toBeInTheDocument();
  });

  it('Should decrement', () => {
    render(<Counter />);
    fireEvent.click(screen.getByText('-'));
    expect(screen.queryByText('Counter: -1')).toBeInTheDocument();
  });
});

Enter fullscreen mode Exit fullscreen mode

We can find three basic tests. They help us verify weve displayed a default value, which increments or decrements when the corresponding button is clicked.

With the current configuration, Im able to run my test successfully:

Successful tests

But issues arise when working with a bigger codebase, more functionalities, and dependencies. In this article, I would like to demonstrate how we handle Redux and GraphQL which are fairly common.

Adding Redux

Im following RTK Quick Start, which conveniently shows an example with a counter app. I end up with the following slice:

import { createSlice } from '@reduxjs/toolkit';

export interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0,
};

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
  },
});

export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;

Enter fullscreen mode Exit fullscreen mode

The following store:

import { combineReducers, configureStore } from '@reduxjs/toolkit';

import counterReducer from './counter.slice';

export const rootReducer = combineReducers({
  counter: counterReducer,
});

export const store = configureStore({
  reducer: rootReducer,
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Enter fullscreen mode Exit fullscreen mode

And an updated Counter.tsx component:

import { useDispatch, useSelector } from 'react-redux';
import { decrement, increment } from './store/counter.slice';

import { RootState } from './store/store';

const Counter = () => {
  const counter = useSelector((state: RootState) => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <h1>Counter: {counter}</h1>
      <button onClick={() => dispatch(decrement())}>-</button>
      <button onClick={() => dispatch(increment())}>+</button>
    </div>
  );
};

export default Counter;

Enter fullscreen mode Exit fullscreen mode

The behavior of my counter didnt change, but tests are failing with the following error message:

Could not find react-redux context value; please ensure the component is wrapped in a .

Its quite straightforward. We are trying to test a component in isolation, but it needs a react-redux provider to work. I added it to my App.tsx, but from my test perspective, its nowhere to be seen.

Now, for every test, we need to declare a new store and render our component with the Provider from react-redux. Technically, it would work with the following code:

import { fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';

import Counter from '../Counter';
import { rootReducer } from '../store/store';

describe('Counter', () => {
  it('Should display a default value', () => {
    const store = configureStore({ reducer: rootReducer });
    render(
      <Provider store={store}>
        <Counter />
      </Provider>
    );

    expect(screen.queryByText('Counter: 0')).toBeInTheDocument();
  });

  it('Should increment', () => {
    const store = configureStore({ reducer: rootReducer });
    render(
      <Provider store={store}>
        <Counter />
      </Provider>
    );

    fireEvent.click(screen.getByText('+'));
    expect(screen.queryByText('Counter: 1')).toBeInTheDocument();
  });

  it('Should decrement', () => {
    const store = configureStore({ reducer: rootReducer });
    render(
      <Provider store={store}>
        <Counter />
      </Provider>
    );

    fireEvent.click(screen.getByText('-'));
    expect(screen.queryByText('Counter: -1')).toBeInTheDocument();
  });
});

Enter fullscreen mode Exit fullscreen mode

But, is that the right solution? Thats a lot of unneeded boilerplate code. If you ever have more dependencies, your tests will grow exponentially. It makes them hard to write and maintain.

Instead, React Testing Library explains how to set up a Custom Render.

Setup | Testing Library

React Testing Library does not require any configuration to be used. However,

favicon testing-library.com

Setup improvement

After following the Custom Render section, I end up creating a tests/ directory with a testing.tsx file:

import React, { FC, ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { Provider } from 'react-redux';

import { rootReducer } from '../src/store/store';
import { configureStore } from '@reduxjs/toolkit';

const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => {
  const store = configureStore({ reducer: rootReducer });
  return <Provider store={store}>{children}</Provider>;
};

const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => {
  return render(ui, { wrapper: AllTheProviders, ...options });
}

export * from '@testing-library/react';
export { customRender as render };

Enter fullscreen mode Exit fullscreen mode

I add an index.ts file that re-exports everything from my tests/ directory Then, instead of importing my Redux boilerplate code and utilities from @testing-library/react, I import utilities from this directory:

import { fireEvent, render, screen } from '../../tests';
import Counter from '../Counter';

describe('Counter', () => {
  it('Should display a default value', () => {
    render(<Counter />);
    expect(screen.queryByText('Counter: 0')).toBeInTheDocument();
  });

  it('Should increment', () => {
    render(<Counter />);
    fireEvent.click(screen.getByText('+'));
    expect(screen.queryByText('Counter: 1')).toBeInTheDocument();
  });

  it('Should decrement', () => {
    render(<Counter />);
    fireEvent.click(screen.getByText('-'));
    expect(screen.queryByText('Counter: -1')).toBeInTheDocument();
  });
});

Enter fullscreen mode Exit fullscreen mode

Thats much better! But, what if we need to trigger some change to our Redux store during our test? Also, when our app grows, adding dozens of providers inside our testing.tsx will make it harder to read and maintain.

First, I want to make my render more customizable. If you look at the customRender method, you can see it takes some options related to React Testing Library.

We can use those options to customize our providers. Ill allow a new property, providers, which is an object with the data related to our providers. For now, it takes the following:

export type ProvidersRenderOptions = {
  store?: Store;
};

export type CustomRenderOptions = {
  providers?: ProvidersRenderOptions;
};

Enter fullscreen mode Exit fullscreen mode

The implementation looks like this:

import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { Provider } from 'react-redux';

import { rootReducer } from '../src/store/store';
import { configureStore, Store } from '@reduxjs/toolkit';

export type ProvidersRenderOptions = {
  store?: Store;
};

export type CustomRenderOptions = {
  providers?: ProvidersRenderOptions;
};

const AllTheProviders = (options: ProvidersRenderOptions = {}) => ({ children }: { children: React.ReactNode }) => {
  const store = options.store ?? configureStore({ reducer: rootReducer });
  return <Provider store={store}>{children}</Provider>;
};

const customRender = (ui: ReactElement, options: CustomRenderOptions & Omit<RenderOptions, 'wrapper'> = {}) => {
  const { providers, ...others } = options;
  render(ui, { wrapper: AllTheProviders(providers), ...others });
};

export * from '@testing-library/react';
export { customRender as render };

Enter fullscreen mode Exit fullscreen mode

Looks good, Ill even throw in a helper function to build a store:

export const generateStore = (preloadedState: PreloadedState<typeof rootReducer>) => {
  return configureStore({
    preloadedState,
    reducer: rootReducer,
  });
};

Enter fullscreen mode Exit fullscreen mode

This way, Im able to write a more advanced test:

  it('Should display a default value', () => {
    const store = generateStore({ counter: { value: 5 } });
    render(<Counter />, { providers: { store } });
    expect(screen.queryByText('Counter: 5')).toBeInTheDocument();
  });

Enter fullscreen mode Exit fullscreen mode

That looks quite good! But we still have one issue: the testing.tsx file will grow to become unmaintainable with the current state of things.

Instead, I would like to move the declaration for providers to different files, and build the function allTheProviders on the fly. Also, there is a great pattern to build it, function composition!

Let me explain. Instead of having one huge function, I create one for each provider I want to add. These new functions take options, a React node, and return a React node (with potentially a new provider).

This function, for Redux, would look like this:

export const withRedux = (children: ReactElement, options: ProvidersRenderOptions) => {
  return <Provider store={options.store ?? configureStore({ reducer: rootReducer })}>{children}</Provider>;
};

Enter fullscreen mode Exit fullscreen mode

If they have this structure, I can chain them, or in other words, compose them, to build a component with multiple providers. If I split my list of providers, the function dedicated to composing them, and the function AllTheProviders, it looks like the following:

type ComposableProvider = (children: ReactElement, options: ProvidersRenderOptions) => ReactElement;

const providers: ComposableProvider[] = [withRedux];

const composeProviders = (children: ReactElement, options: ProvidersRenderOptions) => {
  return providers.reduce((component, provider) => {
    return provider(component, options);
  }, children);
};

const AllTheProviders = (options: ProvidersRenderOptions = {}) => {
  return ({ children }: { children: ReactElement }) => {
    return composeProviders(children, options);
  };
};

Enter fullscreen mode Exit fullscreen mode

And thats it! This way, we can declare new (composable) provider functions, in a different file, and add them to our list.

Adding GraphQL

Lets improve our demonstration by adding GraphQL. Well add functionalities to load and save the current counter.

My schema and resolvers look like the following:

type Query {
  counter: Int
}

type Mutation {
  setCounter(counter: Int): Int
}


let savedCounter = 0;

export default {
  Query: {
    counter() {
      return savedCounter;
    },
  },
  Mutation: {
    setCounter(_: any, { counter }: { counter: number }) {
      savedCounter = counter;
      return savedCounter;
    },
  },
};

Enter fullscreen mode Exit fullscreen mode

After setting up Apollo on the server and frontend, I added two queries to my counter:

export const GET_COUNTER = gql`
  query counter {
    counter
  }
`;

export const SET_COUNTER = gql`
  mutation setCounter($counter: Int) {
    setCounter(counter: $counter)
  }
`;

Enter fullscreen mode Exit fullscreen mode

Then, I updated my Redux slice, and added two buttons in order to save and load the current counter:

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    load: (state, action: PayloadAction<number>) => {
      state.value = action.payload;
    },
  },
});

export const { increment, decrement, load } = counterSlice.actions;


const Counter = () => {
  const counter = useSelector((state: RootState) => state.counter.value);
  const dispatch = useDispatch();

  const [getCount, query] = useLazyQuery(GET_COUNTER);
  const [mutate, mutation] = useMutation(SET_COUNTER, { variables: { counter } });

  useEffect(() => {
    if (query.data) dispatch(load(query.data.counter));
  }, [query.data]);

  return (
    <div>
      <h1>Counter: {counter}</h1>
      <button onClick={() => dispatch(decrement())}>-</button>
      <button onClick={() => dispatch(increment())}>+</button>
      <button disabled={mutation.loading} onClick={() => mutate()}>
        save
      </button>
      <button disabled={mutation.loading} onClick={() => getCount()}>
        load
      </button>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

But now, just like for Redux, our tests throw an error:

Invariant Violation: Could not find client in the context or passed in as an option. Wrap the root component in an , or pass an ApolloClient instance in via options.

Lets follow the testing section from Apollo, and integrate it into our custom render. We can start by adding an option for GraphQL mocks and create a composable test provider for apollo:

export type ProvidersRenderOptions = {
  store?: Store;
  graphql?: MockedResponse[];
};


import { ReactElement } from 'react';
import { MockedProvider } from '@apollo/client/testing';

import { ProvidersRenderOptions } from '../testing';

export function withApollo(children: ReactElement, options: ProvidersRenderOptions) {
  return (
    <MockedProvider mocks={options.graphql ?? []} addTypename={false}>
      {children}
    </MockedProvider>
  );
}

Enter fullscreen mode Exit fullscreen mode

Then, we can add this composable provider to our providers. Keep in mind the order of providers will have an impact on how our providers are added.

This is related to how function composition works. The most left in the list will be the inner providers, while the most right will be the outer providers.

const providers: ComposableProvider[] = [withRedux, withApollo];

Enter fullscreen mode Exit fullscreen mode

Then, Im able to write the following test:

  it('Should load counter', async () => {
    const query = { request: { query: GET_COUNTER }, result: { data: { counter: 5 } } };
    render(<Counter />, { providers: { graphql: [query] } });

    fireEvent.click(screen.getByText('load'));

    await waitFor(() => {
      expect(screen.queryByText('Counter: 5')).toBeInTheDocument();
    });
  });

  it('Should save counter', async () => {
    const query = {
      request: { query: SET_COUNTER, variables: { counter: 1 } },
      result: jest.fn().mockReturnValue({ data: { setCounter: 1 } }),
    };

    render(<Counter />, { providers: { graphql: [query] } });

    fireEvent.click(screen.getByText('+'));
    fireEvent.click(screen.getByText('save'));

    await waitFor(() => {
      expect(query.result).toHaveBeenCalled();
    });
  });

Enter fullscreen mode Exit fullscreen mode

And thats it! You are now able to write proper tests in a healthy environment. Moreover, you wont have any issues when your app gets bigger, as long as you continue to create composable test providers.

Dont forget a complete repository is available on GitHub.


Cover photo by sharonmccutcheon on Unsplash

Top comments (0)