18. Тестирование
🧪 Тестирование Svelte компонентов
Заголовок раздела «🧪 Тестирование Svelte компонентов»Тесты — это как документация, которая проверяет себя сама. В Svelte экосистеме есть отличный инструментарий: @testing-library/svelte + Vitest дают тебе мощное, быстрое и удобное тестирование 🔬
Зачем тестировать?
Заголовок раздела «Зачем тестировать?»Без тестов:❌ "Работало вчера, сломалось сегодня"❌ Боишься рефакторить код❌ Баги находишь только в продакшне❌ Код превращается в хрупкую паутину
С тестами:✅ Рефакторинг без страха✅ Баги ловишь при написании кода✅ Документация через примеры использования✅ Уверенность при деплоеУстановка: Vitest + Testing Library
Заголовок раздела «Установка: Vitest + Testing Library»npm install -D vitest @testing-library/svelte @testing-library/jest-domnpm install -D @testing-library/user-event jsdom
# Если используешь SvelteKitnpm install -D @sveltejs/kit vitest @testing-library/svelteКонфигурация vitest.config.ts
Заголовок раздела «Конфигурация vitest.config.ts»import { defineConfig } from 'vitest/config'import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({ plugins: [svelte({ hot: !process.env.VITEST })], test: { include: ['src/**/*.{test,spec}.{js,ts}'], environment: 'jsdom', globals: true, setupFiles: ['./src/setupTests.ts'], },})setupTests.ts
Заголовок раздела «setupTests.ts»import '@testing-library/jest-dom'// Теперь доступны: toBeInTheDocument, toHaveClass, toHaveValue и т.д.Первый тест: render()
Заголовок раздела «Первый тест: render()»import { render, screen } from '@testing-library/svelte'import { describe, it, expect } from 'vitest'import Button from './Button.svelte'
describe('Button', () => { it('отображает текст', () => { render(Button, { props: { label: 'Нажми меня' } })
// screen.getByText — находит по тексту expect(screen.getByText('Нажми меня')).toBeInTheDocument() })
it('применяет вариант через класс', () => { render(Button, { props: { label: 'OK', variant: 'danger' } })
const btn = screen.getByRole('button') expect(btn).toHaveClass('btn--danger') })
it('кнопка disabled когда loading=true', () => { render(Button, { props: { label: 'Загрузка...', loading: true } })
expect(screen.getByRole('button')).toBeDisabled() })
it('отображает slot контент', () => { // Для тестирования слотов используем обёртку render(Button, { props: {}, slots: { default: 'Кликни!' } })
expect(screen.getByText('Кликни!')).toBeInTheDocument() })})screen — Запросы к DOM
Заголовок раздела «screen — Запросы к DOM»import { screen, within } from '@testing-library/svelte'
// Находит по роли (ARIA) — ПРЕДПОЧТИТЕЛЬНО!screen.getByRole('button', { name: 'Сохранить' })screen.getByRole('textbox', { name: 'Email' })screen.getByRole('list')screen.getByRole('listitem')screen.getByRole('heading', { level: 1 })screen.getByRole('checkbox', { checked: true })
// По текстуscreen.getByText('Привет, мир!')screen.getByText(/привет/i) // RegExp — нечувствительно к регистру
// По test-idscreen.getByTestId('submit-button')// В компоненте: <button data-testid="submit-button">
// По label (для форм!)screen.getByLabelText('Email адрес')screen.getByLabelText(/email/i)
// По placeholderscreen.getByPlaceholderText('Введи поисковый запрос')
// queryBy — не бросает ошибку если не найден (для проверки отсутствия)screen.queryByText('Ошибка') // null если не найденexpect(screen.queryByText('Ошибка')).not.toBeInTheDocument()
// findBy — асинхронный, ждёт появленияawait screen.findByText('Загружено!')await screen.findByRole('alert')
// getAllBy — все совпаденияconst items = screen.getAllByRole('listitem')expect(items).toHaveLength(3)fireEvent и userEvent — Взаимодействие
Заголовок раздела «fireEvent и userEvent — Взаимодействие»import { render, screen, fireEvent } from '@testing-library/svelte'import userEvent from '@testing-library/user-event'import Counter from './Counter.svelte'
describe('Counter', () => { it('увеличивает счёт при клике (fireEvent)', async () => { render(Counter)
const button = screen.getByRole('button', { name: 'Увеличить' })
// fireEvent — прямое событие, без браузерной симуляции await fireEvent.click(button) await fireEvent.click(button)
expect(screen.getByText('2')).toBeInTheDocument() })
it('увеличивает счёт при клике (userEvent)', async () => { // userEvent — реалистичная симуляция пользователя (ЛУЧШЕ!) const user = userEvent.setup() render(Counter)
await user.click(screen.getByRole('button', { name: 'Увеличить' })) await user.click(screen.getByRole('button', { name: 'Увеличить' })) await user.click(screen.getByRole('button', { name: 'Увеличить' }))
expect(screen.getByText('3')).toBeInTheDocument() })})
describe('SearchInput', () => { it('вызывает поиск при вводе', async () => { const user = userEvent.setup() render(SearchInput, { props: { onSearch: vi.fn() } })
const input = screen.getByRole('searchbox')
// type — симуляция каждого нажатия await user.type(input, 'Яша')
expect(input).toHaveValue('Яша') })
it('вызывает поиск по Enter', async () => { const user = userEvent.setup() const onSearch = vi.fn()
render(SearchInput, { props: { onSearch } })
await user.type(screen.getByRole('searchbox'), 'Svelte{Enter}')
expect(onSearch).toHaveBeenCalledWith('Svelte') expect(onSearch).toHaveBeenCalledTimes(1) })})Тестирование событий компонента
Заголовок раздела «Тестирование событий компонента»import { render, screen, fireEvent } from '@testing-library/svelte'import { vi, expect } from 'vitest'import Modal from './Modal.svelte'
describe('Modal', () => { it('отправляет событие close при клике на overlay', async () => { const handleClose = vi.fn()
const { component } = render(Modal, { props: { title: 'Тест', open: true } })
// Подписываемся на событие Svelte component.$on('close', handleClose)
// Кликаем на overlay await fireEvent.click(screen.getByTestId('modal-overlay'))
expect(handleClose).toHaveBeenCalledOnce() })
it('НЕ отправляет close при клике на контент', async () => { const handleClose = vi.fn() const { component } = render(Modal, { props: { title: 'Тест', open: true } }) component.$on('close', handleClose)
await fireEvent.click(screen.getByRole('dialog'))
expect(handleClose).not.toHaveBeenCalled() })})Тестирование Stores
Заголовок раздела «Тестирование Stores»import { get } from 'svelte/store'import { describe, it, expect, beforeEach } from 'vitest'import { createCounter } from './counter'
describe('counterStore', () => { let counter: ReturnType<typeof createCounter>
beforeEach(() => { counter = createCounter(0) })
it('начальное значение', () => { expect(get(counter)).toBe(0) })
it('increment увеличивает значение', () => { counter.increment() counter.increment() expect(get(counter)).toBe(2) })
it('decrement не уходит ниже минимума', () => { const limited = createCounter(0, { min: 0 }) limited.decrement() limited.decrement() expect(get(limited)).toBe(0) // Не меньше 0! })
it('reset возвращает начальное значение', () => { const c = createCounter(5) c.increment() c.increment() c.reset() expect(get(c)).toBe(5) })
it('подписка получает обновления', () => { const values: number[] = [] const unsubscribe = counter.subscribe(v => values.push(v))
counter.increment() counter.increment() counter.decrement()
unsubscribe()
expect(values).toEqual([0, 1, 2, 1]) })})Тестирование асинхронных компонентов
Заголовок раздела «Тестирование асинхронных компонентов»import { render, screen, waitFor } from '@testing-library/svelte'import { vi } from 'vitest'import UserProfile from './UserProfile.svelte'
// Мокируем fetchglobal.fetch = vi.fn()
describe('UserProfile', () => { beforeEach(() => { vi.resetAllMocks() })
it('показывает загрузку', async () => { // fetch никогда не резолвится (зависает) ;(global.fetch as any).mockImplementation( () => new Promise(() => {}) )
render(UserProfile, { props: { userId: 1 } })
expect(screen.getByText('Загрузка...')).toBeInTheDocument() })
it('показывает профиль после загрузки', async () => {
;(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockUser, })
render(UserProfile, { props: { userId: 1 } })
// Ждём пока компонент обновится await waitFor(() => { expect(screen.getByText('Яша')).toBeInTheDocument() })
expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument() })
it('показывает ошибку при неудачном запросе', async () => { ;(global.fetch as any).mockRejectedValue(new Error('Network error'))
render(UserProfile, { props: { userId: 999 } })
await waitFor(() => { expect(screen.getByText(/network error/i)).toBeInTheDocument() }) })})Тестирование с SvelteKit
Заголовок раздела «Тестирование с SvelteKit»import { render, screen } from '@testing-library/svelte'import Page from './+page.svelte'
describe('+page.svelte', () => { it('отображает данные из load()', () => { // Передаём data как пропс (data от load функции) render(Page, { props: { data: { posts: [ { id: 1, title: 'Первый пост' }, { id: 2, title: 'Второй пост' }, ], }, }, })
expect(screen.getByText('Первый пост')).toBeInTheDocument() expect(screen.getByText('Второй пост')).toBeInTheDocument() expect(screen.getAllByRole('article')).toHaveLength(2) })})// Мокирование SvelteKit модулейvi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidate: vi.fn(), prefetch: vi.fn(),}))
vi.mock('$app/stores', () => ({ page: readable({ url: new URL('http://localhost/'), params: {}, route: { id: '/' }, data: {}, }), navigating: readable(null),}))Playwright для E2E тестирования
Заголовок раздела «Playwright для E2E тестирования»import { test, expect } from '@playwright/test'
test.describe('Авторизация', () => { test('успешный вход', async ({ page }) => { await page.goto('/login')
// Заполняем форму await page.fill('[data-testid="password"]', 'secret123') await page.click('[data-testid="submit"]')
// Проверяем редирект на dashboard await expect(page).toHaveURL('/dashboard') await expect(page.getByText('Добро пожаловать, Яша!')).toBeVisible() })
test('ошибка при неверных данных', async ({ page }) => { await page.goto('/login')
await page.fill('[data-testid="password"]', 'wrongpass') await page.click('[data-testid="submit"]')
await expect(page.getByRole('alert')).toContainText('Неверный email или пароль') await expect(page).toHaveURL('/login') })})
// playwright.config.tsimport { defineConfig, devices } from '@playwright/test'
export default defineConfig({ testDir: './tests/e2e', use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'mobile', use: { ...devices['iPhone 14'] } }, ], webServer: { command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: !process.env.CI, },})Частые проблемы и решения
Заголовок раздела «Частые проблемы и решения»// ❌ Проблема: Тест падает на SSR-специфичный код// Решение: Используй jsdom environment и мокируй браузерные API
// ❌ Проблема: localStorage не работает в тестахvi.mock('./stores/localStorage', () => ({ getItem: vi.fn(), setItem: vi.fn(),}))
// ❌ Проблема: Анимации мешают тестам// Решение: Отключи transitions в тестовой среде// В компоненте:import { prefersReducedMotion } from './media'// transitions работают только если !prefersReducedMotion
// ❌ Проблема: Не дождались обновления DOM// Решение: Используй await + tick() или waitForimport { tick } from 'svelte'
await fireEvent.click(button)await tick() // Ждём обновления Svelte
// ❌ Проблема: Тестируем детали реализации// Плохо:expect(component.internalState).toBe(true) // Деталь реализации!
// Хорошо:expect(screen.getByText('Успешно сохранено')).toBeInTheDocument() // Поведение!