Engineering Core
ISB Vietnam's skilled software engineers deliver high-quality applications, leveraging their extensive experience in developing financial tools, business management systems, medical technology, and mobile/web platforms.

In software development, writing unit tests is an effective technique for developers to ensure the quality of their source code. There are many popular testing frameworks, such as Cypress, Vitest, Jest, Mocha, etc. In this article, I will guide you through writing unit tests using Vitest, a modern framework that leverages the speed of Vite.

This guide will walk you through, step-by-step, how to write robust unit tests for quite common scenarios in a modern Vue 3 + TypeScript application: get data from API and fetching data to populate a table. Finally, we'll learn how to measure the effectiveness of our tests using Code Coverage.

Assume that we have a page that displays a list of users when opened. The data is fetched from an API.
During the data fetching process, a loading icon is displayed and is hidden once the data retrieval is complete.

The example source code is as follows:

The content of the component that displays the user list. To keep this article from being too long, you are free to define the CSS yourself.

src/components/UserTable.vue

<template>

  <div class="user-table-container">
    <h2>User List</h2>
    <divv-if="loading"class="loading-message">
      <div class="spinner"></div>
      <span>Loading users...</span> </div>
    <div v-if="error" class="error-message">
      <p>Error: {{ error }}</p>
      <p>Please try again later.</p>
    </div>
    <table v-if="users.length > 0" class="user-table">
      <thead>
        <tr>
          <th>ID</th>
          <th>Name</th>
          <th>Email</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="user in users" :key="user.id">
          <td>{{ user.id }}</td>
          <td>{{ user.name }}</td>
          <td>{{ user.email }}</td>
        </tr>
      </tbody>
    </table>
    <p v-else-if="!loading && !error" class="no-users-message">No users found.</p>
  </div>
</template>

 

<script setup lang="ts">
import { fetchUsers, type User } from '@/services/userService';
import { ref, onMounted } from 'vue';
 
const users = ref<User[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
 
onMounted(async () => {
  error.value = null;
  try {
    users.value = await fetchUsers();
  } catch (e: any) {
    error.value = e.message;
  } finally {
    loading.value = false;
  }
});
</script>
 
Here is the content for our data service. For this example, we will be using the public API from JSONPlaceholder.
src/services/userService.ts
export interface User {
  id: number;
  name: string;
  email: string;
}
 
export async function fetchUsers(): Promise<User[]> {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  if (!response.ok) {
    throw new Error('Failed to fetch users from JSONPlaceholder');
  }
  return response.json();
}
 
Let's now begin writing the unit tests for our two source files, UserTable.vue and userService.ts.
 

Setting up the environment for unit testing

First, open your terminal and run the following command:
Bash
npm install -D vitest @vue/test-utils jsdom
 
Next, open your vite.config.ts file and add the configuration for Vitest:
vite.config.ts
import { defineConfig } from 'vitest/config'
import { fileURLToPath, URL } from 'node:url'
import vue from "@vitejs/plugin-vue";
 
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    },
   },  
  test: {
    coverage: {
      reporter: ['text', 'json', 'html'],
    },
    globals: true,
    environment: 'jsdom',
  },
})
 
Next, add below test script to your package.json:
package.json
{
  "scripts": {
...................
...................
...................
    "test:unit": "vitest",
    "test:unit:coverage": "vitest run --coverage",
    "test:unit:ui": "vitest --ui --coverage"
  }
}
 
Finally, install vitest ui to view Code Coverage result.
Bash
npm install -D @vitest/ui
 
Our environment is now ready for testing.
 

Create and implement tests for UserTable.vue and userService.ts.

Create a tests folder alongside the src directory that mirrors its structure.
tests/components/UserTable.spec.ts
import { mount, flushPromises } from '@vue/test-utils';
import UserTable from '../../src/components/UserTable.vue';
import * as userService from '../../src/services/UserService';
import type { User } from '../../src/services/UserService';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; 
 
describe('UserTable.vue', () => {
    // Declare global variables to control the Promise
    let resolveFetchUsers: (value: User[]) => void;
    let rejectFetchUsers: (reason?: any) => void;  
    // Configure mock before each test
    beforeEach(() => {
      vi.restoreAllMocks(); // Ensure a clean state before each test
  
      // Create a pending promise and store its resolve/reject functions.
      // `fetchUsers` will be mocked to return this promise.
      const pendingPromise = new Promise<User[]>((resolve, reject) => {
        resolveFetchUsers = resolve; // Store the resolve function
        rejectFetchUsers = reject;   // Store the reject function
      });  
      vi.spyOn(userService, 'fetchUsers').mockImplementation(() => pendingPromise);
    });
 
    // Test Case 1: Initial loading state
    it('should display a loading state initially', () => {
      // Since loading.value is true from the start in the component,
      // the loading state will appear immediately after mount.
      const wrapper = mount(UserTable);  
      // Now, the loading state should be present for us to assert
      expect(wrapper.find('.loading-message').exists()).toBe(true);
      expect(wrapper.text()).toContain('Loading users...');  
      // Ensure other sections are not displayed
      expect(wrapper.find('table').exists()).toBe(false);
      expect(wrapper.find('.error-message').exists()).toBe(false);
    });
  
    // Test Case 2: Successful API call and rendering of user data
    it('should render a table of users on a successful API call', async () => {
      const mockUsers: User[] = [
        { id: 1, name: 'Alice Smith', email: 'alice@example.com' },
        { id: 2, name: 'Bob Johnson', email: 'bob@example.com' },
        { id: 3, name: 'Charlie Brown', email: 'charlie@example.com' },
      ];  
      const wrapper = mount(UserTable); // Component mounts and fetchUsers starts (pending)  
      // Trigger the Promise to resolve successfully with mock data
      resolveFetchUsers(mockUsers);      
      // Wait for all Promises to resolve and the component to update the DOM
      await flushPromises();  
      // Assert successful outcome
      expect(wrapper.find('.loading-message').exists()).toBe(false);
      expect(wrapper.find('.error-message').exists()).toBe(false);
      const table = wrapper.find('table.user-table');
      expect(table.exists()).toBe(true);  
      const rows = wrapper.findAll('tbody tr');
      expect(rows).toHaveLength(mockUsers.length);
      expect(rows[0].text()).toContain('Alice Smith');
      expect(rows[0].text()).toContain('alice@example.com');
    });
  
    // Test Case 3: Handling API call failure
    it('should display an error message on a failed API call', async () => {
      const errorMessage = 'Failed to connect to server';  
      const wrapper = mount(UserTable); // Component mounts and fetchUsers starts (pending)  
      // Trigger the Promise to be rejected
      rejectFetchUsers(new Error(errorMessage));      
      // Wait for the Promise to be rejected and the component to update the DOM
      await flushPromises();  
      // Assert error outcome
      expect(wrapper.find('.loading-message').exists()).toBe(false);
      expect(wrapper.find('table').exists()).toBe(false);  
      const errorDiv = wrapper.find('.error-message');
      expect(errorDiv.exists()).toBe(true);
      expect(errorDiv.text()).toContain('Error: ' + errorMessage);
    });
  
    // Test Case 4: Handling an empty array from API
    it('should display "No users found" if API returns an empty array', async () => {
      const wrapper = mount(UserTable); // Component mounts and fetchUsers starts (pending)  
      // Trigger the Promise to resolve with an empty array
      resolveFetchUsers([]);      
      // Wait for the Promise to resolve and the component to update the DOM
      await flushPromises();  
      // Assert no user found outcome
      expect(wrapper.find('.loading-message').exists()).toBe(false);
      expect(wrapper.find('.error-message').exists()).toBe(false);
      expect(wrapper.find('table').exists()).toBe(false);      
      expect(wrapper.text()).toContain('No users found.');
      expect(wrapper.find('.no-users-message').exists()).toBe(true);
    });
  });
 
tests/services/UserService.spec.ts
import { fetchUsers, type User } from '../../src/services/UserService'; // Adjust path if needed
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; 
 
describe('userService', () => {
  // Variable to store the Spy object for the global fetch function
  let fetchSpy: vi.SpyInstance;
  // Runs before each test case
  beforeEach(() => {
    // Reset all mocks and spies to ensure each test case is independent
    vi.restoreAllMocks();
    // Create a spy on the global `fetch` function. This allows us to control its behavior.
    fetchSpy = vi.spyOn(global, 'fetch');
  });
  // Runs after each test case (vi.restoreAllMocks is already in beforeEach, but can be kept for robustness)
  afterEach(() => {
    // Restore the original `fetch` function after the test is done
    vi.restoreAllMocks();
  });
 
  // Test Case 1: Check fetch users successfully
  it('should fetch users successfully', async () => {
    // 1. Prepare mock data that `fetchUsers` should return
    const mockUsers: User[] = [
      { id: 1, name: 'Alice Smith', email: 'alice@example.com' },
      { id: 2, name: 'Bob Johnson', email: 'bob@example.com' },
    ];
    // 2. Mock the `fetch` response
    // `mockResolvedValueOnce` will make this `fetch` call return a successful Promise
    // with a simulated Response object.
    fetchSpy.mockResolvedValueOnce({
      ok: true, // `response.ok` will be true
      json: () => Promise.resolve(mockUsers), // The json() function will return the mockUsers data
      status: 200,
      statusText: 'OK',
    } as Response); // Type assertion for Response properties
    // 3. Call the `fetchUsers` function to be tested
    const users = await fetchUsers();
    // 4. Assertions
    // Check if `fetch` was called exactly once
    expect(fetchSpy).toHaveBeenCalledTimes(1);
    // Check if `fetch` was called with the correct URL
    expect(fetchSpy).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users');
    // Check if `fetchUsers`'s return value matches the mock data
    expect(users).toEqual(mockUsers);
  });
 
  // Test Case 2: Check throw an error if fetching users fails
  it('should throw an error if fetching users fails (e.g., HTTP 404/500)', async () => {
    const errorMessage = 'Failed to fetch users from JSONPlaceholder';
    // 1. Mock the `fetch` response to indicate an error (response.ok is false)
    fetchSpy.mockResolvedValueOnce({
      ok: false, // `response.ok` will be false
      status: 404,
      statusText: 'Not Found',
      json: () => Promise.resolve({ message: 'Resource not found' }), // Return JSON for the error
    } as Response);
    // 2. Assert that the function throws the expected error
    await expect(fetchUsers()).rejects.toThrow(errorMessage);
    // Still check if `fetch` was called
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });
 
  // Test Case 3: Check throw an error if there is a network issue
  it('should throw an error if there is a network issue (fetch itself fails)', async () => {
    const networkError = new Error('Network request failed');
    // 1. Mock `fetch` to throw an error directly (simulating a network error)
    fetchSpy.mockRejectedValueOnce(networkError);
    // 2. Assert that the function throws that network error
    await expect(fetchUsers()).rejects.toThrow(networkError);
    // Still check if `fetch` was called
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });
});
 
Now, we will execute all of our test cases. If any errors occur, we will fix them. The goal is to ensure that all test cases pass.
Bash
npx vitest .\tests\

The test results for the two files are shown in the image below:

 

Measuring Your Test Coverage

Let's review the code coverage results. The closer the percentage is to 100%, the more comprehensive the unit tests are.
Bash
npm run test:unit:uinpx vitest .\tests\

The coverage results for the two source files are shown in the image below:

 

Conclusion

In summary, this article has guided you through writing unit tests for two Vue.js source files for a single feature using Vitest.
For official documentation, please refer here
I hope this guide will help you better understand this testing framework.
 

References

Ready to get started?

Contact IVC for a free consultation and discover how we can help your business grow online.

Contact IVC for a Free Consultation
Written by
Author Avatar
Engineering Core
ISB Vietnam's skilled software engineers deliver high-quality applications, leveraging their extensive experience in developing financial tools, business management systems, medical technology, and mobile/web platforms.

COMPANY PROFILE

Please check out our Company Profile.

Download

COMPANY PORTFOLIO

Explore my work!

Download

ASK ISB Vietnam ABOUT DEVELOPMENT

Let's talk about your project!

Contact US