Компонентные тесты Vue с Vitest

Создавая компонентные тесты с Cypress, я столкнулся с некоторыми неприятными их особенностями.

Во-первых такие тесты оставались очень медленными. Они не были на столько быстрее E2E как этого бы хотелось. Для каждого компонента необходимо делать много тестов. Но из-за их низкой скорости выполнения приходилось ограничивать себя лишь небольшим числом самых необходимых.

Во-вторых эпизодически появлялась ошибка failed to fetch dynamically imported module в CI-CD. И эта проблема на момент написания статьи все еще не была решена.

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

Содержание

Настройка Vitest

Для первого эксперимента будем использовать новый Vue проект.

npm create vue@latest
┌  Vue.js - The Progressive JavaScript Framework
│
◇  Project name (target directory):
│  example-vue-vitest
│
◇  Use TypeScript?
│  Yes
│
◇  Select features to include in your project: (↑/↓ to navigate, space to select, a to toggle all, enter to confirm)
│  Linter (error prevention), Prettier (code formatting)
│
◇  Select experimental features to include in your project: (↑/↓ to navigate, space to select, a to toggle all, enter to confirm)
│  none
│
◇  Skip all example code and start with a blank Vue project?
│  Yes

Scaffolding project in /home/igor/projects/example-vue-vitest...
│
└  Done. Now run:

   cd example-vue-vitest
   npm install
   npm run format
   npm run dev

| Optional: Initialize Git in your project directory with:

   git init && git add -A && git commit -m "initial commit"


Добавить Vitest в проект можно так:

npm install -D vitest @vue/test-utils happy-dom

Пакет happy-dom — это эмуляция браузера внутри Node.js, которая позволяет запускать фронтенд-тесты (Vue, React и т.д.) без реального браузера. Это один из вариантов исполняющей среды (тестового окружения) для Vitest. Есть еще другой вариант — jsdom. О разных тестовых окружениях будет рассказано ниже.

В конфигурационный файл Vite нужно добавить секцию test:

// vite.config.ts
export default defineConfig({
  plugins: [...],
  resolve: {...},
  test: {
    globals: true, // Глобальные тестовые функции без импорта
    environment: 'happy-dom',
    include: ['src/**/*.vitest.spec.{js,ts}'], // Какие файлы считать тестами
  },
})

В файл package.json, в секцию scripts — следующее:

"test": "vitest",
"test:run": "vitest run"

Команда vitest запускает тесты в режиме отслеживания изменений (режим watch). Команда vitest run выполняет тесты один раз и завершает работу.

Создание тестов Vitest

Компонент и файл теста

Для начала создадим простой компонент AppButton.vue — кнопку с несколькими свойствами:

<script setup lang="ts">
import { computed } from 'vue'

const props = withDefaults(
  defineProps<{
    loading?: boolean
    disabled?: boolean
    type?: 'button' | 'submit'
  }>(),
  {
    loading: false,
    disabled: false,
    type: 'button',
  }
)

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

const isBlocked = computed(() => props.loading || props.disabled)

function handleClick(event: MouseEvent) {
  if (isBlocked.value) {
    return
  }
  emit('click', event)
}
</script>

<template>
  <button
    class="app-button"
    :type="type"
    :disabled="isBlocked"
    :aria-busy="loading ? 'true' : undefined"
    @click="handleClick"
  >
    <span
      v-if="loading"
      class="app-button__spinner"
      aria-hidden="true"
    />
    <span
      class="app-button__content"
      :class="{ 'app-button__content--loading': loading }"
    >
      <slot />
    </span>
  </button>
</template>

И набор тестов используя Vitest:

import { describe, it, expect, afterEach } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import AppButton from './AppButton.vue'

describe('AppButton', () => {
  let wrapper: VueWrapper<InstanceType<typeof AppButton>> | undefined

  afterEach(() => {
    wrapper?.unmount()
  })

  it('эмитит click при клике в обычном режиме', async () => {
    wrapper = mount(AppButton, {
      slots: { default: 'Сохранить' },
    })
    await wrapper.find('button').trigger('click')
    expect(wrapper.emitted('click')).toHaveLength(1)
    expect(wrapper.emitted('click')?.[0]?.[0]).toBeInstanceOf(MouseEvent)
  })

  it('не эмитит click при loading', async () => {
    wrapper = mount(AppButton, {
      props: { loading: true },
      slots: { default: 'Сохранить' },
    })
    expect(wrapper.find('button').element.disabled).toBe(true)
    await wrapper.find('button').trigger('click')
    expect(wrapper.emitted('click')).toBeFalsy()
  })

  it('не эмитит click при disabled', async () => {
    wrapper = mount(AppButton, {
      props: { disabled: true },
      slots: { default: 'Сохранить' },
    })
    expect(wrapper.find('button').element.disabled).toBe(true)
    await wrapper.find('button').trigger('click')
    expect(wrapper.emitted('click')).toBeFalsy()
  })

  it('пробрасывает type="button" на нативную кнопку', () => {
    wrapper = mount(AppButton, {
      props: { type: 'button' },
      slots: { default: 'OK' },
    })
    expect(wrapper.find('button').attributes('type')).toBe('button')
  })

  it('пробрасывает type="submit" на нативную кнопку', () => {
    wrapper = mount(AppButton, {
      props: { type: 'submit' },
      slots: { default: 'OK' },
    })
    expect(wrapper.find('button').attributes('type')).toBe('submit')
  })

  it('выставляет aria-busy при loading', () => {
    wrapper = mount(AppButton, {
      props: { loading: true },
      slots: { default: '…' },
    })
    expect(wrapper.find('button').attributes('aria-busy')).toBe('true')
  })

  it('не задаёт aria-busy без loading', () => {
    wrapper = mount(AppButton, {
      slots: { default: 'OK' },
    })
    expect(wrapper.find('button').attributes('aria-busy')).toBeUndefined()
  })

  it('показывает спиннер при loading', () => {
    wrapper = mount(AppButton, {
      props: { loading: true },
      slots: { default: 'Загрузка' },
    })
    expect(wrapper.find('.app-button__spinner').exists()).toBe(true)
  })

  it('рендерит слот по умолчанию', () => {
    wrapper = mount(AppButton, {
      slots: { default: 'Текст кнопки' },
    })
    expect(wrapper.text()).toContain('Текст кнопки')
  })
})

Запуск теста

Попросим npm выполнить наш тест:

npm run test:run AppButton.vitest.ts

> example-vue-vitest@0.0.0 test:run
> vitest run AppButton.vitest.ts


 RUN  v4.1.0 /home/igor/projects/example-vue-vitest

 ✓ src/components/AppButton.vitest.ts (9 tests) 86ms
   ✓ AppButton (9)
     ✓ эмитит click при клике в обычном режиме 45ms
     ✓ не эмитит click при loading 7ms
     ✓ не эмитит click при disabled 6ms
     ✓ пробрасывает type="button" на нативную кнопку 4ms
     ✓ пробрасывает type="submit" на нативную кнопку 5ms
     ✓ выставляет aria-busy при loading 4ms
     ✓ не задаёт aria-busy без loading 3ms
     ✓ показывает спиннер при loading 4ms
     ✓ рендерит слот по умолчанию 5ms

 Test Files  1 passed (1)
      Tests  9 passed (9)
   Start at  19:44:03
   Duration  1.05s (transform 166ms, setup 0ms, import 339ms, tests 86ms, environment 424ms)

Тест выполнился, все проверки прошли. Выполнение заняло всего одну секунду. Большую часть времени уходит при этом на создание окружения.

Замеряем время

В тестовом проекте вы можете найти еще один файл с проверками. Запустим оба теста:

npm run test:run                

> example-vue-vitest@0.0.0 test:run
> vitest run


 RUN  v4.1.0 /home/igor/projects/example-vue-vitest

 ✓ src/components/AppModal.vitest.ts (4 tests) 72ms
 ✓ src/components/AppButton.vitest.ts (9 tests) 86ms

 Test Files  2 passed (2)
      Tests  13 passed (13)
   Start at  19:46:37
   Duration  1.18s (transform 300ms, setup 0ms, import 779ms, tests 158ms, environment 939ms)

Общее время выполнения почти не изменилось.

Но я не до конца верю в те значения, которые отображает Vitest. Давайте посчитаем и все накладные расходы. Будем использовать команду time:

time npm run test:run

> example-vue-vitest@0.0.0 test:run
> vitest run


 RUN  v4.1.0 /home/igor/projects/example-vue-vitest

 ✓ src/components/AppModal.vitest.ts (4 tests) 77ms
 ✓ src/components/AppButton.vitest.ts (9 tests) 86ms

 Test Files  2 passed (2)
      Tests  13 passed (13)
   Start at  19:47:31
   Duration  1.18s (transform 366ms, setup 0ms, import 802ms, tests 163ms, environment 927ms)

npm run test:run  4.57s user 0.57s system 212% cpu 2.419 total

Получилось уже 2,5 секунды.

Добавление тестов Cypress

Создадим аналогичные тесты используя Cypress:

import AppButton from './AppButton.vue'

describe('AppButton (component)', () => {
  it('эмитит click при клике в обычном режиме', () => {
    const onClick = cy.spy().as('onClick')
    cy.mount(AppButton, {
      attrs: { onClick },
      slots: { default: 'Сохранить' },
    })
    cy.get('button.app-button').click()
    cy.get('@onClick').should('have.been.calledOnce')
  })

  it('не вызывает обработчик при loading', () => {
    const onClick = cy.spy().as('onClick')
    cy.mount(AppButton, {
      props: { loading: true },
      attrs: { onClick },
      slots: { default: 'Сохранить' },
    })
    cy.get('button.app-button').should('be.disabled')
    // TODO: cy.get('button.app-button').click()
    cy.get('@onClick').should('not.have.been.called')
  })

  it('не вызывает обработчик при disabled', () => {
    const onClick = cy.spy().as('onClick')
    cy.mount(AppButton, {
      props: { disabled: true },
      attrs: { onClick },
      slots: { default: 'Сохранить' },
    })
    cy.get('button.app-button').should('be.disabled')
    // TODO: cy.get('button.app-button').click()
    cy.get('@onClick').should('not.have.been.called')
  })

  it('пробрасывает type="button" на нативную кнопку', () => {
    cy.mount(AppButton, {
      props: { type: 'button' },
      slots: { default: 'OK' },
    })
    cy.get('button').should('have.attr', 'type', 'button')
  })

  it('пробрасывает type="submit" на нативную кнопку', () => {
    cy.mount(AppButton, {
      props: { type: 'submit' },
      slots: { default: 'OK' },
    })
    cy.get('button').should('have.attr', 'type', 'submit')
  })

  it('выставляет aria-busy при loading', () => {
    cy.mount(AppButton, {
      props: { loading: true },
      slots: { default: '…' },
    })
    cy.get('button.app-button').should('have.attr', 'aria-busy', 'true')
  })

  it('не задаёт aria-busy без loading', () => {
    cy.mount(AppButton, {
      slots: { default: 'OK' },
    })
    cy.get('button.app-button').should('not.have.attr', 'aria-busy')
  })

  it('показывает спиннер при loading', () => {
    cy.mount(AppButton, {
      props: { loading: true },
      slots: { default: 'Загрузка' },
    })
    cy.get('.app-button__spinner').should('exist')
  })

  it('рендерит слот по умолчанию', () => {
    cy.mount(AppButton, {
      slots: { default: 'Текст кнопки' },
    })
    cy.get('button.app-button').should('contain.text', 'Текст кнопки')
  })
})

Запускаем тест Cypress:

time npx cypress run --component --browser=chrome --quiet


  AppButton (component)
    ✓ эмитит click при клике в обычном режиме (208ms)
    ✓ не вызывает обработчик при loading (41ms)
    ✓ не вызывает обработчик при disabled (28ms)
    ✓ пробрасывает type="button" на нативную кнопку (29ms)
    ✓ пробрасывает type="submit" на нативную кнопку (18ms)
    ✓ выставляет aria-busy при loading (25ms)
    ✓ не задаёт aria-busy без loading (18ms)
    ✓ показывает спиннер при loading (26ms)
    ✓ рендерит слот по умолчанию (19ms)


  9 passing (474ms)



  AppModal (component)
    ✓ рендерится при open=true (119ms)
    ✓ не рендерится при open=false (26ms)
    ✓ эмитит close при клике на backdrop (96ms)
    ✓ эмитит close при клике на кнопку закрытия (110ms)


  4 passing (388ms)

npx cypress run --component --browser=chrome --quiet  8.13s user 3.35s system 73% cpu 15.595 total

Итоговое время получилось уже 15 секунд. Это в разы больше чем с Vitest. Однако, в таких простых тестах это, скорее, связано с отсутствием многопоточности.

Почему Vitest быстрее

Разный рендеринг

Cypress тесты будут выполняться медленнее своих аналогов Vitest, первым делом, из-за того, что последние запускаются в более быстрой среде.

Cypress запускает реальный браузер (Chrome/Electron). В нем рендеринг создает настоящий DOM layout (делает расчёт позиций элементов), парсит CSS, делает отрисовку в пикселях.

Vitest ничего не рендерит как браузер. Он выполняет код и даёт упрощённую модель. То есть он создаёт DOM-объекты (в JS), но эти объекты не имеют layout, живут в памяти и не имеют представления в пикселях. CSS почти игнорируется при этом.

В Vitest окружении имеет значение только то, кто чей родитель, какой элемент есть, а какого нет. У элементов нет реальных координат. Только место в дереве. Тесты проверяют лишь структуру, порядок элементов и наличие классов. Если кликнуть по скрытому через CSS элементу, то клик засчитается.

Многопоточность

Vitest запускает тесты в несколько потоков и легко даёт 200% CPU (зависит от числа ядер). Cypress без специальных дополнений — это 1 браузер. Выполнение тестов происходит последовательно. Есть, правда, возможность запускать и тесты Cypress параллельно. Об этом рассказывалась в предыдущих статьях (одна и вторая).

Разные окружения Vitest

У Vitest есть следующие окружения выполнения (тестовые окружения):

  • node — чистая среда Node.js без браузера
  • jsdom — эмуляция браузера через jsdom
  • happy-dom — быстрая альтернатива jsdom
  • edge-runtime — специальное окружение, имитирующее выполнение кода на edge-серверах (например, в Vercel)

Также существует режим browser mode (экспериментально) — запуск в реальном браузере. Это не еще одно окружение, как другие, а совершенно другой режим.

Из этих тестовых окружений нас интересует лишь jsdom и happy-dom (про режим browser mode не говорим сейчас). Так как лишь они пригодны для frontend.

Вариант jsdom покрывает большую часть браузерных API: Selection API, Range API, MutationObserver, Form behavior, Navigation / URL. Он ближе к спецификации WHATWG, использует сложные внутренние структуры и делает много проверок корректности.

Вариант happy-dom реализует только самое важное, что нужно для тестов. Многое упрощено или отсутствует. Это более “плоская” модель. Здесь меньше защит от неправильного использования.

Что выбрать Cypress или Vitest

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

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

Итоги перехода на Vitest

В этой статье мы экспериментировали с совсем простым проектом. Но в реальной работе я перевел компонентные тесты гораздо более крупного проекта с Cypress на Vitest.

Мне удалось в несколько раз уменьшить время их выполнения.

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

Это в свою очередь мотивирует разделять код на небольшие тестируемые части (компоненты, composables и т.д.) и делать основные проверки в компонентных тестах, а не тяжелых E2E. А декомпозиция, в свою очередь, упрощает дальнейшую поддержку и развитие приложения.

Код примера и настройки CI CD

Вы можете найти полные примеры кода из статьи в репозитории. Там же есть и настройки CI-CD GitLab обоих вариантов тестов.

0 0 голоса
Рейтинг статьи
guest
0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии