Тестирование (Vitest + RTL + MSW)
Из M4–M6: мы построили стейт, UI и PWA. Теперь добавляем тесты, чтобы не сломать всё при рефакторинге.
Перед началом: Модули 1–6. Инструменты: Vitest, React Testing Library, MSW. Время: ~6 ч.
🔥 Инцидент. Релиз сломал отправку сообщений. Причина: мок-сервер возвращал
{ok: true}, а продакшен —{ok: true, id: 42}. Код не проверялid. Тестов не было — ручное тестирование пропустило регрессию. Нужна пирамида: unit (Vitest) → integration (RTL + MSW) → e2e (Playwright).
📖 Теория
Фокус: тесты, которые не падают из-за сети. Бизнес-правила проверяются изолированно.
Пирамида тестов
- Unit (основа) — UseCase, стор, guards; API/WS замоканы.
- Integration — компоненты через RTL + MSW.
- E2E (опц.) — Playwright.
Vitest + Testing Library setup
ts// vite.config.ts import { defineConfig } from "vitest/config" import react from "@vitejs/plugin-react" export default defineConfig({ plugins: [react()], test: { environment: "jsdom", globals: true, setupFiles: ["./src/test/setup.ts"], coverage: { provider: "v8", reporter: ["text", "lcov"] }, }, })
bash# package.json "test": "vitest run", "test:watch": "vitest", "test:cov": "vitest run --coverage"
MSW setup (мок сервера)
ts// src/test/server.ts import { setupServer } from "msw/node" import { http, HttpResponse } from "msw" export const server = setupServer( // Auth http.post("/api/auth/login", () => HttpResponse.json({ access_token: "test-token", user: { id: "u1", name: "Test" } }) ), http.post("/api/auth/register", () => HttpResponse.json({ id: "u-new", email: "test@test.com", name: "Test" }, { status: 201 }) ), // Rooms http.get("/api/rooms", () => HttpResponse.json([{ id: "r1", name: "general", type: "group" }]) ), // Messages http.post("/api/rooms/:id/messages", () => HttpResponse.json({ id: "m-real-1" }, { status: 202 }) ), http.get("/api/rooms/:id/messages", () => HttpResponse.json({ messages: [], next_cursor: null }) ), )
ts// src/test/setup.ts import { server } from "./server" beforeAll(() => server.listen({ onUnhandledRequest: "error" })) afterEach(() => { server.resetHandlers() useChatStore.setState(initialState) // сброс стора между тестами }) afterAll(() => server.close())
Unit-тест: sendMessage (optimistic flow)
ts// src/features/messages/service/sendMessage.test.ts import { describe, it, expect } from "vitest" import { sendMessage } from "./sendMessage" import { useChatStore } from "@/store/chatStore" describe("sendMessage", () => { it("adds optimistic then confirms on success", async () => { await sendMessage("r1", "Hello!") const msgs = Object.values(useChatStore.getState().optimisticMessages) expect(msgs).toHaveLength(1) expect(msgs[0].status).toBe("sent") expect(msgs[0].text).toBe("Hello!") }) it("rejects empty text (guard)", async () => { await expect(sendMessage("r1", " ")).rejects.toThrow("пустым") }) it("rejects too long text (guard)", async () => { await expect(sendMessage("r1", "x".repeat(4001))).rejects.toThrow("длинное") }) it("marks as failed on server error", async () => { server.use( http.post("/api/rooms/:id/messages", () => HttpResponse.json({ message: "boom" }, { status: 500 }) ) ) await expect(sendMessage("r1", "test")).rejects.toThrow() const msgs = Object.values(useChatStore.getState().optimisticMessages) expect(msgs[0].status).toBe("failed") }) })
Unit-тест: дедупликация (store)
tsdescribe("receiveMessage (dedup)", () => { it("ignores duplicate message_id", () => { const store = useChatStore.getState() const msg = { id: "m1", roomId: "r1", senderId: "u1", text: "hi", createdAt: "", status: "sent" } store.receiveMessage(msg) expect(useChatStore.getState().messages).toHaveLength(1) store.receiveMessage(msg) // дубль expect(useChatStore.getState().messages).toHaveLength(1) // не добавился }) })
Integration-тест: ChatRoom (RTL + MSW)
tsx// src/features/chat-room/ui/ChatRoom.test.tsx import { render, screen, fireEvent } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ChatRoom } from "./ChatRoom" function renderWithProviders(ui: React.ReactElement) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>) } describe("ChatRoom", () => { it("renders input and send button", () => { renderWithProviders(<ChatRoom roomId="r1" />) expect(screen.getByPlaceholderText(/Написать сообщение/)).toBeTruthy() expect(screen.getByText("Отправить")).toBeTruthy() }) it("disables send button when input is empty", () => { renderWithProviders(<ChatRoom roomId="r1" />) expect(screen.getByText("Отправить")).toBeDisabled() }) it("enables send button when typing", () => { renderWithProviders(<ChatRoom roomId="r1" />) fireEvent.change(screen.getByPlaceholderText(/Написать/), { target: { value: "test" } }) expect(screen.getByText("Отправить")).not.toBeDisabled() }) it("shows WS status indicator", () => { renderWithProviders(<ChatRoom roomId="r1" />) expect(screen.getByText(/WS:/)).toBeTruthy() }) })
WebSocket mock
Для real-time тестов — мокаем wsClient:
ts// src/test/wsMock.ts const handlers = new Map<string, Function[]>() vi.mock("@/infrastructure/ws/wsClient", () => ({ wsClient: { on: (type: string, fn: Function) => { if (!handlers.has(type)) handlers.set(type, []) handlers.get(type)!.push(fn) }, emit: (type: string, data: unknown) => { handlers.get(type)?.forEach((fn) => fn(data)) }, send: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), }, }) // В тесте: имитируем входящее сообщение wsClient.emit("message", { message: { id: "m-ws", roomId: "r1", text: "из WS", ... } }) // → useChatStore.receiveMessage сработает → ChatRoom покажет сообщение
CI
yaml# .github/workflows/frontend.yml name: Frontend CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20' } - run: npm ci - run: npm run lint - run: npx tsc --noEmit - run: npm run test:cov
Практика
- Покрыть
sendMessageтестами: success, guard (пусто/длинно), server error → failed. - Покрыть
receiveMessageтестом дедупликации. - Написать integration-тест
ChatRoom: рендер, ввод, кнопка disabled/enabled. - Добавить WS-мок и проверить, что входящее сообщение появляется в UI.
💱 Компромиссы (Trade-offs)
| Решение | Плюсы | Минусы |
|---|---|---|
| Unit-тесты (Vitest) | Быстрые, изолированные, ловят регрессии в логике | Не покрывают интеграцию слоёв |
| Integration (RTL + MSW) | Проверяют реальный рендер и поток данных | Медленнее, хрупче к изменениям DOM |
| MSW на уровне сети | Тесты не зависят от backend, детерминированы | Мок может разойтись с реальным API (как в инциденте) |
| E2E (Playwright) | Покрывает пользовательские сценарии целиком | Медленные, дорогие в CI |
data-testid vs role-queries | testid стабилен; role устойчив к рефакторингу | testid связывает тест с разметкой |
🧩 Проверь себя
1. MSW (Mock Service Worker) — зачем?
2. При FSD тесты пишут...
3. data-testid в тестах — за и против?
4. Integration test в FSD — какой scope?
5. Vitest + RTL + MSW: кто за что?
🔗 Мостик к Модулю 8
Тесты зелёные, coverage растёт. Задеплоим наш PWA в продакшен — на Yandex Object Storage.