Проверка побочных эффектов директив

Директивы во Vue предназначены для прямого воздействия на DOM и окружающую среду компонента. В отличие от компонентов, они не управляют шаблоном целиком, а внедряются в уже существующую разметку, что почти всегда приводит к побочным эффектам: добавлению обработчиков событий, изменению стилей, работе с таймерами, наблюдателями, внешними API браузера.

Побочный эффект в контексте директивы — любое действие, выходящее за рамки чистого преобразования входных данных в выходные. Проверка таких эффектов требует тестирования не только результата, но и самого факта взаимодействия с внешней средой.


Жизненный цикл директив как основа тестирования

Во Vue 3 пользовательская директива описывается набором хуков:

  • created
  • beforeMount
  • mounted
  • beforeUpdate
  • updated
  • beforeUnmount
  • unmounted

Побочные эффекты чаще всего возникают в mounted и должны быть корректно устранены в unmounted. Тестирование строится вокруг проверки этих двух фаз.

const focusDirective = {
  mounted(el) {
    el.focus()
  }
}

В данном примере побочный эффект — вызов метода focus у DOM-элемента.


Регистрация директивы в тестовой среде

Vue Test Utils предоставляет механизм глобальной регистрации директив через опцию global.directives.

import { mount } from '@vue/test-utils'

const wrapper = mount(Component, {
  global: {
    directives: {
      focus: focusDirective
    }
  }
})

Это позволяет тестировать компонент в условиях, максимально близких к реальному приложению, не модифицируя сам компонент.


Проверка прямого воздействия на DOM

Наиболее простой тип побочного эффекта — изменение состояния DOM-элемента.

Пример: изменение стилей

const colorDirective = {
  mounted(el, binding) {
    el.style.color = binding.value
  }
}

Тест проверяет конечное состояние DOM:

const wrapper = mount({
  template: `<p v-color="'red'">text</p>`
}, {
  global: {
    directives: {
      color: colorDirective
    }
  }
})

expect(wrapper.find('p').element.style.color).toBe('red')

Ключевой момент — проверка результата, а не внутренней реализации директивы.


Проверка вызовов методов и функций

Если директива вызывает методы DOM API или сторонние функции, используется подмена (spy).

Пример: фокусировка элемента

const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus')

mount({
  template: `<input v-focus />`
}, {
  global: {
    directives: {
      focus: focusDirective
    }
  }
})

expect(focusSpy).toHaveBeenCalled()

Такой подход позволяет зафиксировать сам факт побочного эффекта, не полагаясь на особенности среды выполнения тестов.


Работа с обработчиками событий

Директивы часто добавляют слушатели событий, что требует проверки двух аспектов:

  • обработчик был зарегистрирован;
  • обработчик был удалён при размонтировании.

Пример директивы

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__)
  }
}

Проверка регистрации

const addSpy = vi.spyOn(document, 'addEventListener')

mount({
  template: `<div v-click-outside="onClick"></div>`,
  methods: {
    onClick() {}
  }
}, {
  global: {
    directives: {
      clickOutside
    }
  }
})

expect(addSpy).toHaveBeenCalledWith('click', expect.any(Function))

Проверка очистки

const removeSpy = vi.spyOn(document, 'removeEventListener')

const wrapper = mount(...)
wrapper.unmount()

expect(removeSpy).toHaveBeenCalledWith('click', expect.any(Function))

Здесь проверяется корректное управление жизненным циклом побочного эффекта.


Тестирование директив с таймерами

Если директива использует setTimeout или setInterval, необходимо управлять временем выполнения.

vi.useFakeTimers()

const delayDirective = {
  mounted(el) {
    setTimeout(() => {
      el.textContent = 'done'
    }, 1000)
  }
}

Тест:

const wrapper = mount({
  template: `<div v-delay></div>`
}, {
  global: {
    directives: {
      delay: delayDirective
    }
  }
})

vi.advanceTimersByTime(1000)
expect(wrapper.text()).toBe('done')

Использование поддельных таймеров позволяет изолировать тест от реального времени и сделать его детерминированным.


Побочные эффекты, связанные с внешними API

Директивы могут работать с IntersectionObserver, ResizeObserver, window, localStorage.

Для таких случаев применяется подмена глобальных объектов.

const observeMock = vi.fn()

window.IntersectionObserver = vi.fn(() => ({
  observe: observeMock,
  disconnect: vi.fn()
}))

После этого проверяется факт вызова observe или disconnect в соответствующих хуках директивы.


Проверка реакции на обновление привязок

Если директива реагирует на изменение значения, используется хук updated.

const textDirective = {
  updated(el, binding) {
    el.textContent = binding.value
  }
}

Тест:

const wrapper = mount({
  template: `<div v-text="msg"></div>`,
  data() {
    return { msg: 'one' }
  }
}, {
  global: {
    directives: {
      text: textDirective
    }
  }
})

expect(wrapper.text()).toBe('one')

wrapper.vm.msg = 'two'
await wrapper.vm.$nextTick()

expect(wrapper.text()).toBe('two')

Здесь побочный эффект — повторное изменение DOM при обновлении данных.


Изоляция директив в тестах компонентов

При тестировании компонентов директивы могут быть источником нежелательных побочных эффектов. Vue Test Utils позволяет заменять реальные директивы заглушками.

const stubDirective = {
  mounted() {}
}

Это снижает связанность тестов и позволяет проверять логику компонента независимо от поведения директивы.


Типичные ошибки при тестировании побочных эффектов

  • Проверка внутренней реализации вместо observable-результата.
  • Отсутствие проверки очистки ресурсов.
  • Использование реальных таймеров и глобальных API.
  • Совмещение тестирования директивы и компонента в одном сценарии без изоляции.

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