В жизни каждого фронтенд-разработчика наступает момент, когда пора войти в нужную дверь когда приходит осознание того, что было бы неплохо как то подтвердить, что твой код работает. У меня этот момент настал после болезненной миграции с Vue 2 на Vue 3 с тоннами дефектов, которая завершилась очередной прядью седых волос, и не только у меня. Хватит это терпеть (с).
Меня зовут Дмитрий, я Frontend разработчик в компании fuse8, и в этой статье мы рассмотрим как можно начать тестировать Vue компоненты.
Мой проект и стек технологий
Текущий проект на котором я тружусь - это классическая CRM, где формочки, формочки, посыпанные формочками, вывод списков, модалочки, и перманентное преобразование данных в этих формочках. Стек технологий включает Vue 3, Pinia, Vite и ElementPlus, из-за чего получается достаточно быстро и гибко разрабатывать интерфейсы.
Почему именно юнит-тесты?
Почитав теорию про пирамиды и кубки тестирования, я пришел к выводу, что проще влиться в процесс, постепенно добавляя “юнит”-тесты для новых компонентов и фич, а также покрывать тестами найденные баги.
На всякий случай – пару слов о пирамиде тестирования. Это концепция, предложенная Мартином Фаулером, которая помогает организовать тесты в проекте.
Скрытый текст
Юнит-тесты (Unit Tests): самый базовый уровень тестирования, который проверяет отдельные модули или функции приложения. Они быстры и легко настраиваются. Их цель — проверить, что отдельные компоненты работают правильно в изоляции.
Интеграционные тесты (Integration Tests): тестируют взаимодействие между модулями или компонентами, например, как компоненты на странице взаимодействуют между собой. Они более сложные и медленные, но дают понимание о корректности интеграций.
E2E-тесты (End-to-End Tests): проверяют весь процесс работы приложения от начала до конца, имитируя действия пользователя. Такие тесты могут быть медленными и требовать сложной инфраструктуры, но они позволяют убедиться, что приложение работает как единое целое.
Слово “Unit” в кавычках, потому что для тестирования компонентов, мы не можем мокнуть наш фреймворк (в теории можем, но зачем?)), а значит, это все таки интеграционные тесты, но для простоты, в сообществе это тоже называется 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, и хотелось поменьше зависимостей.
Для начала работы необходимо установить и настроить Vitest и Vue test utils.
npm i -D vitest @vue/test-utils
Так как наши тесты будут запускать в node окружении, нужны имплементаторы DOM. Vitest рекомендует либо happy-dom, либо jsdom.
Я остановился на jsdom как более популярном, и он быстрее чем happy-dom, но переключиться можно быстро, так что на ваш выбор.
npm i jsdom -D
Настройка окружения для тестов
Добавим параметры в наш vite.config.js.
defineConfig({
...
test: {
environment: 'jsdom',
deps: {
inline: ['element-plus'], // необязательное поле
},
},
})
Как я говорил, в стеке есть Pinia. Для тестирования можно использовать настоящий стор, а можно тестовый. Я выбрал тестовый, так как в этом случае можно мутировать стор напрямую, а это иногда полезно.
npm i -D @pinia/testing
Что и как тестировать?
Для примера, возьмем компонент, в котором есть кнопка и компонент модального окна.
Для контекста, схема компонента представлена на рисунке.
Есть кнопка (активна, если у пользователя есть права на ее использование), по нажатию на которую открывается форма в модальном окне (отдельный компонент). При заполнении полей формы и нажатии на кнопку “Добавить”, идет запрос на сервер, и после удачного ответа генерируется событие, что документ добавлен.
Код компонента:
<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>
Чуток теории. Для начала определим, что нужно тестировать. В литературе рекомендуется максимально следовать поведению пользователя и пропускать детали реализации, что логично. Предполагается, что мы будем тестировать свой компонент как черный ящик, меняя (подготавливая) входные данные компонента и тестируя выходные.
Примечание: если у вас в компоненте, какая-то мега функция со сложными расчетами, очевидно, её надо вынести из компонента и тестировать отдельно классическим unit тестом.
Моки и заглушки отвечают за изоляцию тестов. Моки заменяют реальные зависимости компонента, позволяя контролировать их поведение. Заглушки упрощают взаимодействие с внешними системами (API, например), заменяя их упрощенными версиями. Здесь главное не стремиться замокать все подряд: мокаем только те части кода, которые действительно влияют на тестируемую функциональность, чтобы избежать излишней зависимости тестов от мока.
К входным данным относятся свойства, компоненты, инъекции зависимостей, внешнее хранилище и слоты (может что-то забыл). Чтобы тест был честный мы должны компоненты, хранилище, 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: '<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');
});
});
Хотел бы обратить ваше внимание на то, как происходит поиск элементов. Нужно стараться искать так, как ищет пользователь. Менее валидный, но более популярный вариант – через data-testId=”foo”.
Проблема с unit тестами
В итоге, мы получаем контракт между компонентами, и вроде все хорошо, однако возникает проблема следующего характера:
Если мы поменяем код Модального окна, например поменяет эмиты, то поломается только тест модального окна, а тест компонента AddIncomingMail останется зеленым.
Тут на помощь приходят тесты большей интеграции (как я писал выше: все тесты на фронте – интеграционные в той или иной мере), на уровне страницы или большего куска приложения. В нашем случае это бы был список документов и нашего компонента AddIncomingMail, и в этом тесте нужно проверять контракты между компонентами.
До реализации таких тестов я еще не дошел. В теории вроде все понятно, но пока не реализовано, так как надо подумать над инфраструктурой – в частности моками серверных запросов, через условный msw. Однако там есть проблема с сохранением актуальности замоканных ответов (фикстур). И над этим я еще думаю. Хотелось бы использовать Vitest browser mode или playwright ct, но эти инструменты в очень сыром состоянии.
Итог: начинайте тестировать свой код и не отделяйте написание тестов от разработки. Да, поначалу будет очень тяжело, затем тяжело, ну а потом, вы просто привыкните.
Полезные материалы
Принципы юнит-тестирования | Хориков Владимир – хорошая книжка, примеры на C#, но по большому счету принципы не зависят от языка.
Канал Lachlan Miller – сам разработчик активно участвует в развитии vue test utils, на его канале есть плейлисты по тестированию.
Документация Vue test utils
Здесь могла быть реклама моего телеграмма, но его нет.