DEV Community

Cover image for Testing Vue components the right way
Maya Shavin πŸŒ·β˜•οΈπŸ‘
Maya Shavin πŸŒ·β˜•οΈπŸ‘

Posted on • Originally published at mayashavin.com

Testing Vue components the right way

This post will explore tips and practices we can use to simplify our Vue component testing with tools like Vitest.

Table of Contents

Before we start, let's take a look of how to set up Vitest for our project quickly.

Setting up Vitest

Vitest is the fast unit testing framework powered by Vite and for Vite-powered projects. It offers similar functionalities and syntax to Jest, with TypeScript/JSX supported out of the box. Many developers consider Vitest to be faster than Jest, especially in hot watch mode.

To set up Vitest in Vue, you can choose it as part of the configuration for a new project generated by Vite, or manually add it to the dependencies using the following command:

npm install -D vitest

//OR
yarn add -D vitest
Enter fullscreen mode Exit fullscreen mode

Then in vite.config.js, we add the following test object to the configuration, where we decide to use jsdom as our DOM environment:

test: {
    environment: "jsdom",
}
Enter fullscreen mode Exit fullscreen mode

We can also set global: true so that we don't have to import each method like the expect, describe, it, or vi instance explicitly from the vitest package in every test file.

Once done with vite.config.js, we need to add a new scripts command to the package.json file of our project to run the tests:

"scripts": {
    "test": "vitest --root src/",
}
Enter fullscreen mode Exit fullscreen mode

In the above code, we set the root of our tests as the src/ folder. We can also put it in vite.config.js under the test.root field. Both approaches work the same.

We are now ready to move on to testing our Vue component, starting with splitting our component's logic into reusable blocks of code.

Split your component into composable (unit block of code)

Yes. Composition APIs make your life easier, and composable is your weapon to achieve reusability and separate the component logic from the component's UI.

Take the following Movies for instance. This component displays a list of movies with the following features:

  • The component fetches the movies list from an external source.
  • Users can search for a movie by title.
  • Optional - There should be a loading state when fetching the movies and an error state when something wrong happens during the fetch.

The below screenshot shows how the component should look on the UI:

Screenshot showing how the list of movies look like on the browser

And our component's template will contain the following code:

<template>
    <div>
        <h1>Movies</h1>
        <div>
        <div>
            <label for="search">Search:</label>
            <input type="text" id="search" v-model="searchTerm" />
        </div>
        <ul>
                <li v-for="movie in filteredMovies" :key="movie.id">
                    <article>
                        <h2>{{ movie.Title }}</h2>
                        <img :src="movie.Poster" :alt="movie.Title" width="100" />
                        <p>Released on: {{ movie.Year }}</p>
                    </article>
                </li>
            </ul>
        </div>
    </div>
  </template>
Enter fullscreen mode Exit fullscreen mode

You may think we can write the component's logic as follows:

import { ref, onBeforeMount } from 'vue';

const movies = ref([]);

const searchTerm = ref("");
const filteredItems = computed(() => {
    return items.value.filter((item) => {
        return filters.some((filter) => {
        return item[filter]
            .toLowerCase()
            .includes(searchTerm.value.toLowerCase());
        });
    });
});

onBeforeMount(async () => {
    const response = await fetch("https://swapi.dev/api/films");

    if (!response.ok) {
    throw new Error("Failed to fetch movies");
    }

    const data = await response.json();

    movies.value = data.results;
});

Enter fullscreen mode Exit fullscreen mode

However, testing or reading this component or adding new features can be painful as it grows larger. Also, some logic may be reusable for others, such as the fetch and search mechanism. Instead of writing all the code logic within a single component, we can split them to separate unit blocks of code, or composable, as follows:

  1. useMovies - This composable will fetch the list of movies from external API and returns them ready to be used in the component. It can also return a loading state and error state when applicable.
  2. useSearch - This composable will add the search feature to the component. It receives an original list of items and returns a reactive searchTerm, the filtered list, and a method to update the term accordingly.

Now our component logic will be much shorter:

  <script setup>
  import { useMovies } from "../composables/useMovies.js";
  import { useSearch } from "../composables/useSearch.js";

  const { items: movies } = useMovies();
  const { searchTerm, filteredItems: filteredMovies } = useSearch(movies);
  </script>
Enter fullscreen mode Exit fullscreen mode

And we can write tests dedicated to useSearch, useMovies, and integration tests for the component's rendering (when it is in loading, in error state, user change input, etc.). Doing so allows us to divide any potential bug (if any) into its related section and conquer (or fix) it without affecting other logic sections.

An example of the test cases the useSearch can be as below:

import { expect, describe, it } from "vitest";
import { useSearch } from "./useSearch";

describe("useSearch", () => {
  it("should return default value of searchTerm and original items as filtered items", () => {
    //...
  });

  it("should update searchTerm and return filtered items by title accordingly", () => {
    //...
  });

  it("should return filtered items by description field and searchTerm accordingly", () => {
    //...
  })
});

Enter fullscreen mode Exit fullscreen mode

Unit testing with Vitest (or Jest) for Vue composable can be straightforward without additional setup. However, in many cases where your composable contains life cycle hooks, we need a Vue testing component, which we explore next.

Mount mocked app for composable that uses life cycle hooks

Vitest, or any testing library, doesn't bind or contain any Vue context. Standalone Composition APIs like ref(), reactive(), computed(), etc... can act as independent APIs for creating reactive variables without any Vue lifecycle involved. Vitest alone is good enough for testing for composable using these APIs only.

However, once the composable triggers any Vue lifecycle hooks (onBeforeMount, onMounted, etc.), we need to wrap the composable inside a Vue component, simulating the desired lifecycle for testing it.

To create a Vue component using the setup() hook for testing, we make a utility function called withSetup as follows:

export function withSetup(hook) {
    let result;

    const app = createApp({
        setup() {
            result = hook();
            return () => {};
        },
    });

    app.mount(document.createElement("div"));

    return [result, app];
}
Enter fullscreen mode Exit fullscreen mode

Notice withSetup receives a composable (or hook), as return the result of the composable, and the app instance in the form of an array. You can place this function file in your application's test-utils folder or any place dedicated to testing utilities.

Take our useMovies composable, with the following implementation that uses onBeforeMount, for instance:

import { ref, onBeforeMount } from 'vue';

export const useMovies = () => {
  const items = ref([]);

  const fetchItems = async () => {
    try {
      const response = await fetch("https://swapi.dev/api/films");

      if (!response.ok) {
        throw new Error("Failed to fetch items");
      }

      const data = await response.json();

      items.value = data.results;

    } catch (err) {
      //do something
    } finally {
      //do something
    }
  };

  onBeforeMount(fetchItems);

  return {
    items,
  };
};

Enter fullscreen mode Exit fullscreen mode

When testing useMovies, we can trigger withSetup with the composable as its parameter, as seen below:

  it("should fetch movies", () => {
    const [results, app] = withSetup(useMovies);

    //Assert results
  });
Enter fullscreen mode Exit fullscreen mode

And after we finish our test case, we need to unmount the app:

  it("should fetch movies", () => {
    //...
    app.unmount()
  });
Enter fullscreen mode Exit fullscreen mode

With withSetup(), we can ensure onBeforeMount will be triggered and can add tests for any related data changes within this hook.

Nevertheless, for useMovies, we still have one problem. Vue triggers onBeforeMount (or any lifecycle hooks) in a synchronous order, and our fetchItems callback is asynchronous. Vue continues the creation process without waiting for the callback to resolve. And since Vue doesn't know when the asynchronous will resolve, using $nextTick() will not guarantee the new data changes applied to the composable's results. For such cases, we need the help of the @vue/test-utils package, as in the following section.

Flush promises for composable with asynchronous call

vue/test-utils - or Vue Test Utils is the official testing library for the Vue component. It provides a set of utility functions for simplifying testing Vue components. You can use its functions to mount and simulate Vue component interactions. One of the functions we will use today for our useMovies test is flushPromises.

import { flushPromises } from "@vue/test-utils";
Enter fullscreen mode Exit fullscreen mode

flushPromises flushes all the resolved Promise handlers, ensuring all the asynchronous calls are complete before we start asserting. In our previous test, we need to change our test to be async and make sure we await for the flushPromises to finish before asserting the result, as in the following code:

it("should fetch movies", async () => {
    const [results, app] = withSetup(useMovies);

    await flushPromises();

    //Assert results

    app.unmount()
});
Enter fullscreen mode Exit fullscreen mode

Straightforward enough. We now have the composable's asynchronous test case with the lifecycle hook covered.

In testing, we should keep things manageable for the stability and quality of our tests. For testing such composable like useMovies where we use the fetch function internally, we can't fully control the result of the composable for our testing purpose. Some examples are when the request fails or when the request is in progress, or when to resolve the requestβ€”calling the original fetch in our composable equals leaving the test result to the uncontrolled external API response, which can end up rejected when it should resolve (internet connection issue, for instance), long waiting time, etc.

Instead, we should mock (or spy on).

Spy on and mock whatever you don't need to test (or have tested already)

Mocking is a method we use in testing to simulate the behavior of external dependencies in a controlled way. Using mocks allows us to test the code without invoking the dependent process, such as using an actual fetch request in our useMovies mock.

We use mocks based on the assumption that the mocked dependency works as it should. We test the unit code that uses that dependency and then assert how that unit code responds to different values of the mocked dependency.

fetch() takes the URL path to the resource you request as mandatory and returns a Promise that resolves into a Response to that request. In our code example, useMovies, we will mock global.fetch, a built-in method of sending HTTP requests, with the vi.spyOn() where vi is the Vitest instance imported from vitest package.

vi.spyOn(global, 'fetch')
Enter fullscreen mode Exit fullscreen mode

vi.spyOn(), similar to jest.spyOn(), accepts two mandatory arguments: an object and a string indicating the target object's method we wish to spy on. It returns a new mock with a set of methods for mocking implementation or mocking return value.

Here we will mock the return value of the fetch() for the useMovies test case. We can perform that action before all the tests start (beforeAll), before each test (beforeEach), or within the test case itself. In our below code example, we will mock the return value of fetch() using the mockReturnValue method before all the tests start:

describe('useMovies', () => {
    beforeAll(() => {
        const mockFetch = vi.spyOn(global, 'fetch');

        mockFetch.mockReturnValue(createMockResolveValue({
            results: [
                {
                    title: "A New Hope",
                },
            ]
        }));
    })
})
Enter fullscreen mode Exit fullscreen mode

Where createdMockResolveValue is a utility function that takes our desired results and wraps them with the correct response format (with a json() method and a status ok), as follows:

function createMockResolveValue(data) {
    return { 
        json: () => new Promise((resolve) => resolve(data)), 
        ok: true 
    };
}
Enter fullscreen mode Exit fullscreen mode

And with the above code, we now have our fetch() mocked with a designated returned value, allowing our code to run independently.

We can also perform the spying out of the scope of beforeAll but under the range of the test suite (within the describe callback), allowing us to reuse the mock instance in other test cases specifically if needed.

describe('useMovies', () => {
    const mockFetch = vi.spyOn(global, 'fetch');

    it("should fetch movies", async () => {
        mockFetch.mockReturnValue(createMockResolveValue({
            results: [
                {
                    title: "A New Hope",
                },
            ]
        }));

        //...
    })
})
Enter fullscreen mode Exit fullscreen mode

One important note is that once you mock something in your tests, you need to clear the mocks at the end of the test to avoid affecting other tests. For example, Vitest (or Jest) will use the returned value from a specific available mockReturnValue call within that test suite if there is no other implementation mock, leading to undesired behavior. To avoid such circumstances, we call mockClear() method at the end of every test (afterEach), as shown below:

describe('useMovies', () => {
    const mockFetch = vi.spyOn(global, 'fetch');

    //tests

    afterEach(() => {
        mockFetch.mockClear();
    })
})
Enter fullscreen mode Exit fullscreen mode

We clear the mocked values separately from the test cases as a best practice to keep our test code concise and relevant per test suite. Any standard behavior (such as imitating the same return value for every test or clearing the mocks) should be grouped into more generic functions for Vitest to run after/before continuing the test cases.

Using vi.spyOn() allows you to mock a method and track any of its calls. For example, if we want to make sure our fetch() in useMovies is triggered with the correct URL, and only once, we can perform the following:

expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith("https://swapi.dev/api/films")
Enter fullscreen mode Exit fullscreen mode

An alternative for spying on and mocking is using global.fetch = vi.fn(). However, I do not recommend using this approach, especially when you have a large codebase and can't control which component or composable may also use fetch().

We have split our component code into composable and mocked whatever was needed. We can apply the same approaches to testing the component after testing its composable by imitating those composables' returned values to match our component's test cases. Also, whenever we need to add a new feature, such as a loading screen while loading the movies, we only need to add two tests:

  • One to the component Movies's UI with mocked returned value of isLoading from useMovies is true.
  • The other test to useMovies where we assert the value of isLoading to be true before resolving the response.

It seems straightforward enough.

The next and final tip we will discuss is checking the test coverage, a significant part of ensuring our project's coverage range is at the desired level of testing.

Checking the coverage

Writing tests is essential, and knowing whether you test all the required cases for your logic is even more critical. The most common testing coverage tool is Istanbul, where you can see how well your tests exercise your code by lines, functions, and branches. Below is an example of how the test coverage report looks in your terminal:

Screenshot showing the coverage status of each code file as table

You can also define the coverage report in HTML format or export it to a file by configuring your coverage tool with various available reporter tools.

For Vitest, to use the Istanbul coverage tool, you need to install an additional package @vitest/coverage-istanbul with the following command:

yarn add -D @vitest/coverage-istanbul

//OR
npm i -D @vitest/coverage-istanbul
Enter fullscreen mode Exit fullscreen mode

Then, in your vitest.config.js, add a new field coverage within the test object, as follows:

test: {
    globals: true,
    environment: "jsdom",
    coverage: {
      provider: 'istanbul'
    }
}
Enter fullscreen mode Exit fullscreen mode

You can also set up the additional reporter tool, such as in HTML, as below:

test: {
    globals: true,
    environment: "jsdom",
    coverage: {
        provider: 'istanbul',
        reporter: ['html']
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we add a new scripts command for the coverage report in your project's package.json:

"scripts": {
    "coverage": "vitest run --coverage"
},
Enter fullscreen mode Exit fullscreen mode

Upon running the command yarn coverage (or npm coverage), besides the terminal output report, there will be a folder coverage created in your project's directory, with dedicated html report files for folders, as seen below:

An example output of the coverage folder

When you open coverage/composables/index.html on the browser, it will show you the report status of each composable testing coverage within the folder in a much more readable HTML format:

Screenshot showing the coverage status of each code file as table in HTML format

Finally, we can configure our project's testing coverage thresholds (in a more straightforward explanation - the minimum percentage of code coverage we want our tests to achieve) by assigning values to the fields branches, functions, or lines, or statements within the coverage object in the vite.test.js file, as follows:

coverage: {
    branches: 80,
    functions: 80,
    lines: 80,
    statements: 10,
}
Enter fullscreen mode Exit fullscreen mode

Each number assigned to the above fields represents the percentage we wish to achieve. With this configuration, we finally have our testing quality standards defined. If there is any test that doesn't meet the required expectations, we will see it both in the HTML report and in the terminal, like in the screenshot below:

Screenshot showing the coverage status of each code file as table and a coverage percentage status at the bottom

Great, right? And that's it. You can now go ahead and test your components efficiently.

Full demo code is available here

Summary

Testing is mandatory for code confidence, though it can sometimes be challenging. However, with the proper planning for your code and component, you can quickly achieve the required test coverage for your code and build a readable code system for your project. Splitting your component's logic into composable when applicable and dividing your logic into small and testable functions are some practices you can always apply to protect your code from the bottom.

And finally, remember to mock and mock responsibly for your tests.

What's next? It's time to go and write some tests πŸ˜‰, maybe even testing your component's UI-only features like accessibility support with axe-core?

πŸ‘‰ If you'd like to catch up with me sometimes, follow me on Twitter | Facebook.

Like this post or find it helpful? Share it πŸ‘‡πŸΌ πŸ˜‰

Oldest comments (1)

Collapse
 
Sloan, the sloth mascot
Comment deleted