Тестирование Vue 3
Руководство по unit-тестированию расширений, использующих Vue 3 (ui.vue3). Chef запускает тесты в реальном браузере — компоненты монтируются в настоящий DOM и работают с реальным Vue из расширения ui.vue3.
Установка
npm install --save-dev @vue/test-utilsVue Test Utils — официальная библиотека для unit-тестирования Vue-компонентов. Chef автоматически бандлит её в тестовый код, а vue берётся из загруженного расширения ui.vue3.
Структура тестов
local/js/vendor/my-chat/
├── src/
│ ├── my-chat.ts
│ └── components/
│ ├── message-list.ts
│ ├── message-item.ts
│ └── chat-input.ts
└── test/
└── unit/
├── message-list.test.ts
├── message-item.test.ts
└── chat-input.test.tsМонтирование компонентов
mount
mount создаёт полноценный экземпляр компонента со всеми дочерними компонентами:
import { describe, it, afterEach } from 'mocha';
import { assert } from 'chai';
import { mount } from '@vue/test-utils';
import { MessageItem } from '../../src/components/message-item';
describe('MessageItem', () => {
let wrapper;
afterEach(() => {
wrapper?.unmount();
});
it('should render message text', () => {
wrapper = mount(MessageItem, {
props: {
author: 'John',
text: 'Hello, world!',
timestamp: Date.now(),
},
});
assert.include(wrapper.text(), 'Hello, world!');
assert.include(wrapper.text(), 'John');
});
});shallowMount
shallowMount рендерит только корневой компонент, заменяя дочерние на заглушки. Полезно для изоляции тестируемого компонента:
import { shallowMount } from '@vue/test-utils';
import { MessageList } from '../../src/components/message-list';
it('should render correct number of messages', () => {
const wrapper = shallowMount(MessageList, {
props: {
messages: [
{ id: 1, text: 'Hello' },
{ id: 2, text: 'World' },
],
},
});
// Дочерние MessageItem заменены заглушками
assert.equal(wrapper.findAllComponents({ name: 'MessageItem' }).length, 2);
wrapper.unmount();
});Когда что использовать
mount— когда нужно проверить взаимодействие дочерних компонентов, рендеринг DOMshallowMount— когда нужно изолировать логику одного компонента
Тестирование событий
Пользовательские события (emit)
import { mount } from '@vue/test-utils';
import { ChatInput } from '../../src/components/chat-input';
describe('ChatInput', () => {
it('should emit send event on Enter', async () => {
const wrapper = mount(ChatInput);
const input = wrapper.find('textarea');
await input.setValue('Hello!');
await input.trigger('keydown.enter');
const emitted = wrapper.emitted('send');
assert.isArray(emitted);
assert.equal(emitted.length, 1);
assert.equal(emitted[0][0], 'Hello!');
wrapper.unmount();
});
it('should not emit send on empty input', async () => {
const wrapper = mount(ChatInput);
await wrapper.find('textarea').trigger('keydown.enter');
assert.isUndefined(wrapper.emitted('send'));
wrapper.unmount();
});
});Нативные DOM-события
it('should handle click on delete button', async () => {
const wrapper = mount(MessageItem, {
props: { id: 1, text: 'Hello', author: 'John', timestamp: Date.now() },
});
await wrapper.find('.message-item__delete').trigger('click');
const emitted = wrapper.emitted('delete');
assert.deepEqual(emitted[0], [1]);
wrapper.unmount();
});Тестирование пропсов и реактивности
Начальные пропсы
it('should render with initial props', () => {
const wrapper = mount(UserBadge, {
props: {
name: 'Anna',
role: 'admin',
online: true,
},
});
assert.include(wrapper.text(), 'Anna');
assert.isTrue(wrapper.find('.badge--online').exists());
wrapper.unmount();
});Изменение пропсов
it('should update on prop change', async () => {
const wrapper = mount(Counter, {
props: { value: 0 },
});
assert.include(wrapper.text(), '0');
await wrapper.setProps({ value: 5 });
assert.include(wrapper.text(), '5');
await wrapper.setProps({ value: -1 });
assert.include(wrapper.text(), '-1');
wrapper.unmount();
});Проверка computed-свойств
it('should compute full name', () => {
const wrapper = mount(UserProfile, {
props: { firstName: 'John', lastName: 'Doe' },
});
assert.include(wrapper.text(), 'John Doe');
wrapper.unmount();
});Тестирование слотов
Default slot
it('should render default slot', () => {
const wrapper = mount(Panel, {
slots: {
default: '<p>Panel content</p>',
},
});
assert.include(wrapper.html(), 'Panel content');
wrapper.unmount();
});Именованные слоты
it('should render named slots', () => {
const wrapper = mount(Dialog, {
slots: {
header: '<h2>Confirm</h2>',
default: '<p>Are you sure?</p>',
footer: '<button>OK</button><button>Cancel</button>',
},
});
assert.include(wrapper.find('.dialog-header').html(), 'Confirm');
assert.include(wrapper.find('.dialog-body').html(), 'Are you sure?');
assert.equal(wrapper.findAll('.dialog-footer button').length, 2);
wrapper.unmount();
});Scoped slots
it('should pass data to scoped slot', () => {
const wrapper = mount(ItemList, {
props: {
items: [{ id: 1, name: 'First' }, { id: 2, name: 'Second' }],
},
slots: {
item: `<template #item="{ item }">
<span class="custom-item">{{ item.name }}</span>
</template>`,
},
});
const items = wrapper.findAll('.custom-item');
assert.equal(items.length, 2);
assert.equal(items[0].text(), 'First');
wrapper.unmount();
});Тестирование с Vuex Store
Создание тестового store
import { describe, it, beforeEach, afterEach } from 'mocha';
import { assert } from 'chai';
import { mount } from '@vue/test-utils';
import { createStore } from 'ui.vue3.vuex';
import { ChatList } from '../../src/components/chat-list';
describe('ChatList', () => {
let store;
let wrapper;
beforeEach(() => {
store = createStore({
state: () => ({
chats: [
{ id: 1, title: 'General', counter: 3 },
{ id: 2, title: 'Support', counter: 0 },
{ id: 3, title: 'Dev', counter: 12 },
],
}),
getters: {
unreadChats: (state) => state.chats.filter((c) => c.counter > 0),
totalUnread: (state) => state.chats.reduce((sum, c) => sum + c.counter, 0),
},
mutations: {
resetCounter(state, chatId) {
const chat = state.chats.find((c) => c.id === chatId);
if (chat) chat.counter = 0;
},
},
});
});
afterEach(() => {
wrapper?.unmount();
});
it('should render all chats', () => {
wrapper = mount(ChatList, {
global: { plugins: [store] },
});
assert.equal(wrapper.findAll('.chat-item').length, 3);
});
it('should highlight unread chats', () => {
wrapper = mount(ChatList, {
global: { plugins: [store] },
});
const unread = wrapper.findAll('.chat-item--unread');
assert.equal(unread.length, 2);
});
it('should update after store mutation', async () => {
wrapper = mount(ChatList, {
global: { plugins: [store] },
});
store.commit('resetCounter', 1);
await wrapper.vm.$nextTick();
const unread = wrapper.findAll('.chat-item--unread');
assert.equal(unread.length, 1);
});
});Тестирование store отдельно
Store можно тестировать без монтирования компонентов — это быстрее и надёжнее:
import { createStore } from 'ui.vue3.vuex';
import { chatModule } from '../../src/store/chat';
describe('chat store', () => {
let store;
beforeEach(() => {
store = createStore({
modules: { chat: chatModule },
});
});
it('should add message', async () => {
await store.dispatch('chat/addMessage', {
chatId: 1,
text: 'Hello',
author: 'John',
});
const messages = store.getters['chat/getMessages'](1);
assert.equal(messages.length, 1);
assert.equal(messages[0].text, 'Hello');
});
it('should increment counter on new message', async () => {
await store.dispatch('chat/addMessage', {
chatId: 1,
text: 'Hello',
author: 'John',
});
const counter = store.getters['chat/getCounter'](1);
assert.equal(counter, 1);
});
it('should reset counter on read', async () => {
await store.dispatch('chat/addMessage', { chatId: 1, text: 'Hello', author: 'John' });
await store.dispatch('chat/markAsRead', { chatId: 1 });
const counter = store.getters['chat/getCounter'](1);
assert.equal(counter, 0);
});
});Тестирование асинхронного поведения
flushPromises
flushPromises ожидает завершения всех pending промисов — полезно для тестов с API-запросами:
import { mount, flushPromises } from '@vue/test-utils';
it('should load and display users', async () => {
const wrapper = mount(UserList);
// Компонент загружает данные в mounted()
await flushPromises();
assert.isAbove(wrapper.findAll('.user-item').length, 0);
wrapper.unmount();
});nextTick
Для ожидания реактивного обновления DOM после изменения данных:
it('should toggle visibility', async () => {
const wrapper = mount(Collapsible, {
props: { title: 'Details' },
slots: { default: '<p>Content</p>' },
});
assert.isFalse(wrapper.find('.content').exists());
await wrapper.find('.toggle').trigger('click');
assert.isTrue(wrapper.find('.content').exists());
assert.include(wrapper.find('.content').text(), 'Content');
wrapper.unmount();
});Моки и заглушки
Мок компонентов
Замена тяжёлых дочерних компонентов на заглушки:
it('should render without heavy child', () => {
const wrapper = mount(ChatWindow, {
global: {
stubs: {
HeavyEditor: { template: '<div class="editor-stub"></div>' },
EmojiPicker: true, // заменяется на <emoji-picker-stub>
},
},
props: { chatId: 1 },
});
assert.isTrue(wrapper.find('.editor-stub').exists());
wrapper.unmount();
});Мок provide/inject
it('should use injected service', () => {
const mockService = {
getCurrentUser: () => ({ id: 1, name: 'Test User' }),
};
const wrapper = mount(UserMenu, {
global: {
provide: {
userService: mockService,
},
},
});
assert.include(wrapper.text(), 'Test User');
wrapper.unmount();
});Мок глобальных свойств ($Bitrix, $store)
it('should use $Bitrix.Loc', () => {
const wrapper = mount(Greeting, {
global: {
mocks: {
$Bitrix: {
Loc: {
getMessage: (key) => key === 'HELLO' ? 'Привет' : key,
},
},
},
},
});
assert.include(wrapper.text(), 'Привет');
wrapper.unmount();
});Поиск элементов
По CSS-селектору
wrapper.find('.message-item'); // один элемент
wrapper.findAll('.message-item'); // все элементы
wrapper.find('[data-testid="submit"]'); // по data-атрибутуПо компоненту
import { MessageItem } from '../../src/components/message-item';
wrapper.findComponent(MessageItem); // по ссылке
wrapper.findComponent({ name: 'MessageItem' }); // по имени
wrapper.findAllComponents({ name: 'MessageItem' }); // всеПроверка существования
assert.isTrue(wrapper.find('.error').exists());
assert.isFalse(wrapper.find('.success').exists());Лучшие практики
Всегда размонтируйте компоненты
afterEach(() => {
wrapper?.unmount();
});Это очищает DOM и предотвращает утечки между тестами.
Тестируйте поведение, не реализацию
// Плохо — завязано на внутреннее состояние
assert.equal(wrapper.vm.isOpen, true);
// Хорошо — проверяет что видит пользователь
assert.isTrue(wrapper.find('.dropdown-menu').isVisible());Тестируйте store отдельно от компонентов
Store — это чистая логика. Тестируйте actions, getters, mutations без монтирования компонентов. Это быстрее и стабильнее:
// Store test — быстрый, надёжный
await store.dispatch('chat/sendMessage', { text: 'Hello' });
assert.equal(store.getters['chat/lastMessage'].text, 'Hello');
// Component test — только для проверки рендеринга
const wrapper = mount(ChatWindow, { global: { plugins: [store] } });
assert.include(wrapper.text(), 'Hello');Используйте data-testid для стабильных селекторов
CSS-классы могут меняться при рефакторинге. data-testid явно обозначает элемент для тестов:
// В компоненте
template: '<button data-testid="send-btn" @click="send">Send</button>'
// В тесте
await wrapper.find('[data-testid="send-btn"]').trigger('click');Один тест — одна проверка
// Плохо — непонятно что сломалось при падении
it('should work', async () => {
assert.include(wrapper.text(), 'Title');
await wrapper.find('.btn').trigger('click');
assert.isTrue(wrapper.emitted('submit') !== undefined);
assert.equal(wrapper.findAll('.item').length, 3);
});
// Хорошо — каждый тест проверяет конкретное поведение
it('should render title', () => { /* ... */ });
it('should emit submit on button click', async () => { /* ... */ });
it('should render all items', () => { /* ... */ });