Перейти к содержимому

18. Тестирование

Тесты — это как документация, которая проверяет себя сама. В Svelte экосистеме есть отличный инструментарий: @testing-library/svelte + Vitest дают тебе мощное, быстрое и удобное тестирование 🔬


Без тестов:
❌ "Работало вчера, сломалось сегодня"
❌ Боишься рефакторить код
❌ Баги находишь только в продакшне
❌ Код превращается в хрупкую паутину
С тестами:
✅ Рефакторинг без страха
✅ Баги ловишь при написании кода
✅ Документация через примеры использования
✅ Уверенность при деплое

Окно терминала
npm install -D vitest @testing-library/svelte @testing-library/jest-dom
npm install -D @testing-library/user-event jsdom
# Если используешь SvelteKit
npm install -D @sveltejs/kit vitest @testing-library/svelte
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'],
},
})
src/setupTests.ts
import '@testing-library/jest-dom'
// Теперь доступны: toBeInTheDocument, toHaveClass, toHaveValue и т.д.

Button.test.ts
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()
})
})

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-id
screen.getByTestId('submit-button')
// В компоненте: <button data-testid="submit-button">
// По label (для форм!)
screen.getByLabelText('Email адрес')
screen.getByLabelText(/email/i)
// По placeholder
screen.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)

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/counter.test.ts
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])
})
})

UserProfile.test.ts
import { render, screen, waitFor } from '@testing-library/svelte'
import { vi } from 'vitest'
import UserProfile from './UserProfile.svelte'
// Мокируем fetch
global.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 () => {
const mockUser = { id: 1, name: 'Яша', email: '[email protected]' }
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => mockUser,
})
render(UserProfile, { props: { userId: 1 } })
// Ждём пока компонент обновится
await waitFor(() => {
expect(screen.getByText('Яша')).toBeInTheDocument()
})
expect(screen.getByText('[email protected]')).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()
})
})
})

routes/+page.test.ts
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),
}))

tests/e2e/login.test.ts
import { test, expect } from '@playwright/test'
test.describe('Авторизация', () => {
test('успешный вход', async ({ page }) => {
await page.goto('/login')
// Заполняем форму
await page.fill('[data-testid="email"]', '[email protected]')
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="email"]', '[email protected]')
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.ts
import { 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() или waitFor
import { tick } from 'svelte'
await fireEvent.click(button)
await tick() // Ждём обновления Svelte
// ❌ Проблема: Тестируем детали реализации
// Плохо:
expect(component.internalState).toBe(true) // Деталь реализации!
// Хорошо:
expect(screen.getByText('Успешно сохранено')).toBeInTheDocument() // Поведение!