To read more articles like this, visit my blog
Testing is an essential aspect of any severe application. Testing is not only to improve coverage; the primary purpose is to test real-world usage as closely as possible.
Recently, I had to set up the testing architecture for a large ReactJS project.
Let me show you how I did it. It’s incomplete, but I wanted to share the progress with you.
Why?
The project was already using Enzyme for testing.
It’s a good library but react-testing-library
is a better alternative for testing react components. The React team also recommends it.
As many engineers are working on the same project on different parts, It was imperative to establish a common framework for common use cases.
The Scenarios
Testing is a very, very important part of any good React application. And react-testing-library is the recommended way of testing any modern React application.
Although react-testing-library makes it really easy and intuitive to test components based on their behavior, sometimes setting up a component for testing can get tricky.
Let’s see how we can solve different issues.
Scenario 1: Testing Redux Connected Components
Testing pure components that are only controlled by props is easy to test. But, more often than not, that’s not the case.
If your component depends on the redux state, you can’t test all behaviors without connecting to the redux state.
So what can we do?
First, we need to create a reusable function to render a component. It’s kinda similar to the render prop pattern in ReactJS.
It will take a store and an initialState as an argument. These are the values of the redux store that you want to test your component with.
import { render, RenderOptions } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import {initialState as reducerInitialState , reducer, store} from 'reducer';
type RenderConnectedInterface = {
initialState: Partial<typeof reducerInitialState>;
store?: typeof store;
} & RenderOptions;
const renderConnected = (
ui: React.ReactElement,
{
initialState = reducerInitialState,
store = createStore(reducers, initialState),
...renderOptions
}: RenderConnectedInterface = {} as RenderConnectedInterface
) => {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<Provider store={store}>
{children}
</Provider>
);
return render(ui, { wrapper: Wrapper, ...renderOptions });
};
export default renderConnected;
Basically, we are taking the store and an initialState as a parameter of the function.
Then we wrap the passed component with the Redux Provider .
Now instead of using the default render method provided by react-testing-library , you can test your component with renderConnected function and pass the piece of the store that you want.
import { screen } from '@testing-library/react';
import React from 'react';
import SomeComponent from './SomeComponent.tsx';
import renderConnected from 'utils/renderConnected';
describe('Test SomeComponent', () => {
const initialState = {
name : "your name"
};
it('should show the name properly', () => {
const props = {
// ... pass any additional prop
};
renderConnected(<SomeComponent {...props} />, { initialState });
expect(screen.getByText("your name")).toBeDefined();
});
});
Awesome right?
Scenario 2: Using UI Library and Customized Theme
But the problem doesn’t end there. Many times we need to wrap our root component with many types of providers.
One example is the ThemeProvider of material-ui or styled-components .
<ThemeProvider theme={theme}>
<CustomCheckbox defaultChecked />
</ThemeProvider>
Now if you want to test a component’s functionality that uses the values passed by these Providers, your tests will fail!
We can use the same concept to alleviate this issue and wrap our root component with the theme provider.
So to alleviate that, let’s adjust the renderConnected function to wrap the components with ThemeProvider.
import { render, RenderOptions } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import {initialState as reducerInitialState , reducer, store} from 'reducer';
// ---- NOTICE HERE
import { ThemeProvider } from 'styled-components';
import {customTheme} from './customTheme'
type RenderConnectedInterface = {
initialState: Partial<typeof reducerInitialState>;
store?: typeof store;
} & RenderOptions;
const renderConnected = (
ui: React.ReactElement,
{
initialState = reducerInitialState,
store = createStore(reducers, initialState),
...renderOptions
}: RenderConnectedInterface = {} as RenderConnectedInterface
) => {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider theme={customTheme}> // .................... NOTICE HERE......................... <-
<Provider store={store}>
{children}
</Provider>
</ThemeProvider>
);
return render(ui, { wrapper: Wrapper, ...renderOptions });
};
export default renderConnected;
Now we can pass any component, and our tests will pass.
Scenario 3: Testing with React Router
It’s a really common practice to navigate to a new route once any action is completed.
Let’s say you want your users to be re-directed to the home page after the login is successful.
How can we do that?
We can take advantage of the MemoryRouter provided by react-router We can pass down the URL path and test our component.
We will see how it works later, but let’s add that to the code first!
The modified version of the renderConnected will look like this
// .. same as before
import { MemoryRouter } from 'react-router-dom';
type RenderConnectedInterface = {
initialState: Partial<typeof reducerInitialState>;
store?: typeof store;
route?: string; // new one!
} & RenderOptions;
const renderConnected = (
ui: React.ReactElement,
{
initialState = reducerInitialState,
store = createStore(reducers, initialState),
route = '/', // new addition
...renderOptions
}: RenderConnectedInterface = {} as RenderConnectedInterface
) => {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider theme={customTheme}>
<Provider store={store}>
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter> // new addition!
</Provider>
</ThemeProvider>
);
return render(ui, { wrapper: Wrapper, ...renderOptions });
};
export default renderConnected;
Notice we are passing a new parameter route into the function now. We are also wrapping our children with the MemoryRouter provided by the react-router.
Test the navigation
Let’s say you are testing a FirstPage that, on the click of a button, navigates to another SecondPage . And you want to test this behavior.
But the problem is that SecondComponent is not mounted yet…. right?
One way to do that is to mock useNavigation or use the history object of react-router.
But there is a simpler way. We will use Router from react-router-dom mount a dummy component for the second URL path and ensure that it comes into the picture.
it('Test navigation' , () => {
const ui = renderConnected(
<>
<FirstPage />
<Route path="/second-page">Second Page</Route>
</>,
{ initialState }
);
expect(screen.queryByText('Second Page')).toBeNull();
expect(screen.getByText(/First Page/)).toBeDefined();
// do the action
const button = utils.getByText(/Submit/);
fireEvent.click(button);
// the navigation should occur at this point and we should be on the second page
expect(screen.getByText(/Second Page/)).toBeDefined();
expect(screen.queryByText(/First Page/)).toBeNull();
})
Now, your tests will pass, and you will be greeted with those sweet green checkboxes!
What to do next?
These are the three scenarios that I have had to encounter so far. But you get the basic idea.
Whenever you face a new scenario or need to integrate some other library, think of how to add that to our magical renderConnected function.
And you and your team will be happy!
Conclusion
I have shown you three of the most common use cases of how we can leverage the concept of composition
to solve these testing scenarios in ReactJS.
I hope you learned something new today! Have a wonderful Day! :D
Have something to say? Get in touch with me via LinkedIn or Personal Website