Writing unit tests with React, Typescript, and react-testing-library
The company I work for started embracing Typescript as a go-to solution for writing React. During code reviews, I noticed a lot of people had problems while testing their components. While looking at the code, I noticed that it was written in such a way that made Typescript look more like a burden and not a tool that assists you while writing code. Having some experience with Typescript I came up with a pattern for writing tests which, in my opinion, avoids unnecessary repetition and makes them clear.
Example Component
This is the component we are going to test. It is quite simple but contains enough logic so that we can use a couple of features of jest and react-testing-library .
import React from "react"; import Todo > from "./Todo"; type Props = id: number; onClick: (todo: Todo) => void; >; type State = fetchState: "loading" | "error" | "success"; todo: Todo | undefined; >; function Todo( id, onClick >: Props) const [state, setState] = React.useStateState>( fetchState: "loading", todo: undefined >); React.useEffect(() => function fetchTodo() fetch(`https://jsonplaceholder.typicode.com/todos/$id>`) .thenTodo>(response => response.json()) // Normally we would probably check if the component // is still mounted here, before using `setState` .then(todo => setState( todo, fetchState: "success" >)) .catch(() => setState( todo: undefined, fetchState: "error" >)); > fetchTodo(); >, [id]); if (state.fetchState == "loading" || !state.todo) return p>loading . p>; if (state.fetchState == "error") return p>error. p>; return ( div onClick=() => onClick(state.todo as Todo)>> p>state.todo.title>p> p>state.todo.id>p> div> ); >
Tests
import render > from "@testing-library/react"; it("fetches a todo", () => const /* selectors */> = render(Todo onClick=() => <>> id=1> />); // rest of the test >); it("handles non-existing id", () => const /* selectors */> = render(Todo onClick=() => <>> id=420> />); // rest of the test >); // more test cases
And there is nothing wrong with that. But when writing fourth, fifth test case you may get tired of all this repetition. Notice that I had to explicitly provide onClick function even though that function will not be used within the test (eg. handles non-existing id )? We can remove all of this repetition by creating renderUI or setup function (these are just propositions, call it what you want).
renderUI function
Let’s create renderUI function which will be responsible for rendering the component and returning react-testing-library selectors and utilities.
function renderUI(props: ?) return render(Todo . props>/>) >
Now, I left the question mark here on purpose. You might be tempted to just import the type of props from ./App (the file that holds the component we are testing).
import render > from "@testing-library/react"; import Todo, Props > from "./App"; function renderUI(props: Props) return render(Todo . props> />); >
- unless you use verbose names like TodoComponentProps , exporting the type of component props may cause collisions with other exported types, this can be especially painful when using code completion.
- exporting the type of component props can be confusing for the future reader of the code. Can I change the name of the type?, Are those used somewhere?.
With that in mind, lets leverage Typescript features and get the type of component props without exporting/importing them.
import render > from "@testing-library/react"; import Todo > from "./App"; type ComponentProps = React.ComponentPropstypeof Todo>; function renderUI(props: ComponentProps) return render(Todo . props> />); >
I’m using generic React.ComponentProps defined within @types/react to get the type I need. No exporting/importing of the props type needed!
With that, within our test, we got rid of some repetition:
it("fetches a todo", () => const /* selectors */ > = renderUI( onClick: () => <>, id: 1 >); // rest of the test >);
But still, we have to include properties that are not really important for a given test case ( onClick in this case). Parial from Typescript utility types can help with that.
import Todo > from "./App"; type ComponentProps = React.ComponentPropstypeof Todo>; const baseProps: ComponentProps = onClick: () => <>, id: 1 >; function renderUI(props: PartialComponentProps> = <>) return render(Todo . baseProps> . props> />); >
Notice that I had to create baseProps . These should be specified in such a manner that your component can actually render using them. The baseProps and props combo allows us to only pass these properties to renderUI function which matters in the context of a given test.
it("handles non-existing id", () => const /* selectors */> = render(Todo id=420> />); // rest of the test >);
The handles non-existing id test case does test the ability to respond to user clicks so it does not specify onClick function. This is possible because we included baseProps within our renderUI function.
Rerendering
Sometimes, you need to use the rerender function returned from react-testing-library render function to test how the component behaves when given prop changes (before and after the change).
Looking at the signature of the rerender function:
rerender: (ui: React.ReactElement) => void;
it takes an parameter of type React.ReactElement . This means that our renderUI function, as it stands, will not cut it.
it("reacts to id change", () => const rerender > = renderUI( id: 1 >); // assert rerender(Todo . baseProps> id=2> />); // assert >);
We can abstract the rerender function in the same way we abstracted render .
function renderUI(props: PartialComponentProps> = <>) const rtlProps = render(Todo . baseProps> . props> />); return . rtlProps, rerender: (newProps: PartialComponentProps>) => rtlProps.rerender(Todo . baseProps> . props> . newProps> />) >; >
I’ve replaced the returned rerender function. Instead of returning the original one, it now abstracts the renedring of the component away, which makes our tests clearer.
it("reacts to id change", () => const rerender > = renderUI( id: 1 >); // assert rerender( id: 2 >); // assert >);
Word of caution
I just want to point out that, sometimes, repetition is not necessarily a bad thing. Creating hasty abstractions surely is worse than having to pass props multiple times.
This is why I only recommend following the advice I’m giving here if and only if you feel the need to do so.
There is a great article which you definitely should read and consider before creating any kind of abstractions within your tests (and in general).
Summary
Overall, I think this pattern can help you write tests faster and with less repetition.
Please keep in mind that I’m no expert in the field of testing and/or Typescript so if something feels off or incorrect to you, please reach out!
You can follow me on twitter: @wm_matuszewski
Setting up Jest unit tests in a React + Typescript project
Here’s what I do when I want to set up a Jest on a React project.
First, we need to install some dependencies:
npm install --save-dev jest babel-jest ts-jest chai chai-jest identity-obj-proxy
- jest is a unit test runner from Facebook. You probably already know this, because you’re reading this.
- ts-jest is a transform for jest which compiles typescript files. If you’re using babel to compile your typescript files, you can skip this.
- babel-jest is like ts-jest , but uses babel to transform files — handy if you have a project with some mixed typescript and javascript.
- chai is an assertion library. Jest already comes with an expect built in, but if you’re coming from mocha you probably already use chai, and it’s somewhat more expressive and has a lot of plugins available.
- chai-jest is a plugin for chai which has supports jest mocks. It basically re-implements a bunch of the jest-specific asserts on the built-in expect object. See the documentation for a list of assertions available.
- identity-obj-proxy is a handy library for cases where we import files like CSS modules. If you import styles from ‘styles.css’ , then we can configure jest to import ‘identity-obj-proxy’ for *.css, and then when you do styles.container , it will resolve to “container” instead of throwing an exception.
Then we’re going to create some files to set up jest. First, jest.config.js:
// jest.config.js module.exports = globals: "ts-jest": // Tell ts-jest about our typescript config. // You can specify a path to your tsconfig.json file, // but since we're compiling specifically for node here, // this works too. tsConfig: target: "es2019", >, >, >, // Transforms tell jest how to process our non-javascript files. // Here we're using babel for .js and .jsx files, and ts-jest for // .ts and .tsx files. You *can* just use babel-jest for both, if // you already have babel set up to compile typescript files. transform: "^.+\\.jsx?$": "babel-jest", "^.+\\.tsx?$": "ts-jest", // If you're using babel for both: // "^.+\\.[jt]sx?$": "babel-jest", >, // In webpack projects, we often allow importing things like css files or jpg // files, and let a webpack loader plugin take care of loading these resources. // In a unit test, though, we're running in node.js which doesn't know how // to import these, so this tells jest what to do for these. moduleNameMapper: // Resolve .css and similar files to identity-obj-proxy instead. ".+\\.(css|styl|less|sass|scss)$": `identity-obj-proxy`, // Resolve .jpg and similar files to __mocks__/file-mock.js ".+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": `/__mocks__/file-mock.js`, >, // Tells Jest what folders to ignore for tests testPathIgnorePatterns: [`node_modules`, `\\.cache`], testURL: `http://localhost`, >;
We need to create the “mocks/file-mock.js” referenced above in “moduleNameMapper”, too. This file will be imported in place of “.jpg” or “.mp3” or similar files:
// __mocks__/file-mock.js module.exports = "test-file-stub";
Resolving Custom Paths
In your tsconfig.json file, you can set up a “paths” section:
"paths": "components/*": ["src/components/*"], >
This lets you import Button from «components/Button» , and helps you avoid long chains of “../../..” in your code. But, we need to make jest know about these imports. The easiest way to do this, if you’re already using webpack, is via the jest-webpack-resolver. In your webpack configuration, you probably already have something like:
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); module.exports = . resolve: modules: ['node_modules'], plugins: [new TsconfigPathsPlugin( extensions >)], extensions: ['.js', '.jsx', '.ts', '.tsx'], >, . >
So we’re going to take advantage of the fact that webpack knows how to resolve things already:
npm install --save-dev jest-webpack-resolver
And then somewhere in your jest.config.js add:
module.exports = . resolver: 'jest-webpack-resolver', . >
If you are not using webpack, then check out tsconfig-paths-jest as a possible alternative.
Running tests
At this point you should be able to:
And it should run your tests!
Example Test File
Here’s a quick example of a test file — if you have a Button.tsx , you might put this in Button.test.tsx in the same folder. Note that this uses the @testing-library/react and @testing-library/user-event libraries:
import render > from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import chai from "chai"; import chaiDom from "chai-dom"; chai.use(chaiDom); const expect > = chai; describe("Test Suite", () => beforeEach(() => // TODO: Uncomment this if you're using `jest.spyOn()` to restore mocks between tests // jest.restoreAllMocks(); >); it("click on a button", () => const getByText > = render(button>Hello World/button>); // `getByText` comes from `testing-library/react` and will find an element, // or error if the element doesn't exist. See the queries documentation // for info about other query types: // https://testing-library.com/docs/dom-testing-library/api-queries const button = getByText("Hello World"); // `userEvent` is a library for interacting with elements. This will // automatically call `React.act()` for you - https://reactjs.org/docs/test-utils.html#act. userEvent.click(button); >); >);
Note that all tests run in node.js, where this is no browser. By default, Jest will initialize a jsdom environment for you, which gives you a window and a document and will let you render nodes to a virtual screen. But, if you’re writing a test for a module that doesn’t need to interact with the DOM, you can speed up a test by using the “node” jest environment which will skip all of that:
/** * @jest-environment node */ import chai from "chai"; import chaiJest from "chai-jest"; chai.use(chaiJest); const expect > = chai; describe("Test Suite", () => it("should do the thing", () => // TODO >); >);