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.useState<State>({
fetchState: "loading",
todo: undefined
});
React.useEffect(() => {
function fetchTodo() {
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)
.then<Todo>(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>
);
}
Like I said the code here does not really matter. It's just here so that we have something to test.
Tests
Your test cases would probably look like this:
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} />);
}
While you certainly can do that, I personally do not recommend doing so.
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.ComponentProps<typeof 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<T>
from Typescript utility types can help with that.
import { Todo } from "./App";
type ComponentProps = React.ComponentProps<typeof Todo>;
const baseProps: ComponentProps = {
onClick: () => {},
id: 1
};
function renderUI(props: Partial<ComponentProps> = {}) {
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: Partial<ComponentProps> = {}) {
const rtlProps = render(<Todo {...baseProps} {...props} />);
return {
...rtlProps,
rerender: (newProps: Partial<ComponentProps>) =>
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
Thanks 👋