Testing Vue 3
A guide to unit testing extensions that use Vue 3 (ui.vue3). Chef runs tests in a real browser — components are mounted into a real DOM and work with the actual Vue from the ui.vue3 extension.
Installation
npm install --save-dev @vue/test-utilsVue Test Utils is the official library for unit testing Vue components. Chef automatically bundles it into the test code, while vue is taken from the loaded ui.vue3 extension.
Test structure
local/js/vendor/my-chat/
├── src/
│ ├── my-chat.ts
│ └── components/
│ ├── message-list.ts
│ ├── message-item.ts
│ └── chat-input.ts
└── tests/
└── unit/
├── message-list.test.ts
├── message-item.test.ts
└── chat-input.test.tsMounting components
mount
mount creates a full component instance with all child components:
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 renders only the root component, replacing children with stubs. Useful for isolating the component under test:
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' },
],
},
});
// Child MessageItem components are replaced with stubs
assert.equal(wrapper.findAllComponents({ name: 'MessageItem' }).length, 2);
wrapper.unmount();
});When to use which
mount— when you need to test child component interactions, DOM renderingshallowMount— when you need to isolate the logic of a single component
Testing events
Custom events (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();
});
});Native DOM events
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();
});Testing props and reactivity
Initial props
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();
});Changing props
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();
});Checking computed properties
it('should compute full name', () => {
const wrapper = mount(UserProfile, {
props: { firstName: 'John', lastName: 'Doe' },
});
assert.include(wrapper.text(), 'John Doe');
wrapper.unmount();
});Testing slots
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();
});Named slots
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();
});Testing with Vuex Store
Creating a test 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);
});
});Testing store separately
Stores can be tested without mounting components — this is faster and more reliable:
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);
});
});Testing async behavior
flushPromises
flushPromises waits for all pending promises to resolve — useful for tests with API calls:
import { mount, flushPromises } from '@vue/test-utils';
it('should load and display users', async () => {
const wrapper = mount(UserList);
// Component loads data in mounted()
await flushPromises();
assert.isAbove(wrapper.findAll('.user-item').length, 0);
wrapper.unmount();
});nextTick
To wait for reactive DOM updates after data changes:
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();
});Mocks and stubs
Component stubs
Replace heavy child components with stubs:
it('should render without heavy child', () => {
const wrapper = mount(ChatWindow, {
global: {
stubs: {
HeavyEditor: { template: '<div class="editor-stub"></div>' },
EmojiPicker: true, // replaced with <emoji-picker-stub>
},
},
props: { chatId: 1 },
});
assert.isTrue(wrapper.find('.editor-stub').exists());
wrapper.unmount();
});Mocking 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();
});Mocking global properties ($Bitrix, $store)
it('should use $Bitrix.Loc', () => {
const wrapper = mount(Greeting, {
global: {
mocks: {
$Bitrix: {
Loc: {
getMessage: (key) => key === 'HELLO' ? 'Hello' : key,
},
},
},
},
});
assert.include(wrapper.text(), 'Hello');
wrapper.unmount();
});Finding elements
By CSS selector
wrapper.find('.message-item'); // single element
wrapper.findAll('.message-item'); // all elements
wrapper.find('[data-testid="submit"]'); // by data attributeBy component
import { MessageItem } from '../../src/components/message-item';
wrapper.findComponent(MessageItem); // by reference
wrapper.findComponent({ name: 'MessageItem' }); // by name
wrapper.findAllComponents({ name: 'MessageItem' }); // allChecking existence
assert.isTrue(wrapper.find('.error').exists());
assert.isFalse(wrapper.find('.success').exists());Best practices
Always unmount components
afterEach(() => {
wrapper?.unmount();
});This cleans up the DOM and prevents leaks between tests.
Test behavior, not implementation
// Bad — tied to internal state
assert.equal(wrapper.vm.isOpen, true);
// Good — checks what the user sees
assert.isTrue(wrapper.find('.dropdown-menu').isVisible());Test store separately from components
Store is pure logic. Test actions, getters, mutations without mounting components. It's faster and more stable:
// Store test — fast, reliable
await store.dispatch('chat/sendMessage', { text: 'Hello' });
assert.equal(store.getters['chat/lastMessage'].text, 'Hello');
// Component test — only for verifying rendering
const wrapper = mount(ChatWindow, { global: { plugins: [store] } });
assert.include(wrapper.text(), 'Hello');Use data-testid for stable selectors
CSS classes can change during refactoring. data-testid explicitly marks elements for tests:
// In the component
template: '<button data-testid="send-btn" @click="send">Send</button>'
// In the test
await wrapper.find('[data-testid="send-btn"]').trigger('click');One test — one assertion
// Bad — unclear what broke on failure
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);
});
// Good — each test checks specific behavior
it('should render title', () => { /* ... */ });
it('should emit submit on button click', async () => { /* ... */ });
it('should render all items', () => { /* ... */ });