In this module you'll learn the basics of unit testing, you'll start by setting up a react application with Vite, writing a simple test then we'll explore some advanced concepts in unit tests. We'll be using Vitest which is a vite-native framework. Vitest is a test runner for Vite projects, and it's similar to Jest.
- Step 1: In your terminal, create a new project with Vite and React using Yarn:
yarn create vite unit-testing-setup --template react-ts
-
Step 2: Navigate to your project directory and install the initial dependencies by running
yarn install
-
Step 3: Installing the required dependencies run:
yarn add @testing-library/react @testing-library/jest-dom vitest @vitejs/plugin-react @testing-library/dom jsdom
-
@testing-library/react: React Testing Library for testing React components.
-
@testing-library/jest-dom: Custom Jest matches for more readable assertions.
-
vitest: A Vite-native testing framework (similar to Jest).
-
@vitejs/plugin-react: Vite plugin for React fast refresh and JSX support.
-
@testing-library/dom: Ensures that all DOM-related utilities, like screen, are properly available in your test environment.
-
jsdom: Acts as a lightweight, headless browser environment for testing components that interact with the DOM. Vitest requires it to simulate the browser environment and properly test React components.
-
Step 4: Configure
vitest
invite.config.ts
Openvite.config.ts
file and add the test configurations
import { defineConfig } from "vitest/config"; // Use vitest's defineConfig instead of vite's
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true, // Enable Vitest's globals like `test` and `expect`
environment: "jsdom", // Use JSDOM for React component testing
setupFiles: "./src/setupTests.ts", // Setup file for configurations
},
});
- Step 5: Create the test setup file
Navigate to the src folder and create a setupTest.ts
file (this could be any filename but it should match the name added to setupFiles in the vite.config.ts
) and add the following line to include the jest-dom matches:
import '@testing-library/jest-dom';
- Step 6: Update TypeScript Config (tsconfig.json)
Add Vitest types to your tsconfig.json to recognize globals like test and expect, By adding vitest/globals, TypeScript will recognize the test, describe, and expect etc functions.
{
"compilerOptions": {
"types": ["vitest/globals"],
"jsx": "react",
"module": "ESNext",
"target": "ESNext",
...
}
}
That'll be all for now, Let's create a simple component and write a test for it
First, create a components folder and create a folder inside of that and call Hello, inside of that create an index file. The structure should look like this src/components/Hello/index.tsx
. Inside the index.tsx
file add this
type HelloProps = {
name: string,
};
function Hello({ name }: HelloProps) {
return <h1>Hello, {name}!</h1>;
}
export default Hello;
This is a basic component with a prop, let's test this to ensure that it renders "Hello, World" if we pass "World" as a props to the component
create a test file in the component src/components/Hello/Hello.test.tsx
and add the following:
import { render, screen } from "@testing-library/react";
import Hello from ".";
test("renders the correct greeting text", () => {
render(<Hello name="World" />);
const greetingElement = screen.getByText("Hello, World!");
expect(greetingElement).toBeInTheDocument();
});
Next run yarn vitest
OR npx vitest
to run your test
-
test The test keyword is used to define a single test case.
-
expect The expect is used to assert that a value meets certain conditions. It's used for assertion.
-
render The Render method is used here to render a react component (Hello) into the virtual DOM for testing purposes. You use it to stimulate what happens when the component is rendered in the actual application.
-
screen The Screen is a utility object that provides a simplified and global way to access the queries returned by render. Instead of destructuring the return value of render, the screen provides a global query object that directly queries the DOM.
When testing, useEffect can complicate things because it triggers automatically on mount and update, making it difficult to control.
Now let's look at testing a component with internal state and a simple useEffect, and then add an API call to see how it changes the testing approach.
Create a Counter component src/component/Counter/index.tsx
, Alternatively, you can update the App.tsx file to this
import { useState, useEffect } from "react";
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<div className="card">
<button onClick={() => setCount(count + 1)}>count is {count}</button>
</div>
);
};
export default App;
To test this component, we'll focus on three thing
- The document initially displays 'count is 0'
- On click of the button the content gets updated to 'count is 1'
- On click of the button the document.title gets updated to 'Count: 1'
So let's create a test for this. Create a test file src/component/Counter/counter.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import Counter from ".";
test("renders initial count as 0", () => {
render(<Counter />);
expect(screen.getByText(/count is 0/i)).toBeInTheDocument();
});
test("increment the count on button click", () => {
render(<Counter />);
const button = screen.getByRole("button", { name: /count is 0/i });
fireEvent.click(button);
expect(screen.getByText(/count is 1/i)).toBeInTheDocument();
});
test("should update the document title on button click", () => {
render(<Counter />);
expect(document.title).toBe("Count: 0");
const button = screen.getByRole("button", { name: /count is 0/i });
fireEvent.click(button);
expect(document.title).toBe("Count: 1");
});
Next, let's see how to test on a component that uses useEffect to make API calls, For simplicity sake we'll use the JSONPlaceholder API.
Create a UserProfile component in and add a basic user profile view
import { useEffect, useState } from "react";
type User = {
id: number;
name: string;
};
const UserProfile = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/1"
);
const data = await response.json();
setUser(data);
setLoading(false);
};
fetchUser();
}, []);
if (loading) return <p>Loading...</p>;
return (
<div>
<h1>User Profile</h1>
{user ? <p>{user.name}</p> : <p>No user found</p>}
</div>
);
};
export default UserProfile;
For this component here are some possible test consideration
- Mock the fetch function (to prevent the test from making actual fetch request)
- Ensure that the component displays loading initially
- Enusure that the component displays user name after data is loaded
- Ensure that the component account for a missing user
import { render, screen, waitFor } from "@testing-library/react";
import UserProfile from ".";
import { vi, Mock } from "vitest";
beforeEach(() => {
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ id: 1, name: "Leanne Graham" }),
})
) as Mock;
});
afterEach(() => {
vi.clearAllMocks();
});
test("displays loading initially", () => {
render(<UserProfile />);
expect(screen.getByText(/Loading.../i)).toBeInTheDocument();
});
test("displays user name after data is loaded", async () => {
render(<UserProfile />);
await waitFor(() =>
expect(screen.getByText(/Leanne Graham/i)).toBeInTheDocument()
);
});
test("handles missing user gracefully", async () => {
(global.fetch as Mock).mockImplementationOnce(() =>
Promise.resolve({
json: () => Promise.resolve(null),
})
);
render(<UserProfile />);
await waitFor(() =>
expect(screen.getByText(/No user found/i)).toBeInTheDocument()
);
});
The code above might throw a typescript error for the global.fetch like something like "Cannot find name 'global'", This is because the global object (global in Node.js) is not recognized in TypeScript by default. We need to properly type it so TypeScript knows what global.fetch refers to. To fix this you'll need to install types/node
. So run yarn add types/node
That should fix the error. So let's go through the code to understand what was done.
These are lifecycle hooks provided by the test runner (vitest in this case). They allow you to run specific code before and after each test case. This is useful for setting up and tearing down configurations, mocks, or data that you need for your tests. Some other lifecycle hooks are beforeAll
and afterAll
.
In the test beforeEach
mocks the global.fetch
function before each test so every test starts with a fresh mock version of fetch.
afterEach
clears the mock data after each test to ensure one test doesn’t affect the next for example preventing leftover mock states.
The fetch
function is a browser-native function used for making HTTP requests. To get a refrence to the fetch function we're using global.fetch
which is similar to the browser window
.
In the test, we're overridding the global.fetch
to mock the fetch
implementation, so that when a request is made in the React component, it returns a fake data instead of making a network request.
vi.fn()
is a mock function that allows you stimulate a a function’s behavior and track its calls, parameters, and return values.
In the test, vi.fn(() => Promise.resolve(...))
creates a mock version of fetch that returns a resolved promise with fake JSON data.
This way, when your component calls fetch
to get user data, it doesn’t actually hit a real server.
mockImplementationOnce
sets up a one-time implementation for a mock function. After it’s called once, it reverts to the default behavior of vi.fn()
.
In the test, (global.fetch as Mock).mockImplementationOnce()
is telling fetch
to return null
for a specific test in this case a missing user.
Before we continue here let's do a quick recap on useEffect
- useEffect is a React hook that allows you to run side effects in function components.
- It runs after the component renders and re-runs if its dependencies change.
- The dependency array tells React when to re-run the effect. If any of the values in the dependency changes, the effect is triggered again.
The goal here is to understand how to write effective test for useEffect
when its dependency array changes.
So let's create a component that fetches data based on a userId
prop and when userId
changes, useEffect
re-runs and fetches the new user’s data
import React, { useState, useEffect } from "react";
type User = {
id: number;
name: string;
};
type UserDetailProps = {
userId: number;
};
const UserDetail: React.FC<UserDetailProps> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Mock API call to fetch user data
const fetchUser = async () => {
setLoading(true);
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
const data = await response.json();
setUser(data);
setLoading(false);
};
fetchUser();
}, [userId]);
if (loading) return <p>Loading...</p>;
if (!user) return <p>No user found</p>;
return (
<div>
<h2>User Detail</h2>
<p>ID: {user.id}</p>
<p>Name: {user.name}</p>
</div>
);
};
export default UserDetail;
Here's a list of what we'll test for
- shows loading initially
- displays user info after fetch is complete
- update user data when the userId changes
- handles empty result
Based on the previous example can you try running the test yourself before checking the answer?
Here's what you'll need to do
- Mock the api call and response
- Clear the mock after each call
- For the test to "update user data when the userId changes" you'll need to rerender the app and make a mock request twice passing different userId
import { render, screen, waitFor } from "@testing-library/react";
import UserDetail from ".";
import { vi, Mock } from "vitest";
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
test("shows loading initially", () => {
(global.fetch as Mock).mockResolvedValueOnce({
json: () => Promise.resolve({ id: 1, name: "Cat Burns" }),
});
render(<UserDetail userId={1} />);
expect(screen.getByText(/Loading.../i)).toBeInTheDocument();
});
test("displays user info after fetch is complete", async () => {
(global.fetch as Mock).mockResolvedValueOnce({
json: () => Promise.resolve({ id: 1, name: "Cat Burns" }),
});
render(<UserDetail userId={1} />);
await waitFor(() =>
expect(screen.getByText(/Name: Cat Burns/i)).toBeInTheDocument()
);
});
test("update user data when the userId changes", async () => {
(global.fetch as Mock).mockResolvedValueOnce({
json: () => Promise.resolve({ id: 1, name: "Cat Burns" }),
});
const { rerender } = render(<UserDetail userId={1} />);
// rerender is used here to re-render the component with different props without unmounting it.
await waitFor(() =>
expect(screen.getByText(/Name: Cat Burns/i)).toBeInTheDocument()
);
(global.fetch as Mock).mockResolvedValueOnce({
json: () => Promise.resolve({ id: 2, name: "Joy Odungoke" }),
});
rerender(<UserDetail userId={2} />);
await waitFor(() =>
expect(screen.getByText(/Name: Joy Odungoke/i)).toBeInTheDocument()
);
});
test("handles empty result", async () => {
(global.fetch as Mock).mockResolvedValueOnce({
json: () => Promise.resolve(null),
});
render(<UserDetail userId={1} />);
await waitFor(() =>
expect(screen.getByText(/No user found/i)).toBeInTheDocument()
);
});
In this solution, I added individual mock to each test to show that your mock doesn't always have to be in one before each, especially if you won't be using the sane implementation across all tests.
Using rerender
rerender
is used here to re-render the component with different props without unmounting it. This is useful for testing useEffect’s behavior when its dependency changes.
In the example above we only used one dependency, Which makes the test kinda straightfroward, But what of when we're working with multiple dependencies? It's the same process for that usecase as well.
When a component has multiple dependencies in useEffect, the effect runs when any of the dependencies change. So let's look at an example of that and run a test for this.
Let's create a simple component called UserProfileWithStatus: