Bulletproof React Testing with Vitest & RTL
TL;DR: If you want to just see the final code and play with it straight away go here.
Why testing matters
If you’re here, you likely already appreciate the value of testing. For me, it’s about giving everyone the confidence that they won’t break anything in production when adding new features, while also making the codebase more flexible for future changes. It’s a way to help future developers understand why certain parts of the code exist and comfortably add new features. Yes, let’s care for those future people who will work on the codebase! Also, yes, you still need 100% test coverage.
Introduction
Very recently I was setting up a project that was set up in Typescript and found it hard to set up Jest with Typescript. I thought to give Vitest a try, and it all worked from the start. I knew the API was the same as Jest so I guessed it was worth a try since it was known to be faster.
I found there are some differences, maybe the most important being that in Jest it automocks every module of a file whereas in Vitest you have to mock everything manually, however it was not a big hurdle to overcome and get used to it.
Key Differences Between Jest and Vitest
Let’s say you use axios
to make requests. The following code in Jest will mock all axios methods, get, post etc (hint: We will see more about axios mocking later):
jest.mock("axios");
Whereas in Vitest you’d have to manually mock every method:
vi.mock('axios', () => ({
default: {
get: vi.fn(),
},
}));
Jest and Vitest have a similar way to mock methods but leave other untouched (i.e. use the original methods). In Jest you can do:
jest.mock('date-fns', () => {
const original = jest.requireActual('date-fns');
return {
...original, // Spread the original module
format: jest.fn(() => '2025-01-01'), // Mock only the `format` method
};
});
In Vitest you can do the same with:
vi.mock('date-fns', async () => {
const original = await vi.importActual('date-fns');
return {
...original, // Retain all original methods
format: vi.fn(() => '2025-01-01'), // Mock `format` to return a fixed value
};
});
The App we want to test
Imagine a React app that fetches posts from an API and renders them in a list. Let’s see the main App.tsx
file:
import { usePosts } from './hooks/usePosts';
import './styles.css';
export default function App() {
const { posts, loading, error } = usePosts();
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error loading posts</div>;
}
return (
<div className="App">
<h1>Posts list</h1>
<ul>
{posts.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
As you noticed we use the usePosts
hook to fetch posts. Let’s see how our custom hook usePosts
looks:
import { useEffect, useState } from 'react';
import axios from 'axios';
import { Post } from '@/types/Post';
const API_URL = 'https://jsonplaceholder.typicode.com/posts';
export const usePosts = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
const fetchPosts = async () => {
try {
const response = await axios.get(API_URL);
setPosts(response.data);
} catch (error) {
setError(true);
} finally {
setLoading(false);
}
};
fetchPosts();
}, []);
return { posts, loading, error };
};
Let’s start with testing the usePosts
. It's an asynchronous function and we use axios
for it. Initially we want to test the happy path:
import { renderHook, waitFor } from '@testing-library/react';
import { usePosts } from './usePosts';
import axios from 'axios';
// Vitest does not auto-mock modules like Jest does.
// We manually mock 'axios' to control its behavior and use mockResolvedValue for testing.
vi.mock('axios');
describe('usePosts', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when the API call is successful', () => {
// We use vi.mocked for Typescript type inference.
const mockAxios = vi.mocked(axios.get);
const mockPosts = [
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' },
];
beforeEach(() => {
// We use mockResolvedValue to mock the API response.
mockAxios.mockResolvedValue({ data: mockPosts });
});
it('should return the posts and loading as false', async () => {
// We use renderHook from RTL to render our custom hook.
const { result } = renderHook(() => usePosts());
// waitFor is used to wait for asynchronous state updates in the hook.
// Without it, assertions might run before the API call resolves.
await waitFor(() => {
expect(result.current.posts).toEqual(mockPosts);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(false);
});
});
});
});
I like mocking everything in a beforeEach
whenever possible as I find that it keeps the assertions more clear. Other than that the comments should help you understand what’s happening.
Next, we need to mock when the hook does not return a successful response, and we will assert that it will set the error
variable to true
:
describe('when the API call fails', () => {
const mockAxios = vi.mocked(axios.get);
beforeEach(() => {
mockAxios.mockRejectedValue(new Error('API call failed'));
});
it('should return an empty array and loading as false', async () => {
const { result } = renderHook(() => usePosts());
await waitFor(() => {
expect(result.current.posts).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(true);
});
});
});
Finally we will test how the React Component uses this custom hook. Remember to always mock anything else that you don’t want to test. For instance, you will see below that since we want to test our App.tsx
only, we will mock usePosts
. Lets quickly remember how the App.tsx
looks:
import { usePosts } from './hooks/usePosts';
import './styles.css';
export default function App() {
const { posts, loading, error } = usePosts();
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error loading posts</div>;
}
return (
<div className="App">
<h1>Posts list</h1>
<ul>
{posts.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
First let’s test the loading state of this component. It would look something like this:
import App from './App';
import { render } from '@testing-library/react';
import { usePosts } from '@/hooks/usePosts';
vi.mock('@/hooks/usePosts');
describe('App', () => {
const mockUsePosts = vi.mocked(usePosts);
beforeEach(() => {
vi.clearAllMocks();
});
describe('Loading state', () => {
beforeEach(() => {
mockUsePosts.mockReturnValue({ posts: [], loading: true, error: false });
});
it('should render the loading state', () => {
const { getByText } = render(<App />);
expect(getByText('Loading...')).toBeInTheDocument();
});
});
});
Then let’s test what happens when the hook returns error
as true
, i.e when the fetch of the posts failed:
describe('Error state', () => {
beforeEach(() => {
mockUsePosts.mockReturnValue({ posts: [], loading: false, error: true });
});
it('should render the error state', () => {
const { getByText } = render(<App />);
expect(getByText('Error loading posts')).toBeInTheDocument();
});
});
And finally the success state, everything went well and we just render the posts:
describe('Success state', () => {
const mockPosts = [
{ id: 1, userId: 1, body: 'Content 1', title: 'Post 1' },
{ id: 2, userId: 2, body: 'Content 2', title: 'Post 2' },
];
beforeEach(() => {
mockUsePosts.mockReturnValue({
posts: mockPosts,
loading: false,
error: false,
});
});
it('should render the posts', () => {
const { getByText } = render(<App />);
expect(getByText('Post 1')).toBeInTheDocument();
expect(getByText('Post 2')).toBeInTheDocument();
});
});
Conclusion
Hopefully this gives you an initial idea on how to use Vitest to test a React component along with a custom hook. Here are the links if you want to explore or play with the code:
- https://codesandbox.io/p/github/vaskort/vitest-blog-post/main?workspaceId=ws_AS8QkZMpvkUMLhEyFFNjkE
- https://github.com/vaskort/vitest-blog-post
Thanks for reading!