Read More on Telerik Blogs
May 05, 2025 Web, Vue
Get A Free Trial

Testing is vital for the reliability of our applications. See the essentials of testing Vue applications using Vitest and @vue/test-utils.

Imagine filling in a long form on a website, finally getting to the end, and clicking the submit button while starting to celebrate the job done. However, nothing happens. You decide to open the devtools, only to see a bunch of errors popping up when pressing the submit button again.

A situation like this can be very discouraging, and users often abandon websites after such ordeals. Fortunately, as developers, there are things we can do about reducing the chances of something like this happening. One of those is testing. In this article, we will cover how to set up and run tests with Vitest in a Vue application.

Why Is Testing Important?

An automated testing suite can be crucial for a success of an application, as it can improve application reliability, reduce the occurrence of bugs and increase the speed of delivering new features and updates to existing functionality.

With automated tests, developers can be more confident the code they wrote is bug free and won’t break existing functionality. There are three primary types of tests that can be used for Vue applications:

1. Unit Testing
Check the behavior of small functions, composables and components in isolation. For example, a unit test could check if a Tabs component works correctly and changes the active tab on a click.

2. Integration Testing
Assert multiple components and units work together in cohesion.

3. End-to-End (E2E) Testing
Simulate a real user and test the entire functionality flow from opening the browser, visiting the URL and interacting with the website to verify the frontend works seamlessly with the server and users are able to accomplish specific tasks.

There are pros and cons to each. While unit and integration tests can run faster, as most of the time they are tested in isolation without a need for API requests and interacting with any services, end-to-end tests can provide the most confidence, as they test that the whole system from the UI to the database works as expected.

What’s Vitest?

For many years, Jest was the testing framework of choice for JavaScript applications due to its robust functionality, mature community and plugin system, and compatibility with a lot of frameworks and libraries.

However, Jest can be quite slow in larger applications that might comprise hundreds or even thousands of files and tests. What’s more, it also doesn’t work well with modern tools such as Vite that rely on ES modules, as Jest uses CommonJS modules.

That’s where Vitest came into play. Vitest is a modern testing framework that is blazing fast in comparison to Jest and many other solutions. It has out-of-the-box support for ES modules, TypeScript, JSX and has Jest compatible API, which means migrating from Jest to Vitest is very straightforward.

It also has other interesting features, such as browser mode, in-source testing or type testing. You can find out more about all features in the documentation.

Project Setup with Vue, Vite and Vitest

The best way to learn to code is by practice, so let’s set up a new Vue project with Vite. After the setup, we will create a simple Vue component and then write unit tests with Vitest to test that the component works as expected. You can also find the full code for this article in this GitHub repository.

To scaffold a new Vue project, open the terminal and run the following command:

# npm 7+, extra double-dash is needed:
$ npm create vite@latest vue-vitest-basics -- --template vue

This command will create a new project in the vue-vitest-basics folder. After the installation is completed, change the directory into vue-vitest-basics and install vitest, @vue/test-utils and jsdom.

$ cd vue-vitest-basics
$ npm install -D vitest @vue/test-utils jsdom

If you’re wondering why we need to install @vue/test-utils, here’s the reason. Vitest itself is a test runner, but it’s framewor-agnostic. Therefore, it doesn’t know out of the box how to handle Vue components. For that, we need a separate library. @vue/test-utils is the official testing library provided by the Vue.js team. While there are other possible solutions, such as Vue Testing Library, in this article, we will focus on using @vue/test-utils.

Another library that we need to include is jsdom. By default, Vitest runs tests in the Node environment. However, Vue components need to be mounted in a browser-like environment. Since we will run our unit tests in Node, we need to configure Vitest to simulate the browser environment. We can do so by updating the vite.config.js file and adding test.environment config.

vite.config.js

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
  test: {
    environment: "jsdom",
  },
});

Finally, you can start the development environment by running the command below.

$ npm run dev

You can visit http://localhost:5173 in your browser to see the starting page.

One more thing we need to do as part of the setup is to update the package.json file. Add a new test script that will run Vitest.

package.json

"scripts": {
  // other scripts 
  "test": "vitest"
}

We don’t have any test files yet, but we will get to it in a moment. You can already run the test command in the terminal to start Vitest.

Building a Simple Tabs Component

Let’s create a simple Tabs component that will render tab buttons based on the props provided. The Tabs component will accept two props: tabs and modelValue. The former will be an array of strings for each tab that should be displayed, while the former will represent the currently active tab. Here’s the code for the component.

src/components/Tabs.vue

<script setup>
const props = defineProps({
  tabs: {
    type: Array,
    default: () => [],
  },
  modelValue: {
    type: String,
  },
});

const emit = defineEmits(["update:modelValue"]);
</script>

<template>
  <div>
    <ul :class="$style.tab_list" role="tablist">
      <li
        v-for="tab of tabs"
        :key="tab"
        role="presentation"
      >
        <button
          type="button"
          :class="[
            $style.tab_button,
            tab === props.modelValue && $style.active,
          ]"
          role="tab"
          :id="`tab-${tab}`"
          :aria-selected="tab === props.modelValue"
          @click="emit('update:modelValue', tab)"
        >
          {{ tab }}
        </button>
      </li>
    </ul>
  </div>
</template>
<style module>
.tab_list {
  list-style: none;
  display: flex;
  align-items: center;
  gap: 1rem;
  border-bottom: 1px solid #ccc;
  padding: 0;
}

.tab_button {
  background: none;
  border: none;
  padding: 0.5rem 1rem;
  cursor: pointer;
  color: #007bff;
}

.active {
  border-bottom: 2px solid #007bff;
  background-color: green;
  color: white;
}
</style>

The Tabs component loops through the tabs array provided via props and renders a button for each tab. Whenever a tab is active, the active style is applied and the aria-selected attribute changes to "true".

Now, we can update the App.vue component, render the Tabs component, and see what it looks like in the browser.

src/App.vue

<script setup>
import Tabs from "./components/Tabs.vue";
const tabs = ["Home", "Profile", "About", "Settings"];
const activeTab = defineModel("activeTab");
</script>

<template>
  <div>
    <Tabs :tabs="tabs" v-model="activeTab" />
  </div>
</template>

You should see in the browser four tabs for Home, Profile, About and Settings.

Whenever a tab is clicked, its styles should change to reflect that.

Now that we have a working component, let’s write some unit tests to verify its functionality.

Unit Testing the Tabs Component with Vitest

The Tabs component is quite simple in its functionality, but there are a few things we can test to verify it’s working as expected:

  1. The component renders the correct number of tabs, as provided via props.
  2. A correct tab is set as active based on the modelValue prop.
  3. The component emits an event when a tab is clicked and updates the active tab accordingly whenever the modelValue prop changes.
  4. No buttons are rendered if the tabs prop doesn’t contain any items.

By writing tests for the aforementioned scenarios, we will cover how to:

  • Mount and render Vue components
  • Query elements and verify their contents and attributes
  • Fire, intercept and verify events
  • Update props programmatically

Let’s start by creating a new file called Tabs.spec.js in the src/components directory and writing the first test.

src/components/Tabs.spec.js

import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import Tabs from "./Tabs.vue";

const tabs = ["Home", "Profile", "About", "Settings"];

describe("Tabs.vue", () => {
  it("renders correct number of tabs", () => {
    const wrapper = mount(Tabs, {
      props: {
        tabs,
      },
    });

    const buttons = wrapper.findAll("button");

    expect(buttons).toHaveLength(4);
    for (const [index, tab] of Object.entries(tabs)) {
      expect(buttons[index].text()).toBe(tab);
    }
  });
});

The describe function collects and groups all the tests defined inside, while it separates tests. Our first test checks if the Tabs component renders the correct number of tabs. As I mentioned before, Vitest doesn’t know how to handle Vue component directly, so that’s where the mount from the @vue/test-utils library comes into play. The Tabs component receives the tabs array as a prop with four items—Home, Profile, About and Settings. After the component is mounted, we find all the buttons and verify their text content matches the items in the tabs array.

Now that we have the first passing test, we can add a few more. Let’s check if the component emits an update event when a tab is clicked, and whether it updates the active tab accordingly.

src/components/Tabs.spec.js

// ...other code
describe("Tabs.vue", () => {
  // ...other tests
 
  it("emits update:modelValue on tab click", async () => {
    const wrapper = mount(Tabs, {
      props: {
        tabs: tabs,
        modelValue: tabs[0],
      },
    });
    const buttons = wrapper.findAll("button");
    expect(buttons[0].attributes("aria-selected")).toBe("true");
    expect(buttons[1].attributes("aria-selected")).toBe("false");
    await buttons[1].trigger("click");

    expect(wrapper.emitted("update:modelValue")).toBeTruthy();
    expect(wrapper.emitted("update:modelValue")[0]).toEqual([tabs[1]]);

    await wrapper.setProps({
      modelValue: tabs[1],
    });

    expect(buttons[1].attributes("aria-selected")).toBe("true");
  });
})

In the new test, we set the first tab as active by passing the "Home" text to the modelValue prop. After the component is mounted, we verify that it’s active by checking the state of the aria-selected attribute.

We are going to test whether the active tab will change on click from the first tab the second one, so we also verify the second tab is in the inactive state. Afterward, the click event is triggered on the second tab by executing the trigger('click') method on the second tab button. Note that the trigger methods must be awaited, as otherwise the test will proceed executing other lines of code before Vue is able to update the state and re-render any changes that occurred as a result of the click. This could result in race conditions and unexpected test outcomes.

After the promise returned by the trigger method is resolved, we verify the Tabs component emitted the update:modelValue event and check the argument passed to it matches the text of the second tab.

Next, we change the modelValue prop to match the second tab. The reason we do it is because the Tabs component here is tested in isolation, and the active tab state is actually controlled by the parent component that renders it.

In our tests, the Tabs component doesn’t have any parent, and we are responsible for passing the props. That’s why we change the modelValue prop programmatically. Similarly to the trigger method, setProps returns a promise that must be awaited.

Finally, we verify that the second button is now active by asserting the aria-selected attribute is “true”.

Here is the code for a few more tests that check if the Tabs component correctly handles attributes, and empty values.

// ...other code
describe("Tabs.vue", () => {
  // ...other tests
  
  it("sets correct role and id attributes", () => {
    const wrapper = mount(Tabs, {
      props: {
        tabs,
      },
    });
    const button = wrapper.find("button");
    expect(button.attributes("role")).toBe("tab");
    expect(button.attributes("id")).toBe(`tab-${tabs[0]}`);
  });

  it("handles empty tabs array", () => {
    const wrapper = mount(Tabs, {
      props: {
        tabs: [],
      },
    });
    const lis = wrapper.findAll("li");
    expect(lis).toHaveLength(0);
  });

  it("does not set aria-selected if modelValue not in tabs", () => {
    const wrapper = mount(Tabs, {
      props: {
        tabs: tabs,
        modelValue: "Features",
      },
    });
    const buttons = wrapper.findAll("button");
    buttons.forEach(button => {
      expect(button.attributes("aria-selected")).toBe("false");
    });
  });

  it("handles missing modelValue", () => {
    const wrapper = mount(Tabs, {
      props: {
        tabs: tabs,
      },
    });
    const buttons = wrapper.findAll("button");
    buttons.forEach(button => {
      expect(button.attributes("aria-selected")).toBe("false");
    });
  });
})

Conclusion

In this article, we’ve explored the essentials of testing Vue applications using Vitest and @vue/test-utils. By setting up a testing environment with Vite and crafting unit tests for a simple Tabs component, we’ve demonstrated how to verify component behavior, manage props, handle events and verify reliability under various conditions.

Testing is a cornerstone of building robust and dependable Vue applications; it catches potential issues early and boosts confidence in your code. To take your testing skills further, consider experimenting with more advanced scenarios, such as testing components with slots, handling asynchronous operations or integrating with state management solutions like Pinia.


About the Author

Thomas Findlay

Thomas Findlay is a 5-star rated mentor, full-stack developer, consultant, technical writer and the author of “React - The Road To Enterprise” and “Vue - The Road To Enterprise.” He works with many different technologies such as JavaScript, Vue, React, React Native, Node.js, Python, PHP and more. Thomas has worked with developers and teams from beginner to advanced and helped them build and scale their applications and products. Check out his Codementor page, and you can also find him on Twitter.

Related Posts