Тестирование (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, меньше integration, мало E2E
  • 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)

ts
describe("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-queriestestid стабилен; 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.