← Курс/Vitest: тестирование Vue приложений#251 из 257+25 XP

Vitest: тестирование Vue приложений

Что такое Vitest

**Vitest** — это тестовый фреймворк, созданный командой Vite. Он совместим с API Jest, но работает значительно быстрее благодаря тому, что использует ту же конфигурацию и трансформацию, что и сам Vite. Не нужно настраивать отдельный TypeScript-транспайлер для тестов.

Установка и настройка

npm install -D vitest @vue/test-utils jsdom
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',   // имитирует браузерное окружение
    globals: true,          // describe/it/expect без импорта
    setupFiles: ['./src/test/setup.ts'],
  },
})

Базовые тесты: describe / it / expect

// src/utils/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice, formatDate } from '@/utils/format'

describe('formatPrice', () => {
  it('форматирует целое число', () => {
    expect(formatPrice(1000)).toBe('1 000 ₽')
  })

  it('форматирует дробное число', () => {
    expect(formatPrice(99.9)).toBe('99,90 ₽')
  })

  it('возвращает 0 ₽ при нулевом значении', () => {
    expect(formatPrice(0)).toBe('0 ₽')
  })
})

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

// src/components/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

describe('Counter', () => {
  it('показывает начальное значение 0', () => {
    const wrapper = mount(Counter)
    expect(wrapper.text()).toContain('0')
  })

  it('увеличивает счётчик при нажатии кнопки', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button').trigger('click')
    expect(wrapper.text()).toContain('1')
  })

  it('принимает проп initialValue', () => {
    const wrapper = mount(Counter, {
      props: { initialValue: 10 },
    })
    expect(wrapper.text()).toContain('10')
  })
})

Мокирование с vi.mock()

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchUser } from '@/api/users'

// Полностью заменяем модуль
vi.mock('@/api/users', () => ({
  fetchUser: vi.fn(),
}))

describe('fetchUser', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('возвращает пользователя', async () => {
    const mockUser = { id: 1, name: 'Alice' }
    fetchUser.mockResolvedValueOnce(mockUser)

    const result = await fetchUser(1)
    expect(result).toEqual(mockUser)
    expect(fetchUser).toHaveBeenCalledWith(1)
  })

  it('пробрасывает ошибку', async () => {
    fetchUser.mockRejectedValueOnce(new Error('Not found'))
    await expect(fetchUser(999)).rejects.toThrow('Not found')
  })
})

Покрытие кода (coverage)

npm run test -- --coverage
// vite.config.ts
test: {
  coverage: {
    provider: 'v8',
    reporter: ['text', 'html'],
    exclude: ['node_modules/', 'src/test/'],
  },
}

После запуска откроется отчёт в coverage/index.html с визуальным отображением покрытых строк.

Полезные матчеры

expect(value).toBe(42)             // строгое равенство (===)
expect(obj).toEqual({ a: 1 })      // глубокое равенство
expect(arr).toContain('item')      // массив содержит элемент
expect(str).toMatch(/pattern/)     // строка по regexp
expect(fn).toThrow('error msg')    // функция бросает ошибку
expect(spy).toHaveBeenCalled()     // мок был вызван
expect(spy).toHaveBeenCalledTimes(2)
expect(promise).resolves.toBe(42)  // промис резолвится в 42

Примеры

Мини-фреймворк тестирования: реализация describe/it/expect с матчерами — так работает Vitest под капотом

// Реализуем минимальный тестовый фреймворк,
// аналогичный Vitest/Jest, чтобы понять, как они работают.

// ============================================
// Матчеры (expect)
// ============================================

function expect(received) {
  return {
    toBe(expected) {
      if (received !== expected) {
        throw new Error(
          `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(received)}`
        )
      }
    },
    toEqual(expected) {
      const a = JSON.stringify(received)
      const b = JSON.stringify(expected)
      if (a !== b) {
        throw new Error(`Expected ${b}, got ${a}`)
      }
    },
    toContain(item) {
      const found = Array.isArray(received)
        ? received.includes(item)
        : String(received).includes(item)
      if (!found) {
        throw new Error(`Expected ${JSON.stringify(received)} to contain ${JSON.stringify(item)}`)
      }
    },
    toThrow(msg) {
      let threw = false
      let error = null
      try { received() } catch (e) { threw = true; error = e }
      if (!threw) throw new Error('Expected function to throw, but it did not')
      if (msg && !error.message.includes(msg)) {
        throw new Error(`Expected error message to include "${msg}", got "${error.message}"`)
      }
    },
    toBeTruthy() {
      if (!received) throw new Error(`Expected truthy value, got ${received}`)
    },
    toBeFalsy() {
      if (received) throw new Error(`Expected falsy value, got ${received}`)
    },
    resolves: {
      async toBe(expected) {
        const result = await received
        if (result !== expected) {
          throw new Error(`Promise resolved to ${result}, expected ${expected}`)
        }
      }
    }
  }
}

// ============================================
// describe / it / beforeEach
// ============================================

let stats = { passed: 0, failed: 0 }
let currentSuite = ''
const beforeEachFns = []

function describe(name, fn) {
  currentSuite = name
  console.log(`\n  ${name}`)
  fn()
  currentSuite = ''
}

function it(name, fn) {
  // Выполняем все beforeEach перед каждым тестом
  for (const setup of beforeEachFns) setup()

  try {
    const result = fn()
    // Обрабатываем асинхронные тесты
    if (result && typeof result.then === 'function') {
      result.then(() => {
        stats.passed++
        console.log(`    ✓ ${name}`)
      }).catch(err => {
        stats.failed++
        console.log(`    ✗ ${name}: ${err.message}`)
      })
    } else {
      stats.passed++
      console.log(`    ✓ ${name}`)
    }
  } catch (err) {
    stats.failed++
    console.log(`    ✗ ${name}: ${err.message}`)
  }
}

function beforeEach(fn) {
  beforeEachFns.push(fn)
}

// ============================================
// vi.fn() — мок-функции
// ============================================

function vi_fn() {
  const calls = []
  let implementations = []

  const mock = function(...args) {
    calls.push(args)
    if (implementations.length > 0) {
      const impl = implementations.shift()
      return impl(...args)
    }
  }

  mock.calls = calls
  mock.mockReturnValueOnce = (val) => {
    implementations.push(() => val)
    return mock
  }
  mock.mockResolvedValueOnce = (val) => {
    implementations.push(() => Promise.resolve(val))
    return mock
  }
  mock.toHaveBeenCalled = () => calls.length > 0
  mock.toHaveBeenCalledWith = (...args) =>
    calls.some(c => JSON.stringify(c) === JSON.stringify(args))
  mock.mockClear = () => { calls.length = 0; implementations.length = 0 }

  return mock
}

// ============================================
// Тестируем реальный код с нашим мини-фреймворком
// ============================================

// Тестируемые утилиты
function formatPrice(amount) {
  if (amount === 0) return '0 ₽'
  return amount.toLocaleString('ru-RU') + ' ₽'
}

function sum(a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Оба аргумента должны быть числами')
  }
  return a + b
}

async function fetchData(id, fetcher) {
  const data = await fetcher(id)
  return { ...data, fetched: true }
}

// Тесты
console.log('=== Запуск тестов ===')

describe('formatPrice', () => {
  it('форматирует число', () => {
    const result = formatPrice(1000)
    expect(result).toContain('₽')
  })

  it('обрабатывает ноль', () => {
    expect(formatPrice(0)).toBe('0 ₽')
  })
})

describe('sum', () => {
  it('складывает два числа', () => {
    expect(sum(2, 3)).toBe(5)
  })

  it('бросает ошибку для не-чисел', () => {
    expect(() => sum('a', 1)).toThrow('числами')
  })
})

describe('fetchData с моком', () => {
  const mockFetcher = vi_fn()

  it('добавляет поле fetched: true', async () => {
    mockFetcher.mockResolvedValueOnce({ id: 1, name: 'Alice' })
    const result = await fetchData(1, mockFetcher)
    expect(result).toEqual({ id: 1, name: 'Alice', fetched: true })
  })
})

setTimeout(() => {
  console.log(`\n=== Итог: ${stats.passed} passed, ${stats.failed} failed ===`)
}, 100)