Yandex.Metrika Counter

Начать работать с нами легко! Просто заполните заявку, и мы свяжемся с вами для обсуждения деталей.

Бюджет

Нажимая на кнопку, вы даёте согласие на обработку персональных данных и соглашаетесь с положением о конфиденциальности данных.

В жизни каждого фронтенд-разработчика наступает момент, когда пора войти в нужную дверь когда приходит осознание того, что было бы неплохо как-то подтвердить, что твой код работает.  У меня этот момент настал после болезненной миграции с Vue 2 на Vue 3 с тоннами дефектов, которая завершилась очередной прядью седых волос, и не только у меня. Хватит это терпеть (с).

Меня зовут Дмитрий, я frontend-разработчик в компании fuse8, и в этой статье мы рассмотрим как можно начать тестировать Vue-компоненты.

Мой проект и стек технологий

Текущий проект на котором я тружусь — это классическая CRM, где формочки, формочки, посыпанные формочками, вывод списков, модалочки и перманентное преобразование данных в этих формочках. Стек технологий включает Vue 3, Pinia, Vite и ElementPlus, из-за чего получается достаточно быстро и гибко разрабатывать интерфейсы.

Почему именно юнит-тесты?

Почитав теорию про пирамиды и кубки тестирования, я пришел к выводу, что проще влиться в процесс, постепенно добавляя “юнит”-тесты для новых компонентов и фич, а также покрывать тестами найденные баги. 

На всякий случай – пару слов о пирамиде тестирования. Это концепция, предложенная Мартином Фаулером, которая помогает организовать тесты в проекте. Она предполагает три уровня тестирования:

  • Юнит-тесты (Unit Tests): самый базовый уровень тестирования, который проверяет отдельные модули или функции приложения. Они быстры и легко настраиваются. Их цель — проверить, что отдельные компоненты работают правильно в изоляции.
  • Интеграционные тесты (Integration Tests): тестируют взаимодействие между модулями или компонентами, например, как компоненты на странице взаимодействуют между собой. Они более сложные и медленные, но дают понимание о корректности интеграций.
  • E2E-тесты (End-to-End Tests): проверяют весь процесс работы приложения от начала до конца, имитируя действия пользователя. Такие тесты могут быть медленными и требовать сложной инфраструктуры, но они позволяют убедиться, что приложение работает как единое целое.

Слово «юнит» в кавычках, потому что здесь есть нюанс. Когда речь заходит о тестировании компонентов любого фреймворка или библиотеки, возникает вопрос: что считать unit-тестами? 

В идеале, в unit-тестах тестируемый код должен быть изолирован от всего окружения, быть чистой функцией (вездесущий пример функции sum), но в реальности мы не можем полностью «замокать» фреймворк. Получается, что тесты для Vue-компонентов будут все-таки интеграционными, но в сообществе их продолжают называть unit-тестами. Поэтому и я буду придерживаться этого термина, хотя и с некоторыми оговорками.

Варианты тестирования и мой выбор

Конечно, были и другие варианты. Например, e2e-тестирование с использованием Playwright, которое запускает тесты в реальном браузере, эмулируя действия пользователей. Это мощный инструмент, но на практике я столкнулся с рядом инфраструктурных сложностей, о которых расскажу в конце статьи. 

Спойлер: бекенд к такому оказался не готов, и по уму, e2e надо писать под чутким руководством QA – эти парни и девчонки умеют ломать систему.

В конечном счете, я остановился на Vitest и Vue Test Utils как основных инструментах для тестирования. Vitest, как раннер, идеально подошел, поскольку Vite уже был установлен в проекте, а Vue Test Utils предоставил все необходимые инструменты для монтирования и изменения  Vue-компонентов.

Мой выбор: Vitest и Vue Test Utils

Почему Vue Test Utils, а не Testing Library? Во-первых, это рекомендовано сообществом Vue (вроде Testing Library запаздывала с миграцией на 3й Vue). Во-вторых, я придерживаюсь «Лондонской школы» тестирования (то есть мокаем весь интернет вокруг нашего компонента), чтобы тест был максимально честным. К тому же, Testing Library построена поверх VTU, и хотелось поменьше зависимостей.

Мы должны тестировать компонент без знания его внутренней структуры и реализации (как черный ящик). Этот подход помогает сосредоточиться на входных данных и выходных результатах, обеспечивая независимость тестов от деталей реализации.

Примечание: если у вас в компоненте, какая-то мегафункция со сложными расчетами, очевидно, её надо вынести из компонента и тестировать отдельно классическим unit-тестом.

Моки и заглушки отвечают за изоляцию тестов. Моки заменяют реальные зависимости компонента, позволяя контролировать их поведение. Заглушки упрощают взаимодействие с внешними системами (API, например), заменяя их упрощенными версиями. Здесь главное не стремиться замокать все подряд: мокаем только те части кода, которые действительно влияют на тестируемую функциональность, чтобы избежать излишней зависимости тестов от мока.

Для начала работы необходимо установить и настроить Vitest  и Vue test utils.


npm i -D vitest @vue/test-utils

Так как наши тесты будут запускать в node окружении, нужны имплементаторы DOM. Vitest рекомендует либо happy-dom, либо jsdom. 

Я остановился на jsdom как более популярном, и он быстрее чем happy-dom, но переключиться можно быстро, так что на ваш выбор.


npm i jsdom -D

Как я говорил, в стеке есть Pinia. Для тестирования можно использовать настоящий стор, а можно тестовый. Я выбрал тестовый, так как в этом случае можно мутировать стор напрямую, а это иногда полезно.


npm i -D @pinia/testing

Что и как тестировать?

Для примера тестирования возьмем компонент, в котором есть кнопка и компонент модального окна. 

Для контекста, схема компонента представлена на рисунке.

Есть кнопка (активна, если у пользователя есть права на ее использование), по нажатию на которую открывается форма в модальном окне (отдельный компонент). При заполнении полей формы и нажатии на кнопку «добавить», идет запрос на сервер, и после удачного ответа генерируется событие, что документ добавлен.

Код компонента:

Чуток теории. Для начала определим, что нужно тестировать. В литературе рекомендуется максимально следовать поведению пользователя и пропускать детали реализации, что логично. Предполагается, что мы будем тестировать свой компонент как черный ящик, меняя (подготавливая) входные данные компонента и тестируя выходные.

К входным данным относятся  свойства, компоненты, инъекции зависимостей, внешнее хранилище и слоты (может что-то забыл). Чтобы тест был честный, мы должны компоненты, хранилище, DI заменить заглушками. Так уменьшим вероятность ложного срабатывания теста.

На выходе же мы должны протестировать, получившийся html. Проверить вызов api (или результат вызова API), причем сам вызов должен быть также заменен заглушкой, мы же не хотим травмировать backend. И также, необходимо проверить  события которые будут обрабатываться снаружи компонента.

Давайте теперь напишем, наконец, тест. Определимся, что надо тестировать (как говорится, «рисуем круг»).


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

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

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

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

На что тут обратить внимание? Делая описания и поясняя, что ожидаем получить при выполнении действий, получаем самодокументируемый код… В теории…

Я использую вариант размещения тестов как можно ближе к компонентам. Кажется, что такой вариант лучше подходит для понимания того, как компонент работает.

В общем, рисуем еще круг, добавляем деталей, и получаем сову, то есть тест.


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: 'Добавить корреспонденцию',
        },
        popup: {
          props: { isFormOpen: false },
          template: '',
        },
      },
    },
    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');
  });
});

Хотел бы обратить ваше внимание на то, как происходит поиск элементов. Нужно стараться искать так, как ищет пользователь. Менее валидный, но более популярный вариант – через data-testId=”foo”.

Проблема с unit-тестами

Вроде бы все хорошо. Получаем в итоге контракт между компонентами, и можем выдыхать. Однако возникает проблема следующего характера:

Если мы поменяем код модального окна, например, эмиты, то поломается только тест модального окна, а тест компонента AddIncomingMail останется зеленым.

Тут на помощь приходят тесты большей интеграции (как я писал выше: все тесты на фронте – интеграционные в той или иной мере), на уровне страницы или большего куска приложения. В нашем случае это бы был список документов и нашего компонента AddIncomingMail, и в этом тесте нужно проверять контракты между компонентами. 

До реализации таких тестов я еще не дошел. В теории вроде все понятно, но пока не реализовано, так как надо подумать над инфраструктурой – в частности моками серверных запросов, через условный msw. Однако там есть проблема с сохранением актуальности замоканных ответов (фикстур). И над этим я еще думаю. Хотелось бы использовать Vitest browser mode или playwright ct, но эти инструменты в очень сыром состоянии. 

Итог: начинайте тестировать свой код и не отделяйте написание тестов от разработки. Да, поначалу будет очень тяжело, затем тяжело, ну а потом, вы просто привыкните.

Полезные материалы

  • Принципы юнит-тестирования | Хориков Владимир – хорошая книжка, примеры на C#, но по большому счету принципы не зависят от языка.
  • Канал Lachlan Miller  – сам разработчик активно участвует в развитии vue test utils, на его канале есть плейлисты по тестированию.
  • Документация Vue test utils
  • Здесь могла быть реклама моего телеграмма, но его нет.