When it comes to unit testing, business logic is considered the most valuable area of your code to test because if there is an error, heads will roll 😵. This was apparent on a React Native project I recently worked on that had plenty of unit tests for the Redux part of the app - reducers, sagas, and selectors, but the overall code coverage was not great 🤔. There was a lot of presentation logic and components that were not covered. So when I joined, the goal was to bring the overall coverage up by unit testing 👨🏼💻the remaining code.
After reading that, you may have asked yourself: “is it a good idea to add tests for the sake of increasing coverage?” While the pragmatic answer is 'no', sometimes you need to accept that some lines of code or source files are not worth testing. They may be too declarative or maybe you have another code quality analysis tooling in place that increases code quality at no cost to your team’s productivity and codebase.
For this project, we used three such tools to improve code quality: Facebook’s Flow type checker, Eslint and SonarQube. Keep this in mind, because the rest of the post may be interpreted as gaming the test coverage numbers. Here we go!
The structure of our application was pretty simple: every screen of our app had a redux-connected container, a corresponding component, and a number of additional helper components to implement the page, separate concerns, keep each screen simple, and eliminate duplication. We split these screens amongst our team-members and each member started writing component tests that looked like this:
import React from 'react';
import { shallow } from 'enzyme';
import { MyComponent } from './my.component.js';
describe(`Component: MyComponent`, () => {
test(`MyComponent renders with default props`, () => {
const wrapper = shallow(<MyComponent />);
expect(wrapper).toMatchSnapshot();
});
});
A certain pattern started emerging where we would first render a component without any props, and expect that it will not crash during rendering. This type of test is very effective in terms of improving coverage numbers with only a small amount of effort. You can also generalize this test and test many components at once. To implement that, export all of your application’s components from a single file, such as ./all-components.js, and run the same test for all of them in a for loop. Best of all, it’s very easy to add new components to the test, requires very little effort to implement, and it dramatically 😲 reduces repetitive boilerplate test code.
import React from 'react';
import { shallow } from 'enzyme';
import * as components from './all-components;
// if something, that could be mocked, is failing your test, jest.mock it
Object.keys(components).forEach(componentName => {
const Component = components[componentName];
describe(`Component: ${componentName}`, () => {
test(`${componentName} renders with default props`, () => {
const wrapper = shallow(<Component />);
expect(wrapper).toMatchSnapshot();
});
});
});
When running this test, the output would look like this ⬇️
PASS ./render-components-with-default-props.test.js (0.698s)
Component: MyComponent
✓ MyComponent renders without props (0.390ms)
Component: MyOtherComponent
✓ MyOtherComponent renders without props (0.308ms)
The only downside to this approach is that it does not cover conditional code paths within each component. For example, if your component has an if statement, this test would not produce 100% coverage. But it’s definitely better than 0% coverage, am I right? 😁
Does this approach work for containers?
Yes! In a similar manner, you can quickly provide coverage for a lot of container React components too. What makes them different from regular components is that they are connected to the redux store. Luckily, the store is pretty easy to mock.
import React from 'react';
import { shallow } from 'enzyme';
import configureStore from 'redux-mock-store';
import { rootReducer } from '../reducer';
import * as containers from './all-containers;
// if something, that could be mocked, is failing your test, jest.mock it
// the main difference between presentation components and containers
// is that containers are connected to a redux store;
// if you render a connected component without a store, react will yell at you.
// This is why we are going to create a mock store with initial state of our app.
const mockStore = configureStore();
const mockState = rootReducer(undefined, { type: 'test-action' });
Object.keys(containers).forEach((containerName) => {
const Container = containers[containerName];
describe(`Container: ${containerName}`, () => {
test(`${containerName} renders with default props`, () => {
const wrapper = shallow(
<Container store={mockStore(mockState)} />,
);
expect(wrapper).toBeDefined();
// if you are using recompose or other Higher Order Components,
// then you might need to wrapper.dive() here;
// if you are not using HOCs, then you might want
// to use toMatchSnapshot here to make your assertion more useful
});
});
});
This approach requires default props
This approach makes a 🚨big assumption here that your components can render without any props being passed into it. Sometimes this isn’t possible, and you’ll have to test those components manually. From my experience, it’s a good practice to have smart default values for each component prop. This approach not only makes for a much nicer development experience, it also allows you to continue using this automatic test approach for your components.
Here’s an example on how *not* to define a default value for a prop in your component:
const MyComponent = ({ myProp = 'Default Value' }) => { ... };
The snippet above defines a default function parameter for our component, which essentially acts as an if statement that 🤢 requires coverage for both branches! To avoid this, use defaultProps instead, like the example below: 👍
const MyComponent = ({ myProp }) => {...};MyComponent.defaultProps = { myProp: 'Default Value' };
If you see an error that does not fail this testReact usually throws an exception when there is a critical error in your component. However, if the code in your component does not produce a critical error, React will simply log an error or a warning message instead ⚠️. These errors would not be caught by our naive test. So, to check for non-critical errors placed in logs and break the tests correctly you will have to spy on the console object. Below is an example on how to spy on console.error and assert that no non-critical errors have occurred.
const consoleError = jest.spyOn(global.console, 'error');expect(consoleError).not.toHaveBeenCalled();
You might also want to watch out for console.warn as well. Don’t forget to consoleError.mockRestore() after you’re done.
What inspired me to take this approach?
Previously I've used @compositor/kit-snapshot. This tool helps to avoid the boilerplate associated with component testing. It takes usage examples and produces jest snapshots. Consider this button component example:
import React from 'react'
import Button from './my-button'
export default (
<Button>Beep</Button>
)
The compositor tool can snapshot examples such as this one automatically. All you need is to gather all your examples in one module, same as ./all-components, and use the snapshotExamples function:
import snapshotExamples from '@compositor/kit-snapshot'
import * as examples from './all-components’
snapshotExamples(examples)
Conclusion
Although the implementation I presented was simple, it’s also unconventional 🧐The typical way would be to create test files for every React component and have the same boilerplate test code copy-pasted into every test file 😒. You can use this simple implementation from project kick-off when you don’t have any tests or introduce it into an existing project that already has partial unit test coverage. Alternatively, you could take the best of both worlds and have default-props tests and combine them with additional test cases as you need to increase coverage!