Unit Testing With Vitest - A Great Alternative to Jest
Explore unit testing with Vitest, a powerful and efficient alternative to Jest. Discover its seamless integration with Vite, built-in features for testing API calls and DOM manipulations, and easy set
Vitest is a powerful test runner built specifically for Vite projects, offering seamless integration without a complicated setup. It’s optimized for efficiency, using modern JavaScript features like ESM imports and top-level await to streamline testing. With Vitest, Vite developers can easily write effective tests, simplifying their workflow while taking full advantage of cutting-edge JavaScript capabilities.
Vitest vs Jest: Why Vitest is the Faster, Smarter Choice for Unit Testing
I wanted to present the following comparison in a table format, but unfortunately, Substack doesn’t support tables (?) 🧐
Vitest
Native Integration: Fully integrated with Vite's workflow
Modern Syntax Support: Supports ESM imports, top-level await
Extensibility: Can be extended with plugins
TypeScript Support: Easily integrates with TypeScript/JSX
Source Code Testing: Optional: Place tests alongside source code
GUI Dashboard: Optional: Visualizes test results
Test Handling: Automatically retries flaky tests
Jest
Native Integration: Requires additional setup
Modern Syntax Support: Limited support for modern JavaScript
Extensibility: Limited extensibility
TypeScript Support: Requires additional configuration
Source Code Testing: Not supported
GUI Dashboard: Optional: Not available
Test Handling: Requires manual setup or third-party tools
Getting Started with the Vitest
In this section, you'll learn how to set up a modern test runner for your project using a simple example. Once configured, you'll have multiple ways to run tests on your code. This article covers two common testing scenarios: basic functionality checks and API call testing.
Vitest without Vite?
Setting Up a Vite Project (Optional). If you don't already have a project, you can quickly create one using Vite's CLI:
Let’s start by testing a simple function, like adding two numbers. This example is perfect for getting familiar with the test runner, especially if you're new to testing or working on smaller projects. We’ll create a project, install the test runner, write the necessary script, and run it to verify the functionality.
npm init vite@latest demo-vite-project
cd demo-vite-project
Create a new project named demo-vite-project with the default settings.
Install the framework by adding a test runner as a development dependency using the command: npm install.
npm install --save-dev vitest
Let’s write some basic tests. We’ll create a test file named math.test.js to demonstrate how to write tests. Below is the code:
// math.test.js
// Import necessary functions for testing from Vitest
import { expect, test } from 'vitest';
// Define a function named sum that takes two numbers (a and b) as arguments
// and returns their sum
function sum(a, b) {
return a + b;
}
// Create a test using the `test` function from Vitest
test('adds two numbers', () => {
// Inside the test function, we define what we want to test
// The first argument is a description of the test
// Use the `expect` function to make assertions about the result
// We expect the sum of 2 and 3 to be 5
expect(sum(2, 3)).toBe(5);
});
This code snippet defines a test to ensure that the sum function correctly adds two numbers. It imports the necessary testing functions, defines the sum function that adds two numbers and uses assertion methods to verify that sum(2, 3) returns 5.
Running Tests: To execute the test, run the following command in the terminal:
npx vitest
The advantage of this framework lies in its native integration with the existing Vite build pipeline. By default, Vite automatically recognizes test files with the extensions .test.js or .spec.js. Vite executes these test files alongside your application code during the build process. This ensures that your tests run when building the production version of your application, allowing you to identify potential issues before deployment.
Testing API Calls
When working with real applications, testing API interactions is crucial. Let's explore the process of testing various API operations using our test runner. First, we’ll create a comprehensive file named userAPI.js that will include several HTTP methods and error handling.
Let’s start by examining the getUser function:
// userAPI.js
async getUser(userId) {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
This method retrieves user information by their ID. It sends a GET request to the API endpoint, checks the success of the response, and returns the parsed JSON data. If the response is unsuccessful, the method throws an error.
Next, let’s look at the createUser function:
// userAPI.js
async createUser(userData) {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return response.json();
}
This method creates a new user. It sends a POST request to the API endpoint with the user data in the request body, converting the data into a JSON string. Upon a successful response, the method returns information about the new user. If the response is unsuccessful, the method throws an error.
Now, let’s examine the updateUser function:
async updateUser(userId, userData) {
const response = await fetch(`https://api.example.com/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error('Failed to update user');
}
return response.json();
}
This method updates the information of an existing user. It sends a PUT request to the API endpoint with the updated user data in the request body. If the update is successful, it returns the updated user data. If it fails, the method throws an error.
Finally, let’s take a look at the deleteUser function:
async deleteUser(userId) {
const response = await fetch(`https://api.example.com/users/${userId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete user');
}
return true;
}
In the userAPI.js file, functions are defined for interacting with the user API, including operations for retrieving, creating, updating, and deleting users.
This method deletes a user by their ID. It sends a DELETE request to the API endpoint. If the deletion is successful, it returns the value true. If an error occurs, the method generates an appropriate message.
Let’s write comprehensive tests for these API methods in the api.test.js file.
// api.test.js
import { expect, test, vi } from 'vitest';
import { userAPI } from './userAPI';
// Mock the fetch function globally
vi.mock('node-fetch');
const mockFetch = vi.fn();
global.fetch = mockFetch;
test('getUser fetches user data successfully', async () => {
const mockUser = { id: 1, name: 'John Doe' };
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser)
});
const user = await userAPI.getUser(1);
expect(user).toEqual(mockUser);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/users/1');
});
This first test case focuses on the getUser function. Let’s take a look at it:
In our tests, we mock the fetch function to control its behavior.
We set up a dummy user object that should be returned when the API is called.
We use mockFetch.mockResolvedValueOnce() to define our dummy user data and simulate a successful response from the API.
Next, we call userAPI.getUser(1) and use expect() to verify that the user data returned from the function matches our dummy user, and we also check that the fetch function was called with the correct URL.
Now, let’s examine the test for the createUser function:
test('createUser sends POST request with user data', async () => {
const newUser = { name: 'Jane Doe', email: 'jane@example.com' };
const mockResponse = { id: 2, ...newUser };
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse)
});
const createdUser = await userAPI.createUser(newUser);
expect(createdUser).toEqual(mockResponse);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/users',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser)
})
Explanation of the createUser Test:
We create a dummy object for the new user and a mock response.
We simulate a successful response from the API when creating the user.
Then, we call userAPI.createUser(newUser) and verify that the created user matches our mock response.
We also check that the fetch function was called with the correct URL, method, headers, and request body.
Now, let’s move on to the test for the updateUser function:
// api.test.js
test('updateUser sends PUT request with updated data', async () => {
const userId = 1;
const updatedData = { name: 'John Updated' };
const mockResponse = { id: userId, ...updatedData };
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse)
});
const updatedUser = await userAPI.updateUser(userId, updatedData);
expect(updatedUser).toEqual(mockResponse);
expect(mockFetch).toHaveBeenCalledWith(
`https://api.example.com/users/${userId}`,
expect.objectContaining({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedData)
})
);
});
Explanation of the updateUser Test:
It sets up test data: the user ID and the updated information.
It creates a mock response that the API would typically return.
It configures the mock fetch function to return a successful response with the dummy data.
It calls the updateUser function with the test data.
It checks that the data returned by the function matches the expected mock response.
It verifies that the fetch function was called with the correct URL, method (PUT), headers, and request body.
Now, let’s take a look at the test for the deleteUser function:
test('deleteUser sends DELETE request', async () => {
const userId = 1;
mockFetch.mockResolvedValueOnce({ ok: true });
const result = await userAPI.deleteUser(userId);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
`https://api.example.com/users/${userId}`,
expect.objectContaining({ method: 'DELETE' })
);
});
Explanation of the deleteUser Test:
It creates a test user ID.
It configures the mock fetch function to return a successful response.
It calls the deleteUser function with the test user ID.
It checks that the function returns true, indicating successful deletion.
Finally, it verifies that the fetch function was called with the correct URL and method (DELETE).
Finally, let’s take a look at the error handling tests.
test('getUser handles error response', async () => {
mockFetch.mockResolvedValueOnce({ ok: false });
await expect(userAPI.getUser(1)).rejects.toThrow('Failed to fetch user');
});
// Additional error handling tests can be added for other API methods
test('createUser handles error response', async () => {
mockFetch.mockResolvedValueOnce({ ok: false });
await expect(userAPI.createUser({})).rejects.toThrow('Failed to create user');
});
test('updateUser handles error response', async () => {
mockFetch.mockResolvedValueOnce({ ok: false });
await expect(userAPI.updateUser(1, {})).rejects.toThrow('Failed to update user');
});
test('deleteUser handles error response', async () => {
mockFetch.mockResolvedValueOnce({ ok: false });
await expect(userAPI.deleteUser(1)).rejects.toThrow('Failed to delete user');
});
These tests simulate API error responses and verify that each function returns an error with the expected message when the request is unsuccessful.
DOM Testing with Vitest
This testing tool is highly powerful for DOM testing and can simulate all browser actions, making it significantly easier to test user interactions. In this article, we will discuss the steps to set up and run DOM tests.
Setting Up jsdom
npm install --save-dev jsdom
Let’s create a simple function for manipulating the DOM and then test its functionality.
// Function to create a greeting element and add it to the DOM
export function createGreeting(name) {
// Create a new div element
const div = document.createElement('div');
// Set the text content of the div
div.textContent = `Hello, ${name}!`;
// Add a CSS class to the div
div.className = 'greeting';
// Append the div to the document body
document.body.appendChild(div);
// Return the created element
return div;
}
This createGreeting function creates a new div
element, sets its text content and class, and then adds it to the document body.
Now, let’s write a test for this function.
// dom-utils.test.js
import { expect, test, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import { createGreeting } from './dom-utils';
// Set up a fresh DOM before each test
beforeEach(() => {
// Create a new JSDOM instance with a basic HTML structure
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
// Set the global document and window objects to use the JSDOM instance
global.document = dom.window.document;
global.window = dom.window;
});
test('createGreeting adds a greeting to the DOM', () => {
// Clear the body content before the test
document.body.innerHTML = '';
// Act: Call the createGreeting function
const element = createGreeting('Vitest');
// Assert: Check if the greeting text is correct
expect(element.textContent).toBe('Hello, Vitest!');
// Assert: Check if the correct CSS class is applied
expect(element.className).toBe('greeting');
// Assert: Check if the element is actually in the document body
expect(document.body.contains(element)).toBe(true);
});
This test verifies the functionality of the createGreeting function, which creates and adds a greeting element to the DOM.
Event Testing
// counter.js
export function createCounter() {
const button = document.createElement('button');
button.textContent = 'Count: 0';
let count = 0;
button.addEventListener('click', () => {
count++;
button.textContent = `Count: ${count}`;
});
return button;
}
This createCounter function creates a button that increments a counter when clicked. Let’s take a look at the test right away.
// counter.test.js
import { expect, test, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import { createCounter } from './counter';
beforeEach(() => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
global.document = dom.window.document;
global.window = dom.window;
});
test('counter increments when clicked', () => {
const counter = createCounter();
document.body.appendChild(counter);
expect(counter.textContent).toBe('Count: 0');
counter.click();
expect(counter.textContent).toBe('Count: 1');
counter.click();
counter.click();
expect(counter.textContent).toBe('Count: 3');
});
This test verifies that the counter component's value increments correctly when clicked.
Visualizing Tests with a Graphical Interface
This tool offers an optional graphical user interface (GUI) through the @vitest/ui package. While using the GUI is not mandatory, it can enhance your experience by providing a user-friendly interface for managing and viewing test results.
npm install --save-dev @vitest/ui
Using the GUI Tool Panel
After installing the tool with the --ui flag:
npx vitest --ui
This will launch the GUI tool panel in your web browser. Using this panel, you can easily manage and view your test results.
Configuring Retries
By default, the framework does not retry failed tests. However, you can set up retries using the retry option in the test function:
test('flaky test', { retry: 3 }, () => {
// Your test logic here
});
Here’s an example of an unstable test with the retry parameter set to 3. If the test fails initially, the framework will attempt to run it up to three times before marking it as a final failure.
Conclusion
This testing environment, which integrates seamlessly with Vite, offers a powerful and flexible solution for modern web development. It is capable of handling various types of tests, including API calls, error handling, DOM manipulations, and event testing. Developers are utilizing this tool today to ensure their applications remain robust, stable, and high-quality.
This tool perfectly aligns with the fast-paced Vite workflow, making testing easy to start and straightforward to use with minimal configuration. For testing both front-end and back-end functionality, this framework is an excellent choice as it can create browser-like environments using jsdom, making it an ideal solution for testing in modern web development. Such an approach makes unit testing a natural part of the development process, enhancing the overall quality and reliability of your projects.