Snapshot testing is a powerful technique for ensuring that your React components behave as expected over time. With React Testing Library, you can easily implement snapshot testing to verify that your components are rendering correctly. In this article, we will explore the process of snapshot testing with React Testing Library, including why it’s a useful tool and how you can use it effectively in your own projects.
Why you should migrate to RTL?
In my previous post, I explained why you should migrate away from Enzyme and what tools you can build to support this migration. React Testing Library (RTL) is a great choice for migrating from Enzyme because it encourages testing the behaviour of the application from the user’s perspective, rather than testing the internal implementation details. This makes the tests more robust and less prone to breaking when the implementation changes because they are focused on the outcome of an action, rather than testing the details of the implementation. This leads to tests that are more readable, easier to maintain and less brittle. But RTL can become a stumbling block if your project heavily relies on the JSX snapshots, or in other words, you are too lazy to write explicit assertions for your React components and simply do expect(component).toMatchSnapshot()
in all of your tests.
Why RTL is not good for snapshots?
When your snapshot is more than a few dozen lines it’s going to suffer major maintenance issues and slow you and your team down. Remember that tests are all about giving you confidence that you wont ship things that are broken and you’re not going to be able to ensure that very well if you have huge snapshots that nobody will review carefully. I’ve personally experienced this with a snapshot that’s over 640 lines long. Nobody reviews it, the only care anyone puts into it is to nuke it and retake it whenever there’s a change…
Kent C. Dodds (creator of RTL)
React Testing Library is not necessarily “bad” for snapshot testing, but it is not designed to be used primarily for that purpose. Snapshot testing is often used to check that a component’s output remains the same, even if its implementation changes. While with Enzyme, you could simply use shallow API to produce a compact render tree you could match with the snapshot, with React Testing Library you would have to operate with the real DOM. Using Material UI as an example, if you had to snapshot a component that composes DataGrid component from this library, you would receive the following outputs:
// with Enzyme shallow
<DataGrid
rows={[/*Rows data goes here*/]}
columns={[/*Columns goes here*/]}
pageSize={5}
rowsPerPageOptions={[5]}
checkboxSelection
/>
// with RTL
<div
class="MuiDataGrid-root MuiDataGrid-root--densityStandard MuiDataGrid-withBorderColor css-1wgqnsa"
role="grid"
aria-colcount="6"
aria-rowcount="2"
aria-multiselectable="false"
>
<div></div>
<div class="MuiDataGrid-main css-opb0c2">
<div
class="MuiDataGrid-columnHeaders MuiDataGrid-withBorderColor css-1pzw8os"
style="min-height: 56px; max-height: 56px; line-height: 56px;"
>
<div
class="MuiDataGrid-columnHeadersInner css-vcjdx3"
role="rowgroup"
style="transform: translate3d(0px, 0px, 0px);"
>
<div role="row" aria-rowindex="1" class="css-k008qs">
...
I intentionally did not put the entire HTML output here in the example, but you already see the difference. And you can also notice that HTML output contains hashed class names — this means, any time you consume a new version of DataGrid component, you would get a new hash (this methodology is called “scoped class names” and it’s out of scope for this article).
What to do?
A piece of advice from Kent C. Dodds: “ avoid huge snapshots and take smaller, more focused ones. While you’re at it, see if you can actually change it from a snapshot to a more explicit assertion (because you probably can 😉)”. There is also a lint rule in eslint-plugin-jest called “no-large-snapshots” that could be pretty helpful in discouraging large snapshots.
However, I have another solution for you that is future-proof (compatible with the latest versions of React and RTL) and would not require you to re-write all the snapshot tests in your codebase. Moreover, it would be almost identical to the shallow snapshot produced by Enzyme! Word of caution — it works only with Jest, you may need to write another solution if you want to integrate it with something else.
The solution is based on react-shallow-renderer library that is part of React ecosystem (see React documentation). You can be sure that it will work with the latest version of React (at the moment of writing it is 18.2.0) — here you can find the commit from Dan Abramov.
Without further ado, let me introduce you to a solution:
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import { isFragment, isLazy, isPortal, isMemo, isSuspense, isForwardRef } from 'react-is';
class ReactShallowRenderer {
instance = null;
shallowRenderer = null;
constructor(children, { Wrapper = null } = {}) {
this.shallowRenderer = new ShallowRenderer();
this.shallowWrapper = Wrapper
? this.shallowRenderer.render(<Wrapper>{children}</Wrapper>)
: this.shallowRenderer.render(children);
}
getRenderOutput() {
if (!this.shallowWrapper) return undefined;
const getNodeName = node => {
return node.displayName || node.name || '';
};
const getWrappedName = (outerNode, innerNode, wrapperName) => {
const functionName = getNodeName(innerNode);
return outerNode.type.displayName || (functionName !== '' ? `${wrapperName}(${functionName})` : wrapperName);
};
const extractType = node => {
if (!this.shallowWrapper) return this.shallowWrapper;
const getNodeName = node => {
return node.displayName || node.name || '';
};
const getWrappedName = (outerNode, innerNode, wrapperName) => {
const functionName = getNodeName(innerNode);
return outerNode.type.displayName || (functionName !== '' ? `${wrapperName}(${functionName})` : wrapperName);
};
const extractType = node => {
if (typeof node === 'string') return node;
const name = getNodeName(node.type) || node.type || 'Component';
if (isLazy(node)) {
return 'Lazy';
}
if (isMemo(node)) {
return `Memo(${name || extractType(node.type)})`;
}
if (isSuspense(node)) {
return 'Suspense';
}
if (isPortal(node)) {
return 'Portal';
}
if (isFragment(node)) {
return 'Fragment';
}
if (isForwardRef(node)) {
return getWrappedName(node, node.type.render, 'ForwardRef');
}
return name;
};
const transformNode = node => {
const extractProps = ({ children, ...props }, key) => {
const childrenArray = Array.isArray(children) ? children : [children];
return {
children: childrenArray.filter(Boolean).flatMap(transformNode),
props: {
...props,
...(key ? { key } : {}),
},
};
};
if (Array.isArray(node)) {
return node.map(transformNode);
}
if (typeof node !== 'object') {
return node;
}
return {
// this symbol is used by Jest to prettify serialized React test objects: https://github.com/facebook/jest/blob/e0b33b74b5afd738edc183858b5c34053cfc26dd/packages/pretty-format/src/plugins/ReactTestComponent.ts
$$typeof: Symbol.for('react.test.json'),
type: extractType(node),
...extractProps(node.props, node.key),
};
};
return transformNode(this.shallowWrapper);
}
}
The critical part here is how you output the tree. Jest has special requirements to print the React Test object in a prettified fashion:
- Test object needs to have
$$typeof: Symbol.for('react.test.json')
in order to get integrated with the Jest serializer -
type
refers to the name of the node that will be displayed (<ComponentName propA="1234" />
) -
children
is an optional property that takes all children nodes that need to follow the same format, andprops
- component properties that will be displayed as node attributes in the output.
You can wrap it into a utility function and use it in your tests. The simplest example:
const shallowRender = Component => new ReactShallowRenderer(Component).getRenderOutput();
If you are using Redux, you can enhance the example from Redux documentation:
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: PreloadedState<RootState>
store?: AppStore
mode?: 'deep' | 'shallow'
}
export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = configureStore({ reducer: { user: userReducer }, preloadedState }),
mode = 'deep',
...renderOptions
}: ExtendedRenderOptions = {}
) {
function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
return <Provider store={store}>{children}</Provider>
}
let renderOutput;
if (mode === 'shallow') {
renderOutput = {
container: new ShallowRenderer(ui, { Wrapper })
.getRenderOutput(),
};
} else {
renderOutput = render(ui, { wrapper: Wrapper, ...renderOptions });
}
// Return an object with the store and all of RTL's query functions
return { store, ...renderOutput }
}
Here is an example of how you can use it in your tests:
it('should match snapshot', () => {
expect(shallowRender(<MyComponent />)).toMatchSnapshot();
});
it('should match snapshot with Redux', () => {
const {container} = renderWithProviders(<MyComponent />, {mode: 'shallow'});
expect(container).toMatchSnapshot();
});
Conclusion
In conclusion, migrating away from Enzyme to React Testing Library can bring numerous benefits to your React application’s testing process. React Testing Library focuses on testing the behaviour of a component from the user’s perspective, making the tests more robust, readable, and easier to maintain. However, it’s important to keep in mind that React Testing Library operates with the real DOM, and can produce large snapshots, which should be kept small and focused.
Originally published at https://thesametech.com on February 4, 2023.
You can also follow me on Twitter and connect on LinkedIn to get notifications about new posts!
Top comments (1)
ReactShallowRenderer class definition code has some error