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

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

Привет! 👋 Тестирование Solid.js — это приятный опыт, потому что библиотека @solidjs/testing-library следует тем же принципам, что и React Testing Library: тестируй поведение, а не реализацию.

Думай о тестах для Solid как о самоходных сценариях: ты описываешь что пользователь делает, и проверяешь что видит на экране. Никаких прямых проверок внутреннего состояния — только наблюдаемые результаты.


Окно терминала
npm install -D vitest @solidjs/testing-library @testing-library/jest-dom jsdom
npm install -D vite-plugin-solid
vite.config.ts
import { 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?$/] },
},
});
vitest.setup.ts
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>
);
};
Counter.test.tsx
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');
});
});

// 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),
};
}
useCounter.test.ts
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);
});
});

UserProfile.tsx
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>
);
};
UserProfile.test.tsx
import { render, screen, waitFor } from '@solidjs/testing-library';
import { vi } from 'vitest';
import { UserProfile } from './UserProfile';
// Мокируем глобальный fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('UserProfile', () => {
beforeEach(() => mockFetch.mockReset());
it('показывает загрузку, потом данные', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'Яша', email: '[email protected]' }),
});
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); // Работает, но хрупко
});
// ✅ Тест через UI
it('хороший тест', () => {
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 с реактивным текстом
// Если текст меняется — используй waitFor
it('async проблема', () => {
render(() => <AsyncComp />);
// ❌ Элемент ещё не появился!
expect(screen.getByText('Данные')).toBeInTheDocument();
});
// ✅ waitFor для асинхронного контента
it('async правильно', async () => {
render(() => <AsyncComp />);
await waitFor(() => {
expect(screen.getByText('Данные')).toBeInTheDocument();
});
});