Кастомные директивы во Vue используются для инкапсуляции низкоуровневой логики работы с DOM: управления фокусом, отслеживания кликов вне элемента, интеграции с нативными API браузера, сторонними библиотеками и нестандартного поведения элементов. В отличие от компонентов, директивы не имеют шаблона и реактивного состояния, а их жизненный цикл тесно связан с жизненным циклом DOM-узлов.
Тестирование директив отличается от тестирования компонентов по нескольким причинам:
beforeMount,
mounted, updated, unmounted.Vue Test Utils предоставляет достаточный набор инструментов для проверки такого поведения, но требует аккуратного подхода к организации тестов.
Во Vue 3 директива представляет собой объект с хуками:
const myDirective = {
beforeMount(el, binding, vnode) {},
mounted(el, binding, vnode) {},
updated(el, binding, vnode) {},
unmounted(el) {}
}
Каждый из этих хуков может и должен тестироваться отдельно, если в нем есть логика. Основные точки контроля:
mounted — проверка первичного эффекта
директивы;updated — реакция на изменение
значения или аргументов;unmounted — корректная очистка
ресурсов.Директивы тестируются через компонент-обертку. Напрямую вызывать хук директивы нецелесообразно, так как теряется контекст Vue.
Минимальный шаблон для тестирования:
import { mount } from '@vue/test-utils'
import myDirective from '@/directives/myDirective'
const TestComponent = {
template: `<div v-my-directive="value"></div>`,
directives: {
myDirective
},
props: ['value']
}
Далее тесты работают исключительно с результатом воздействия директивы на DOM или побочные эффекты.
Наиболее распространённый сценарий — директива изменяет DOM-элемент.
const focusDirective = {
mounted(el) {
el.focus()
}
}
Тест:
it('устанавливает фокус на элемент при монтировании', async () => {
const wrapper = mount({
template: `<input v-focus />`,
directives: {
focus: focusDirective
}
})
const input = wrapper.find('input')
expect(document.activeElement).toBe(input.element)
})
Ключевые моменты:
focus, а результат — активный
элемент;Директивы часто принимают значения, аргументы и модификаторы:
<div v-tooltip:top.bold="message"></div>
В объекте binding доступны:
binding.valuebinding.oldValuebinding.argbinding.modifiersconst colorDirective = {
mounted(el, binding) {
el.style.color = binding.value
},
updated(el, binding) {
el.style.color = binding.value
}
}
Тестирование обновления значения:
it('обновляет стиль при изменении значения', async () => {
const wrapper = mount(TestComponent, {
props: {
value: 'red'
}
})
const el = wrapper.find('div')
expect(el.element.style.color).toBe('red')
await wrapper.setProps({ value: 'blue' })
expect(el.element.style.color).toBe('blue')
})
Здесь косвенно тестируется хук updated.
Многие директивы взаимодействуют с глобальными объектами:
windowdocumentaddEventListener)const clickOutside = {
mounted(el, binding) {
el.__handler__ = event => {
if (!el.contains(event.target)) {
binding.value(event)
}
}
document.addEventListener('click', el.__handler__)
},
unmounted(el) {
document.removeEventListener('click', el.__handler__)
}
}
Тест:
it('вызывает колбэк при клике вне элемента', async () => {
const handler = vi.fn()
const wrapper = mount({
template: `
<div>
<div id="inside" v-click-outside="handler"></div>
<div id="outside"></div>
</div>
`,
directives: {
'click-outside': clickOutside
},
setup() {
return { handler }
}
})
await wrapper.find('#outside').trigger('click')
expect(handler).toHaveBeenCalled()
})
it('удаляет обработчик при размонтировании', () => {
const addSpy = vi.spyOn(document, 'addEventListener')
const removeSpy = vi.spyOn(document, 'removeEventListener')
const wrapper = mount(TestComponent)
wrapper.unmount()
expect(addSpy).toHaveBeenCalled()
expect(removeSpy).toHaveBeenCalled()
})
Если директива использует setTimeout,
requestAnimationFrame или промисы, применяются fake
timers.
vi.useFakeTimers()
it('выполняет действие с задержкой', () => {
const delayedDirective = {
mounted(el) {
setTimeout(() => {
el.textContent = 'done'
}, 1000)
}
}
const wrapper = mount({
template: `<div v-delay></div>`,
directives: {
delay: delayedDirective
}
})
vi.advanceTimersByTime(1000)
expect(wrapper.text()).toBe('done')
})
При тестировании компонентов, использующих директивы, нередко требуется заглушить директиву, чтобы не тестировать ее логику повторно.
const noopDirective = {}
mount(MyComponent, {
global: {
directives: {
tooltip: noopDirective
}
}
})
Такой подход позволяет:
Если директива использует vnode (например, для доступа к
компоненту):
mounted(el, binding, vnode) {
vnode.props.onClick()
}
Тестирование осуществляется через проверку вызываемых эффектов, а не
vnode напрямую:
it('взаимодействует с компонентом через vnode', async () => {
const onCl ick = vi.fn()
mount({
template: `<button v-test @click="onClick" />`,
directives: { test: directive },
setup() {
return { onClick }
}
})
expect(onClick).toHaveBeenCalled()
})
focus, а
не на активный элемент).unmounted.Рекомендуемая структура:
directives/
focus.js
clickOutside.js
tests/
directives/
focus.spec.js
clickOutside.spec.js
Каждый файл содержит:
mounted;global.directivesVue Test Utils позволяет регистрировать директивы глобально:
mount(Component, {
global: {
directives: {
focus: focusDirective
}
}
})
Это полезно, если директива используется глубоко в дереве компонентов.
Такой подход делает тесты устойчивыми, читаемыми и независимыми от внутренней реализации Vue.