← Курс/Тестирование Vue компонентов#243 из 257+25 XP

Тестирование Vue компонентов

Инструменты тестирования

**Vue Test Utils (VTU)** — официальная библиотека для тестирования Vue компонентов. Используется совместно с тест-раннером:

  • **Vitest** — рекомендуется (создан командой Vite, быстрее Jest)
  • **Jest** — популярная альтернатива
  • // vite.config.js
    import { defineConfig } from 'vite'
    export default defineConfig({
      test: {
        environment: 'jsdom',  // симуляция браузера
        globals: true,
      }
    })

    mount vs shallowMount

    import { mount, shallowMount } from '@vue/test-utils'
    import MyComponent from './MyComponent.vue'
    
    // mount — полный рендер с дочерними компонентами
    const wrapper = mount(MyComponent, {
      props: { title: 'Привет', count: 5 },
    })
    
    // shallowMount — дочерние компоненты заменяются заглушками
    // Быстрее, изолирует тестируемый компонент
    const wrapper = shallowMount(MyComponent, {
      props: { title: 'Привет' },
    })

    Тестирование props

    import { describe, it, expect } from 'vitest'
    import { mount } from '@vue/test-utils'
    
    describe('UserCard', () => {
      it('отображает имя пользователя', () => {
        const wrapper = mount(UserCard, {
          props: { name: 'Иван', age: 25 },
        })
        expect(wrapper.text()).toContain('Иван')
        expect(wrapper.find('.age').text()).toBe('25')
      })
    
      it('скрывает кнопку если disabled=true', () => {
        const wrapper = mount(UserCard, {
          props: { name: 'Иван', disabled: true },
        })
        expect(wrapper.find('button').exists()).toBe(false)
      })
    })

    Тестирование событий (emits)

    it('эмитирует submit с данными формы', async () => {
      const wrapper = mount(LoginForm)
    
      await wrapper.find('[data-test="email"]').setValue('user@example.com')
      await wrapper.find('[data-test="password"]').setValue('secret')
      await wrapper.find('form').trigger('submit')
    
      const emitted = wrapper.emitted('submit')
      expect(emitted).toHaveLength(1)
      expect(emitted[0][0]).toEqual({
        email: 'user@example.com',
        password: 'secret',
      })
    })

    Мокирование composables

    // Мокируем useAuth внутри компонента
    vi.mock('@/composables/useAuth', () => ({
      useAuth: () => ({
        user: ref({ name: 'Тестовый пользователь' }),
        isAuthenticated: ref(true),
        logout: vi.fn(),
      }),
    }))
    
    it('показывает имя залогиненного пользователя', () => {
      const wrapper = mount(Header)
      expect(wrapper.text()).toContain('Тестовый пользователь')
    })

    Тестирование асинхронного кода

    it('загружает и отображает данные', async () => {
      // Мокируем fetch
      vi.spyOn(global, 'fetch').mockResolvedValue({
        json: async () => [{ id: 1, name: 'Vue' }],
      })
    
      const wrapper = mount(DataList)
    
      // Ждём завершения асинхронных операций
      await flushPromises()
    
      expect(wrapper.find('.item').exists()).toBe(true)
      expect(wrapper.text()).toContain('Vue')
    })

    Советы по тестированию

    Что тестировать:

  • Поведение при разных props
  • События (emits) при взаимодействии
  • Условный рендер (v-if)
  • Изменения состояния после действий пользователя
  • Что не тестировать:

  • Детали реализации (внутренние переменные)
  • CSS-стили
  • Сторонние библиотеки
  • **Атрибут data-test** — лучше использовать для поиска элементов вместо классов/id:

    <button data-test="submit-btn">Отправить</button>
    wrapper.find('[data-test="submit-btn"]')

    Примеры

    Мини-фреймворк тестирования компонентов — упрощённый аналог Vue Test Utils

    // Реализуем упрощённый тест-фреймворк для Vue-подобных компонентов.
    // Это поможет понять, что делает Vue Test Utils под капотом.
    
    // --- Упрощённый "компонент" ---
    function defineComponent(options) {
      return {
        _options: options,
        render(props = {}) {
          return options.setup(props)
        }
      }
    }
    
    // --- Упрощённый mount / wrapper ---
    function mount(component, { props = {} } = {}) {
      const emitted = {}
      const emit = (event, ...args) => {
        if (!emitted[event]) emitted[event] = []
        emitted[event].push(args)
      }
    
      // Вызываем setup компонента
      const instance = component.render({ ...props, emit })
    
      // Виртуальное "дерево" компонента
      const tree = instance
    
      return {
        // Получить текстовое содержимое
        text() {
          function extractText(node) {
            if (!node) return ''
            if (typeof node === 'string' || typeof node === 'number') return String(node)
            if (Array.isArray(node)) return node.map(extractText).join(' ')
            if (node.children) return extractText(node.children)
            return ''
          }
          return extractText(tree).trim()
        },
    
        // Найти элемент (упрощённо — по tag или .class)
        find(selector) {
          function search(node) {
            if (!node) return null
            if (Array.isArray(node)) {
              for (const child of node) {
                const found = search(child)
                if (found) return found
              }
              return null
            }
            if (typeof node !== 'object') return null
    
            const { tag, props: p = {}, children } = node
            const classes = (p.class || '').split(' ')
            const matchTag = selector === tag
            const matchClass = selector.startsWith('.') && classes.includes(selector.slice(1))
            const matchAttr = selector.startsWith('[') && selector.endsWith(']') &&
              p[selector.slice(1, -1)] !== undefined
    
            if (matchTag || matchClass || matchAttr) {
              return {
                node,
                exists() { return true },
                text() {
                  if (typeof children === 'string') return children
                  if (Array.isArray(children)) return children.filter(c => typeof c === 'string').join('')
                  return ''
                },
                trigger(event) {
                  const handler = p[`on${event[0].toUpperCase() + event.slice(1)}`]
                  if (handler) handler()
                }
              }
            }
    
            return search(children)
          }
    
          const found = search(tree)
          return found || {
            exists() { return false },
            text() { return '' },
            trigger() {}
          }
        },
    
        // Получить эмитированные события
        emitted(event) {
          return emitted[event] || null
        },
    
        // Получить props
        props() { return props },
      }
    }
    
    // --- "Компоненты" для тестирования ---
    const UserCard = defineComponent({
      setup({ name, age, disabled, emit }) {
        return {
          tag: 'div',
          props: { class: 'user-card' },
          children: [
            { tag: 'h2', props: { class: 'name' }, children: name },
            { tag: 'span', props: { class: 'age' }, children: String(age) },
            ...(disabled ? [] : [{
              tag: 'button',
              props: { onClick: () => emit('action', { name }) },
              children: 'Действие',
            }]),
          ]
        }
      }
    })
    
    // --- Тесты ---
    function describe(label, fn) {
      console.log(`\n📋 ${label}`)
      fn()
    }
    
    function it(label, fn) {
      try {
        fn()
        console.log(`  ✅ ${label}`)
      } catch(e) {
        console.log(`  ❌ ${label}: ${e.message}`)
      }
    }
    
    function expect(actual) {
      return {
        toBe(expected) {
          if (actual !== expected)
            throw new Error(`Ожидалось ${JSON.stringify(expected)}, получено ${JSON.stringify(actual)}`)
        },
        toContain(expected) {
          if (!String(actual).includes(String(expected)))
            throw new Error(`"${actual}" не содержит "${expected}"`)
        },
        toHaveLength(n) {
          if (actual.length !== n)
            throw new Error(`Ожидалась длина ${n}, получено ${actual.length}`)
        },
        toBeNull() {
          if (actual !== null) throw new Error(`Ожидалось null, получено ${JSON.stringify(actual)}`)
        },
        not: {
          toBeNull() {
            if (actual === null) throw new Error('Ожидалось не null')
          },
          toBe(expected) {
            if (actual === expected)
              throw new Error(`Ожидалось НЕ ${JSON.stringify(expected)}`)
          },
        }
      }
    }
    
    describe('UserCard', () => {
      it('отображает имя', () => {
        const wrapper = mount(UserCard, { props: { name: 'Иван', age: 25 } })
        expect(wrapper.text()).toContain('Иван')
      })
    
      it('отображает возраст', () => {
        const wrapper = mount(UserCard, { props: { name: 'Иван', age: 30 } })
        expect(wrapper.find('.age').text()).toBe('30')
      })
    
      it('показывает кнопку когда не disabled', () => {
        const wrapper = mount(UserCard, { props: { name: 'Иван', age: 25, disabled: false } })
        expect(wrapper.find('button').exists()).toBe(true)
      })
    
      it('скрывает кнопку при disabled=true', () => {
        const wrapper = mount(UserCard, { props: { name: 'Иван', age: 25, disabled: true } })
        expect(wrapper.find('button').exists()).toBe(false)
      })
    
      it('эмитирует action при клике', () => {
        const wrapper = mount(UserCard, { props: { name: 'Пётр', age: 20 } })
        wrapper.find('button').trigger('click')
        const emitted = wrapper.emitted('action')
        expect(emitted).not.toBeNull()
        expect(emitted).toHaveLength(1)
      })
    })