22. Тестирование
🧪 Тестирование Solid.js
Заголовок раздела «🧪 Тестирование Solid.js»Привет! 👋 Тестирование Solid.js — это приятный опыт, потому что библиотека @solidjs/testing-library следует тем же принципам, что и React Testing Library: тестируй поведение, а не реализацию.
Думай о тестах для Solid как о самоходных сценариях: ты описываешь что пользователь делает, и проверяешь что видит на экране. Никаких прямых проверок внутреннего состояния — только наблюдаемые результаты.
🛠️ Настройка: Vitest + @solidjs/testing-library
Заголовок раздела «🛠️ Настройка: Vitest + @solidjs/testing-library»npm install -D vitest @solidjs/testing-library @testing-library/jest-dom jsdomnpm install -D vite-plugin-solidimport { defineConfig } from 'vite';import solid from 'vite-plugin-solid';
export default defineConfig({ plugins: [solid()], test: { environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], transformMode: { web: [/\.[jt]sx?$/] }, },});import '@testing-library/jest-dom';// tsconfig.json — убедись что jest-dom типы подключены{ "compilerOptions": { "types": ["vitest/globals", "@testing-library/jest-dom"] }}⚡ Тестирование компонентов
Заголовок раздела «⚡ Тестирование компонентов»// Counter.tsx — компонент для тестированияimport { createSignal, Component } from 'solid-js';
interface CounterProps { initialCount?: number; step?: number;}
export const Counter: Component<CounterProps> = (props) => { const [count, setCount] = createSignal(props.initialCount ?? 0); const step = () => props.step ?? 1;
return ( <div> <span data-testid="count">{count()}</span> <button onClick={() => setCount(n => n - step())}>−</button> <button onClick={() => setCount(n => n + step())}>+</button> <button onClick={() => setCount(props.initialCount ?? 0)}>Сброс</button> </div> );};import { render, screen, fireEvent } from '@solidjs/testing-library';import { Counter } from './Counter';
describe('Counter', () => { it('показывает начальное значение 0', () => { render(() => <Counter />); expect(screen.getByTestId('count')).toHaveTextContent('0'); });
it('увеличивает счётчик по клику', () => { render(() => <Counter />); fireEvent.click(screen.getByText('+')); expect(screen.getByTestId('count')).toHaveTextContent('1'); });
it('уменьшает счётчик по клику', () => { render(() => <Counter initialCount={5} />); fireEvent.click(screen.getByText('−')); expect(screen.getByTestId('count')).toHaveTextContent('4'); });
it('использует кастомный step', () => { render(() => <Counter step={5} />); fireEvent.click(screen.getByText('+')); expect(screen.getByTestId('count')).toHaveTextContent('5'); });
it('сбрасывает к начальному значению', () => { render(() => <Counter initialCount={10} />); fireEvent.click(screen.getByText('+')); fireEvent.click(screen.getByText('Сброс')); expect(screen.getByTestId('count')).toHaveTextContent('10'); });});🔬 Тестирование сигналов с renderHook
Заголовок раздела «🔬 Тестирование сигналов с renderHook»// useCounter.ts — хук с сигналамиimport { createSignal, createMemo } from 'solid-js';
export function createCounter(initial = 0) { const [count, setCount] = createSignal(initial); const doubled = createMemo(() => count() * 2); const isEven = createMemo(() => count() % 2 === 0);
return { count, doubled, isEven, increment: () => setCount(n => n + 1), decrement: () => setCount(n => n - 1), reset: () => setCount(initial), };}import { renderHook, act } from '@solidjs/testing-library';import { createCounter } from './useCounter';
describe('createCounter', () => { it('инициализируется с начальным значением', () => { const { result } = renderHook(() => createCounter(5)); expect(result.count()).toBe(5); });
it('вычисляет doubled и isEven корректно', () => { const { result } = renderHook(() => createCounter(4)); expect(result.doubled()).toBe(8); expect(result.isEven()).toBe(true); });
it('обновляет значения реактивно', () => { const { result } = renderHook(() => createCounter(0));
act(() => result.increment()); expect(result.count()).toBe(1); expect(result.doubled()).toBe(2); expect(result.isEven()).toBe(false);
act(() => result.increment()); expect(result.isEven()).toBe(true); });
it('сбрасывает к начальному значению', () => { const { result } = renderHook(() => createCounter(10)); act(() => result.increment()); act(() => result.increment()); act(() => result.reset()); expect(result.count()).toBe(10); });});🌊 Асинхронное тестирование с createResource
Заголовок раздела «🌊 Асинхронное тестирование с createResource»import { createResource, Show, Component } from 'solid-js';
interface User { id: number; name: string; email: string; }
async function fetchUser(id: number): Promise<User> { const res = await fetch(\`/api/users/\${id}\`); if (!res.ok) throw new Error('User not found'); return res.json();}
export const UserProfile: Component<{ userId: number }> = (props) => { const [user] = createResource(() => props.userId, fetchUser);
return ( <div> <Show when={user.loading}> <div data-testid="loading">Загрузка...</div> </Show> <Show when={user.error}> <div data-testid="error">{user.error?.message}</div> </Show> <Show when={user()}> {(u) => ( <div data-testid="user"> <h2>{u().name}</h2> <p>{u().email}</p> </div> )} </Show> </div> );};import { render, screen, waitFor } from '@solidjs/testing-library';import { vi } from 'vitest';import { UserProfile } from './UserProfile';
// Мокируем глобальный fetchconst mockFetch = vi.fn();global.fetch = mockFetch;
describe('UserProfile', () => { beforeEach(() => mockFetch.mockReset());
it('показывает загрузку, потом данные', async () => { mockFetch.mockResolvedValue({ ok: true, });
render(() => <UserProfile userId={1} />);
// Сначала — состояние загрузки expect(screen.getByTestId('loading')).toBeInTheDocument();
// Потом — данные пользователя await waitFor(() => { expect(screen.getByTestId('user')).toBeInTheDocument(); }); expect(screen.getByText('Яша')).toBeInTheDocument(); expect(screen.queryByTestId('loading')).not.toBeInTheDocument(); });
it('показывает ошибку при неудачном запросе', async () => { mockFetch.mockResolvedValue({ ok: false });
render(() => <UserProfile userId={999} />);
await waitFor(() => { expect(screen.getByTestId('error')).toBeInTheDocument(); }); expect(screen.getByText('User not found')).toBeInTheDocument(); });});🏪 Тестирование сторов
Заголовок раздела «🏪 Тестирование сторов»// store.test.ts — тестируем createStore напрямуюimport { createRoot } from 'solid-js';import { createStore } from 'solid-js/store';
interface CartStore { items: { id: number; name: string; qty: number; price: number }[];}
function createCartStore() { const [store, setStore] = createStore<CartStore>({ items: [] });
return { items: () => store.items, total: () => store.items.reduce((s, i) => s + i.qty * i.price, 0), addItem: (item: CartStore['items'][0]) => { const existing = store.items.findIndex(i => i.id === item.id); if (existing >= 0) { setStore('items', existing, 'qty', n => n + item.qty); } else { setStore('items', [...store.items, item]); } }, removeItem: (id: number) => { setStore('items', items => items.filter(i => i.id !== id)); }, };}
describe('CartStore', () => { it('добавляет товары и считает сумму', () => { createRoot(() => { const cart = createCartStore(); cart.addItem({ id: 1, name: 'Книга', qty: 2, price: 500 }); expect(cart.items()).toHaveLength(1); expect(cart.total()).toBe(1000); }); });
it('увеличивает qty для существующего товара', () => { createRoot(() => { const cart = createCartStore(); cart.addItem({ id: 1, name: 'Книга', qty: 1, price: 500 }); cart.addItem({ id: 1, name: 'Книга', qty: 2, price: 500 }); expect(cart.items()[0].qty).toBe(3); }); });});⚠️ Типичные ловушки при тестировании
Заголовок раздела «⚠️ Типичные ловушки при тестировании»// ❌ Тест реализации, а не поведенияit('плохой тест', () => { const [count, setCount] = createSignal(0); setCount(5); // Проверяем внутренний state — это антипаттерн! expect(count()).toBe(5); // Работает, но хрупко});
// ✅ Тест через UIit('хороший тест', () => { render(() => <Counter />); fireEvent.click(screen.getByText('+')); fireEvent.click(screen.getByText('+')); expect(screen.getByTestId('count')).toHaveTextContent('2');});
// ❌ Забыли cleanup — утечка между тестамиit('без cleanup', () => { render(() => <App />); // render из @solidjs/testing-library авто-cleanup!});
// ✅ @solidjs/testing-library автоматически чистит после каждого теста
// ❌ Неправильный selectByText с реактивным текстом// Если текст меняется — используй waitForit('async проблема', () => { render(() => <AsyncComp />); // ❌ Элемент ещё не появился! expect(screen.getByText('Данные')).toBeInTheDocument();});
// ✅ waitFor для асинхронного контентаit('async правильно', async () => { render(() => <AsyncComp />); await waitFor(() => { expect(screen.getByText('Данные')).toBeInTheDocument(); });});