Component testing is a good place to start demonstrating practical testing code. Component tests are more substantial than simple unit tests, less complex than end-to-end testing, and demonstrate interacting with the DOM. More philosophically, the use of React has made it easier for web developers to think of websites or web apps as being made up of components.
So testing individual components, regardless of how complex they are, is a good way to start thinking about testing a new or existing application.
This page walks through testing a small component with complex external dependencies. It's easy to test a component that doesn't interact with any other code, such as by clicking a button and confirming that a number increases. In reality, very little code is like that, and testing code that doesn't have interactions can be of limited value.
(This isn't intended as a full tutorial, and a later section, Automated testing in practice, will walk through testing a real site with sample code you can use as a tutorial. However, this page will still cover several examples of practical component testing.)
The component under test
We'll use Vitest and its JSDOM environment to test a React component. This lets us run tests quickly using Node on the command line while emulating a browser.
This React component named UserList
fetches a list of users from the network
and lets you select one of them. The list of users is obtained using
fetch
inside a useEffect
, and the selection handler is passed in by
Context
. This is its code:
import React, { useEffect, useState, useContext } from 'react';
import { UserContext } from './UserContext.tsx';
import { UserRow } from './UserRow.tsx';
export function UserList({ count = 4 }: { count?: number }) {
const [users, setUsers] = useState<any[]>([]);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users?_limit=' + count)
.then((response) => response.json())
.then((json) => setUsers(json));
}, [count]);
const c = useContext(UserContext);
return (
<div>
<h2>Users</h2>
<ul>
{users.map((u) => (
<li key={u.id}>
<button onClick={() => c.userChosen(u.id)}>Choose</button>{' '}
<UserRow u={u} />
</li>
))}
</ul>
</div>
);
}
This example doesn't demonstrate React best practices (for example, it uses
fetch
inside useEffect
), but your codebase is likely to contain many cases
like it. More to the point, these cases can appear stubborn to test at first
glance. A future section of this course will discuss writing testable code in
detail.
Here are the things we're testing in this example::
- Check that some correct DOM gets created in response to data from the network.
- Confirm that clicking a user triggers a callback.
Every component is different. What makes testing this one interesting?
- It uses the global
fetch
to request real-life data from the network, which might be flaky or slow under test. - It imports another class,
UserRow
, which we might not want to implicitly test. - It uses a
Context
which isn't specifically part of the code under test, and is normally provided by a parent component.
Write a quick test to start
We can quickly test something very basic about this component. To be clear, this
example isn't very useful! But it's helpful to set up the boilerplate in a peer
file called UserList.test.tsx
(remember, test runners like Vitest will, by
default, run files that end with .test.js
or similar, including .tsx
):
import { vi, test, assert, afterAll } from 'vitest';
import { render } from '@testing-library/react';
import { UserList } from './UserList.tsx';
import React, { ContextType } from 'react';
test('render', async () => {
const c = render(<UserList />);
const headingNode = await c.findAllByText(/Users);
assert.isNotNull(headingNode);
});
This test asserts that when the component renders, it contains the text "Users".
It works even though the component has a side effect of sending a fetch
to
the network. The fetch
is still in progress at the end of the test, with no
set endpoint. We can't confirm that any user information is being shown when the
test ends, at least not without waiting for a timeout.
Mock fetch()
Mocking is the act of replacing a real function or class with something under your control for a test. This is common practice in nearly all types of tests, except for the simplest unit tests. This will be covered more in Assertions and other primitives.
You can mock fetch()
for your test so that it completes quickly and returns
data you expect, and not "real-world" or unknown data. fetch
is a global,
which means we don't have to import
or require
it into our code.
In vitest, you can mock out a global by calling vi.stubGlobal
with a special
object returned by vi.fn()
—this builds a mock that we can modify later. These
methods will be examined in more detail in a later section of this course, but
you can see them in practice in the following code:
test('render', async () => {
const fetchMock = vi.fn();
fetchMock.mockReturnValue(
Promise.resolve({
json: () => Promise.resolve([{ name: 'Sam', id: 'sam' }]),
}),
);
vi.stubGlobal('fetch', fetchMock);
const c = render(<UserList />);
const headingNode = await c.queryByText(/Users);
assert.isNotNull(headingNode);
await waitFor(async () => {
const samNode = await c.queryByText(/Sam);
assert.isNotNull(samNode);
});
});
afterAll(() => {
vi.unstubAllGlobals();
});
This code adds a mock, describes a "fake" version of the network fetch
Response
, and then waits for it to appear. If the text doesn't appear—
you can check this by changing the query in queryByText
to a new name—
the test will fail.
This example has used Vitest's built-in mocking helpers, but other testing
frameworks have similar approaches to mocking. Vitest is unique in that you must
call vi.unstubAllGlobals()
after all tests, or set an equivalent global
option. Without "undoing" our work,
the fetch
mock can affect other tests, and every request will be responded to
with our odd pile of JSON!
Mock imports
You might have noticed that our UserList
component itself imports a component
called UserRow
. While we haven't included its code, you can see that it
renders the user's name: the previous test checks for "Sam", and that isn't
rendered inside UserList
directly, so it must come from UserRow
.
However, UserRow
might itself be a complex component—it might fetch further
user data, or have side effects that aren't relevant to our test. Removing that
variability will make your tests more helpful, especially as the components you
want to test get more complex and more intertwined with their dependencies.
Fortunately, you can use Vitest to mock out certain imports, even if your test doesn't use them directly, so that any code that uses them is provided with a simple or known version:
vi.mock('./UserRow.tsx', () => {
return {
UserRow(arg) {
return <>{arg.u.name}</>;
},
}
});
test('render', async () => {
// ...
});
Like mocking the fetch
global, this is a powerful tool, but it can become
unsustainable if your code has lots of dependencies. Again, the best fix for
that is to write testable code.
Click and provide context
React, and other libraries such as Lit,
have a concept called Context
. The sample code includes a UserContext
which
invokes method if a user is chosen. This is often seen as an alternative to
"prop drilling", where the callback is passed to UserList
directly.
The test harness we've written hasn't provided UserContext
. By adding a click
action to the React test without it, this will, at worst, crash the test, or at
best, if a default instance was provided elsewhere, cause some behavior out of
our control (similar to an unknown UserRow
above):
const c = render(<UserList />);
const chooseButton = await c.getByText(/Choose);
chooseButton.click();
Instead, when rendering the component, you can provide your own Context
. This
example uses an instance of vi.fn()
, a Vitest Mock Function, that can be used
after the fact to check that a call was made, and what arguments it was made
with. In our case, this interacts with the mocked fetch
in the earlier
example, and the test can confirm that the ID passed through was "sam":
const userChosenFn = vi.fn();
const ucForTest: ContextType<typeof UserContext> = { userChosen: userChosenFn as any };
const c = render(
<UserContext.Provider value={ucForTest}>
<UserList />
</UserContext.Provider>,
);
const chooseButton = await c.getByText(/Choose);
chooseButton.click();
assert.deepStrictEqual(userChosenFn.mock.calls, [['sam']]);
This is a simple but powerful pattern that can let you remove irrelevant dependencies from the core component you're trying to test.
In Summary
This has been a quick and simplified example demonstrating how to build a
component test to test and safeguard a difficult-to-test React component,
focusing on ensuring that the component correctly interacts with its
dependencies (the fetch
global, an imported subcomponent, and a Context
).
Check your understanding
What approaches were used to test the React component?