# Testing

Three layers: Vitest + Testing Library for unit/integration, MSW for HTTP mocking, and Playwright for end-to-end. Each layer has a distinct purpose — do not duplicate tests across them.

## Install

```bash
bun add -d vitest @vitest/coverage-v8 \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event \
  jsdom @playwright/test msw
```

## Vitest Configuration

```ts
// vitest.config.ts:1
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "node:path";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./vitest.setup.ts"],
    css: false,
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "json-summary"],
      exclude: ["**/*.test.*", "**/*.spec.*", "src/app/**", "**/types.ts"],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});
```

```ts
// vitest.setup.ts:1
import "@testing-library/jest-dom/vitest";
import { server } from "./src/test/server";

// Start MSW before all tests
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```

## MSW Server

Centralized HTTP mocks. Handlers live next to features; the server boots from `src/test/server.ts`.

```ts
// src/test/server.ts:1
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
```

```ts
// src/test/handlers.ts:1
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("/api/users", () => {
    return HttpResponse.json({
      items: [
        { id: "1", nip: "12345678", name: "Alice", email: "alice@example.com", roles: ["admin"], isActive: true, createdAt: new Date().toISOString() },
      ],
      total: 1,
      page: 1,
      pageSize: 20,
    });
  }),
];
```

Override per-test in a `describe` block via `server.use(...)` — handlers compose, last-wins.

## Mantine Test Wrapper

Every component test mounts the providers tree:

```tsx
// src/test/TestProviders.tsx:1
"use client";

import { MantineProvider } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { theme } from "@/lib/theme";

export function TestProviders({ children }: { children: ReactNode }) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return (
    <MantineProvider theme={theme}>
      <QueryClientProvider client={queryClient}>
        <ModalsProvider>
          <Notifications />
          {children}
        </ModalsProvider>
      </QueryClientProvider>
    </MantineProvider>
  );
}
```

```tsx
// src/test/render.tsx:1
import { render, type RenderOptions } from "@testing-library/react";
import { TestProviders } from "./TestProviders";

export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
  return render(ui, { wrapper: TestProviders, ...options });
}
```

## Component Test Example

```tsx
// src/features/users/components/__tests__/UsersList.test.tsx:1
import { describe, it, expect, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "@/test/server";
import { renderWithProviders } from "@/test/render";
import { UsersList } from "../UsersList";

describe("UsersList", () => {
  it("renders users fetched from API", async () => {
    renderWithProviders(<UsersList />);

    await waitFor(() => {
      expect(screen.getByText("Alice")).toBeInTheDocument();
    });
  });

  it("shows error alert when API fails", async () => {
    server.use(
      http.get("/api/users", () => new HttpResponse(null, { status: 500 })),
    );

    renderWithProviders(<UsersList />);

    await waitFor(() => {
      expect(screen.getByRole("alert")).toHaveTextContent(/failed/i);
    });
  });
});
```

## Hook Test Example

```tsx
// src/features/users/hooks/__tests__/useUsers.test.ts:1
import { describe, it, expect } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { server } from "@/test/server";
import { http, HttpResponse } from "msw";
import { TestProviders } from "@/test/TestProviders";
import { useUsers } from "../useUsers";

describe("useUsers", () => {
  it("returns parsed user list", async () => {
    const { result } = renderHook(() => useUsers({}), {
      wrapper: TestProviders,
    });

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data?.total).toBe(1);
    expect(result.current.data?.items[0].name).toBe("Alice");
  });
});
```

## Form Test Example

```tsx
// src/features/users/components/__tests__/UserForm.test.tsx:1
import { describe, it, expect, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/test/render";
import { UserForm } from "../UserForm";

describe("UserForm", () => {
  it("shows validation errors for empty submit", async () => {
    const onSubmit = vi.fn();
    renderWithProviders(<UserForm onSubmit={onSubmit} />);

    await userEvent.click(screen.getByRole("button", { name: /save/i }));

    expect(await screen.findByText(/NIP must be at least 8 characters/)).toBeInTheDocument();
    expect(screen.getByText(/Name is required/)).toBeInTheDocument();
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it("submits valid values", async () => {
    const onSubmit = vi.fn().mockResolvedValue(undefined);
    renderWithProviders(<UserForm onSubmit={onSubmit} />);

    await userEvent.type(screen.getByLabelText(/NIP/i), "12345678");
    await userEvent.type(screen.getByLabelText(/Name/i), "Alice");
    await userEvent.type(screen.getByLabelText(/Email/i), "alice@example.com");
    await userEvent.click(screen.getByRole("button", { name: /save/i }));

    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        nip: "12345678",
        name: "Alice",
        email: "alice@example.com",
        isActive: true,
      });
    });
  });
});
```

## Playwright

```ts
// playwright.config.ts:1
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? "github" : "list",
  use: {
    baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
  ],
  webServer: {
    command: "bun run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 60_000,
  },
});
```

```ts
// e2e/auth.spec.ts:1
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
  test("redirects to login when unauthenticated", async ({ page }) => {
    await page.goto("/dashboard");
    await expect(page).toHaveURL(/\/login/);
  });

  test("shows dashboard after sign-in", async ({ page }) => {
    await page.goto("/login");
    await page.getByRole("button", { name: /continue with keycloak/i }).click();

    // Keycloak test realm — fill credentials
    await page.fill("#username", process.env.E2E_USERNAME!);
    await page.fill("#password", process.env.E2E_PASSWORD!);
    await page.click("#kc-login");

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByRole("heading", { name: /overview/i })).toBeVisible();
  });
});
```

E2E auth tests must use a **dedicated Keycloak test realm** with seeded users — never the production realm.

## Commands

```bash
bun run test              # vitest run (CI mode)
bun run test:watch        # vitest --watch
bun run test:coverage     # vitest run --coverage
bun run test:e2e          # playwright test
bun run test:e2e:ui       # playwright test --ui
```

## Test Discipline

- One assertion focus per test. Use multiple `it()` blocks for multiple concerns.
- Tests must not depend on execution order.
- Mock at the network boundary (MSW), not at the axios boundary — keep tests realistic.
- For Zod schema drift, the test that fails is a feature: it forces an explicit schema update.
- E2E tests cover happy paths only; edge cases belong in unit/integration tests.
- Coverage threshold: 80% lines/functions, 75% branches. Adjust per-feature in `vitest.config.ts` if a folder is exceptionally hard to test (e.g. generated code).