How I started writing unit tests for Vue components

There comes a time in the life of every front-end developer, when it's time to enter the right door when you realize that it would be nice to somehow confirm that your code works. For me, this moment came after a painful migration from Vue 2 to Vue 3 with tons of defects, which ended with another strand of gray hair, and not only for me. Enough of this (c).

My name is Dmitry, I am a Frontend developer at fuse8, and in this article we will look at how you can start testing Vue components.

My project and technology stack

The current project I am working on is a classic CRM, where there are forms, forms sprinkled with forms, list output, modals, and permanent data transformation in these forms. The technology stack includes Vue 3, Pinia, Vite and ElementPlus, which makes it possible to develop interfaces quite quickly and flexibly.

Why unit tests?

After reading the theory about the pyramids and cups testing, I came to the conclusion that it is easier to integrate into the process, gradually adding “unit” tests for new components and features, as well as covering the bugs found with tests.

Just in case, a few words about the testing pyramid. This is a concept proposed by Martin Fowler that helps organize tests in a project.

Hidden text
  • Unit Tests: The most basic level of testing, which checks individual modules or features of an application. They are quick and easy to set up. Their goal is to check that individual components work correctly in isolation.

  • Integration Tests: tests the interaction between modules or components, such as how components on a page interact with each other. They are more complex and slower, but provide insight into the correctness of the integrations.

  • E2E tests (End-to-End Tests): Test the entire application flow from start to finish, simulating user actions. These tests can be slow and require complex infrastructure, but they ensure that the application works as a whole.

The word “Unit” is in quotes because we cannot wet our framework to test components (in theory we can, but why?)), which means that these are still integration tests, but for simplicity, in the community these are also called Unit tests.

Of course, there were other options. For example, e2e testing using Playwright, which runs tests in a real browser, emulating user actions. This is a powerful tool, but in practice I encountered a number of infrastructure difficulties, which I will talk about at the end of the article. Spoilers: the backend was not ready for this, and, wisely, e2e should be written under the strict guidance of QA – these guys and girls know how to break the system.

Ultimately, I settled on Vitest and Vue Test Utils as my primary testing tools.

Vitest was chosen as the runner because Vite was already installed in the project and Vue Test Utils provided all the necessary tools to mount and modify Vue components.

My pick: Vitest and Vue Test Utils

Why Vue Test Utils and not Testing Library? Firstly, it is recommended by the Vue community (it seems Testing Library was late in migrating to Vue 3).
Secondly, I adhere to the “London School” of testing (that is, we mock the entire Internet around our component) so that the test is as fair as possible. In addition, the Testing Library is built on top of VTU, and I wanted fewer dependencies.

To get started, you need to install and configure Vitest And Vue test utils.

npm i -D vitest @vue/test-utils

Since our tests will be run in a node environment, DOM implementers are needed. Vitest recommends either happy-dom or jsdom.

I settled on jsdom as it's more popular and faster than happy-dom, but you can switch quickly, so it's your choice.

 npm i jsdom -D

Setting up the environment for testing

Let's add parameters to our vite.config.js.

defineConfig({
...
test: {
 environment: 'jsdom',
 deps: {
   inline: ['element-plus'], // необязательное поле
 },
},
})

As I said, there is Pinia in the stack. You can use the real store for testing, or you can test. I chose the test one, because in this case you can mutate the store directly, and this is sometimes useful.

npm i -D @pinia/testing

What and how to test?

For example, let's take a component that has a button and a modal window component.

For context, the component schematic is shown in the figure.

There is a button (active if the user has rights to use it), when clicked, a form opens in a modal window (a separate component). When filling in the form fields and clicking the “Add” button, a request is sent to the server, and after a successful response, an event is generated that the document has been added.

component diagram

component diagram

Component code:

<template>
  <div>
    <button :disabled="!isAddDocumentAvailable" class="button button--primary" @click="addIncomingMail">Добавить входящую корреспонденцию</button>
    <popup drawer :is-form-open="isFormOpen" @closed="closeForm">
      <add-incoming-mail-modal :lawsuit-id="lawsuitId" @closeForm="closeForm" @mail-added="onMailChanged" />
    </popup>
  </div>
</template>

<script setup >
import { ref } from 'vue';
import AddIncomingMailModal from '@/.../AddIncomingMailModal.vue';
const isFormOpen = ref(false);

const emits = defineEmits(['mail-added']);
defineProps({
  lawsuitId: {
    type: Number,
    required: true,
  },	
});

function addIncomingMail() {
  isFormOpen.value = true;
}

function closeForm() {
  isFormOpen.value = false;
}
function onMailChanged() {
  emits('mail-added');
}
const isAddDocumentAvailable = computed(() => {
  return checkAvailabilityByClaim(...);
});
</script>

A bit of theory. First, let's define what needs to be tested. The literature recommends following user behavior as much as possible and skipping implementation details, which is logical. It is assumed that we will test our component as a black box, changing (preparing) the component's input data and testing the output.

Note: if you have a mega function with complex calculations in your component, it obviously needs to be taken out of the component and tested separately with a classic unit test.

Mocks and stubs are responsible for isolating tests. Mocks replace the real dependencies of a component, allowing you to control their behavior. Stubs simplify interaction with external systems (API, for example), replacing them with simplified versions. The main thing here is not to try to mock everything: mock only those parts of the code that actually affect the functionality being tested, to avoid excessive test dependence on the mock.

Input data includes properties, components, dependency injections, external storage and slots (maybe I forgot something). In order for the test to be fair, we must replace components, storage, DI with stubs. This will reduce the likelihood of a false positive test.

Vue Component Inputs and Outputs

Vue Component Inputs and Outputs

At the output, we must test the resulting html. Check the api call (or the result of the API call), and the call itself must also be replaced with a stub, we do not want to damage the backend. And also, it is necessary to check the generation of events that will be processed outside the component.

Let's move on to writing the test. Let's decide what needs to be tested (as they say, “draw a circle”).

describe('Добавление входящей корреспонденции - AddIncomingMail', () => {

  it.todo('Кнопка "Добавить входящую корреспонденцию" активна если у пользователя есть права', async () => {});

  it.todo('По клику на кнопку "Добавить входящую корреспонденцию" открывается модальное окно добавления нового документа', async () => {});

  it.todo('При добавления входящей корреспонденции, генерируем событие наружу компонента', async () => {});
});

What to pay attention to here? By making descriptions and explaining what we expect to receive when performing actions, we get self-documenting code… In theory…

I use the option of placing tests as close to the components as possible. This seems to be a better option for understanding how the component works.

In general, we draw another circle, add details, and get an owl, that is, a test.

import { getButtonByText } from '@/mocks/helpersForTesting/searchElements/index.js';
import { shallowMount } from '@vue/test-utils';
import AddIncomingMail from '@/components/AddIncomingMail.vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTestingPinia } from '@pinia/testing';

let wrapper; // будет храниться экземпляр компонента

beforeEach(() => { // перед каждым тестом монтируем компонент с начальными настройками
  wrapper = shallowMount(AddIncomingMail, {
    global: {
	plugins:[     
createTestingPinia({ // Создаем тестовый экземпляр Pinia
          createSpy: vi.fn,
          stubActions: false,
          initialState: {
            user: {
              state: {
                claims: [1,2],
              },
            },
          },
        }),]
      stubs: { // Так как это юнит тестирование, то все дочерние компоненты заменяем заглушками
        AddIncomingMailModal: {
          name: 'AddIncomingMailModal',
          emits: ['mail-added'],
          template: '<div><h2 >Добавить корреспонденцию</h2></div>',
        },
        popup: {
          props: { isFormOpen: false },
          template: '<div v-if="isFormOpen"><slot/></div>',
        },
      },
    },
    props: {
      lawsuitId: 15,
    },
  });
});

afterEach(() => { // После каждого теста сбрасываем заглушки и уничтожаем экземпляр
  wrapper.unmount();
  vi.resetAllMocks();
});

describe('Добавление входящей корреспонденции - AddIncomingMail', () => {
  it.todo('Кнопка "Добавить входящую корреспонденцию" активна если у пользователя есть права', async () => {
    const button = getButtonByText({ wrapper, buttonText: 'Добавить входящую корреспонденцию' }); // Сделал хелпер для быстрого поиска кнопочек

expect(button.element.disabled).toBe(false);
});

  it('По клику на кнопку "Добавить входящую корреспонденцию" открывается модальное окно добавления нового документа', async () => {
    const button = getButtonByText({ wrapper, buttonText: 'Добавить входящую корреспонденцию' }); 

    await button.trigger('click'); 
    await wrapper.vm.$nextTick(); //Так как Vue изменения выполняет асинхронно, то важно обождать 

    expect(wrapper.text()).contain('Добавить корреспонденцию');
  });

 it('При добавления входящей корреспонденции, генерируем событие наружу компонента', async () => {
    const button = getButtonByText({ wrapper, buttonText: 'Добавить входящую корреспонденцию' });
    await button.trigger('click');
    await wrapper.vm.$nextTick();
    const modalStub = wrapper.findComponent({ name: 'AddIncomingMailModal' });

    modalStub.vm.$emit('mail-added');
    expect(wrapper.emitted()).toHaveProperty('mail-added');
  });
});


I would like to draw your attention to how the search for elements occurs. You should try to search the way the user searches. A less valid, but more popular option is via data-testId=”foo”.

Problem with unit tests

As a result, we get a contract between the components, and everything seems to be fine, but the following problem arises:

If we change the code of the Modal window, for example, change the emitters, then only the test of the modal window will break, and the test of the AddIncomingMail component will remain green.

This is where tests of greater integration come to the rescue (as I wrote above: all tests on the front are integration tests to one degree or another), at the page level or a larger piece of the application. In our case, this would be a list of documents and our AddIncomingMail component, and in this test, we need to check the contracts between the components.

I haven't gotten around to implementing such tests yet. In theory, everything seems clear, but it hasn't been implemented yet, since I need to think about the infrastructure – in particular, mocks of server requests, through a conditional msw. However, there is a problem with keeping the mocked answers (fixtures) up-to-date. And I am still thinking about it. I would like to use Vitest browser mode or playwright ct, but these tools are in a very raw state.

Bottom line: start testing your code and don't separate writing tests from development. Yes, it will be very hard at first, then hard, but then you'll just get used to it.

Useful materials

  • Principles of Unit Testing | Vladimir Khorikov – a good book, examples in C#, but by and large the principles do not depend on the language.

  • Channel Lachlan Miller – the developer himself is actively involved in the development of vue test utils, his channel has playlists on testing.

  • Documentation Vue test utils

  • There could have been an advertisement for my telegram here, but there isn't one.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *