test(frontend): Vitest-Unit-Tests + CI-Integration, CLAUDE.md ignoriert
16 Tests ohne Browser-Abhängigkeit (jsdom): useIsDesktop (matchMedia-Reaktion), client.ts (ApiError, Popup-Blocker-Fix bei ladeDokumentAnsehen), CommandPalette (Debounce/Escape/Material-Treffer-Hinweis), ObjektListPage (Standort- Gruppierung, Kacheln-Umschalter, localStorage-Persistenz, Suche). Vitest separat von vite.config.ts (eigene vitest.config.ts), e2e/*.spec.ts (Playwright) explizit ausgeschlossen. CI führt `npm run test` jetzt vor dem Build aus. Nebenbei: CLAUDE.md ins .gitignore aufgenommen (bleibt lokal, nicht Teil des Repos). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
Generated
+1228
-1
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
@@ -26,12 +28,17 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6",
|
||||
"vite-plugin-pwa": "^0.20.5"
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { apiRequest, ApiError, ladeDokumentAnsehen } from "./client";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("apiRequest", () => {
|
||||
it("gibt JSON bei Erfolg zurück", async () => {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), { status: 200 })
|
||||
);
|
||||
const result = await apiRequest<{ ok: boolean }>("/irgendwas");
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("wirft ApiError mit Status und Detail bei Nicht-2xx", async () => {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: "Duplikat" }), { status: 409 })
|
||||
);
|
||||
await expect(apiRequest("/dokumente")).rejects.toMatchObject({
|
||||
status: 409,
|
||||
detail: { detail: "Duplikat" },
|
||||
});
|
||||
});
|
||||
|
||||
it("liefert undefined bei 204 ohne Body-Parse-Versuch", async () => {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 204 }));
|
||||
const result = await apiRequest("/irgendwas", { method: "DELETE" });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ApiError bleibt auch ohne JSON-Body (z.B. leere 401-Antwort) funktionsfähig", async () => {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 401 }));
|
||||
await expect(apiRequest("/geschuetzt")).rejects.toMatchObject({ status: 401, detail: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ladeDokumentAnsehen (Popup-Blocker-Fix)", () => {
|
||||
it("öffnet das Tab-Fenster SOFORT synchron, bevor fetch abgeschlossen ist", async () => {
|
||||
const tabStub = { location: { href: "" }, close: vi.fn() };
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(tabStub as unknown as Window);
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
new Response(new Blob(["pdf-inhalt"]), { status: 200 })
|
||||
);
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn().mockReturnValue("blob:fake-url") });
|
||||
|
||||
const promise = ladeDokumentAnsehen("abc-123");
|
||||
// window.open muss VOR dem await fetch() aufgerufen worden sein - genau
|
||||
// das war der ursprüngliche Bug (Popup-Blocker griff, weil open() erst
|
||||
// nach dem await passierte und damit nicht mehr als Nutzer-Klick zählte).
|
||||
expect(openSpy).toHaveBeenCalledWith("", "_blank");
|
||||
|
||||
await promise;
|
||||
expect(tabStub.location.href).toBe("blob:fake-url");
|
||||
});
|
||||
|
||||
it("fällt bei blockiertem Popup auf denselben Tab zurück statt zu crashen", async () => {
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
new Response(new Blob(["pdf-inhalt"]), { status: 200 })
|
||||
);
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn().mockReturnValue("blob:fake-url") });
|
||||
const ortsStub = { href: "" };
|
||||
vi.stubGlobal("location", ortsStub);
|
||||
|
||||
await ladeDokumentAnsehen("abc-123");
|
||||
expect(ortsStub.href).toBe("blob:fake-url");
|
||||
});
|
||||
|
||||
it("schließt das leere Tab-Fenster wieder, wenn der Download fehlschlägt", async () => {
|
||||
const tabStub = { location: { href: "" }, close: vi.fn() };
|
||||
vi.spyOn(window, "open").mockReturnValue(tabStub as unknown as Window);
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 404 }));
|
||||
|
||||
await expect(ladeDokumentAnsehen("fehlt")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(tabStub.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import * as client from "../api/client";
|
||||
import { AuthProvider } from "../auth/AuthContext";
|
||||
|
||||
function renderPalette() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<AuthProvider>
|
||||
<CommandPalette />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("CommandPalette", () => {
|
||||
it("öffnet sich per Strg+K und schließt per Escape", () => {
|
||||
renderPalette();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sucht erst ab 2 Zeichen und debounced Backend-Aufrufe (250ms)", async () => {
|
||||
const suchSpy = vi.spyOn(client, "apiRequest").mockResolvedValue([]);
|
||||
renderPalette();
|
||||
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
|
||||
|
||||
const eingabe = screen.getByPlaceholderText("Objekt suchen oder Aktion wählen…");
|
||||
fireEvent.change(eingabe, { target: { value: "K" } });
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(suchSpy).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(eingabe, { target: { value: "Kompresse" } });
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(suchSpy).toHaveBeenCalledWith("/suche?q=Kompresse");
|
||||
});
|
||||
|
||||
it("zeigt Material-Treffer mit ueber_material-Hinweis im Code-Feld", async () => {
|
||||
vi.spyOn(client, "apiRequest").mockResolvedValue([
|
||||
{ id: 7, code: "RTW-1", name: "RTW 1", ueber_material: "Kompresse" },
|
||||
]);
|
||||
renderPalette();
|
||||
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
|
||||
fireEvent.change(screen.getByPlaceholderText("Objekt suchen oder Aktion wählen…"), {
|
||||
target: { value: "Kompresse" },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("RTW 1")).toBeInTheDocument());
|
||||
expect(screen.getByText("RTW-1 · führt „Kompresse“")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useIsDesktop } from "./useIsDesktop";
|
||||
|
||||
function mockMatchMedia(anfangsWert: boolean) {
|
||||
const listeners = new Set<(e: MediaQueryListEvent) => void>();
|
||||
const mql = {
|
||||
matches: anfangsWert,
|
||||
addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn),
|
||||
};
|
||||
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue(mql));
|
||||
return {
|
||||
aendern(neuerWert: boolean) {
|
||||
mql.matches = neuerWert;
|
||||
listeners.forEach((fn) => fn({ matches: neuerWert } as MediaQueryListEvent));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("useIsDesktop", () => {
|
||||
it("liefert den initialen matchMedia-Wert", () => {
|
||||
mockMatchMedia(true);
|
||||
const { result } = renderHook(() => useIsDesktop());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("reagiert live auf einen Breakpoint-Wechsel (Resize)", () => {
|
||||
const { aendern } = mockMatchMedia(false);
|
||||
const { result } = renderHook(() => useIsDesktop());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => aendern(true));
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => aendern(false));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import * as client from "../api/client";
|
||||
import { ObjektListPage } from "./ObjektListPage";
|
||||
|
||||
const OBJEKTE = [
|
||||
{ id: 1, code: "RTW-1", name: "RTW 1", standort_id: 10, status: "aktiv", letzte_kontrolle_am: null },
|
||||
{ id: 2, code: "KTW-1", name: "KTW 1", standort_id: 20, status: "aktiv", letzte_kontrolle_am: null },
|
||||
];
|
||||
const STANDORTE = [
|
||||
{ id: 10, name: "Wache Nord" },
|
||||
{ id: 20, name: "Wache Süd" },
|
||||
];
|
||||
|
||||
function renderSeite() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ObjektListPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.spyOn(client, "apiRequest").mockImplementation((pfad: string) => {
|
||||
if (pfad === "/objekte") return Promise.resolve(OBJEKTE);
|
||||
if (pfad === "/standorte") return Promise.resolve(STANDORTE);
|
||||
return Promise.reject(new Error(`unerwarteter Pfad: ${pfad}`));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ObjektListPage", () => {
|
||||
it("gruppiert Objekte standardmäßig nach Standort", async () => {
|
||||
renderSeite();
|
||||
await waitFor(() => expect(screen.getByText("Wache Nord", { exact: false })).toBeInTheDocument());
|
||||
expect(screen.getByText("Wache Süd", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("RTW 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("KTW 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Umschalter wechselt auf flache Kachel-Ansicht ohne Standort-Gruppen", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderSeite();
|
||||
await waitFor(() => expect(screen.getByText("RTW 1")).toBeInTheDocument());
|
||||
|
||||
await nutzer.click(screen.getByRole("button", { name: "Kacheln" }));
|
||||
|
||||
expect(screen.queryByText("Wache Nord", { exact: false })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("RTW 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("KTW 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merkt die gewählte Ansicht über localStorage", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderSeite();
|
||||
await waitFor(() => expect(screen.getByText("RTW 1")).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByRole("button", { name: "Kacheln" }));
|
||||
|
||||
expect(localStorage.getItem("mabea-objekte-ansicht")).toBe("kacheln");
|
||||
});
|
||||
|
||||
it("Suchfeld filtert nach Name und Code", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderSeite();
|
||||
await waitFor(() => expect(screen.getByText("RTW 1")).toBeInTheDocument());
|
||||
|
||||
await nutzer.type(screen.getByPlaceholderText("Suche oder Code…"), "KTW");
|
||||
|
||||
expect(screen.queryByText("RTW 1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("KTW 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// jsdom kennt window.matchMedia nicht - Default-Stub (immer "matches: false",
|
||||
// also "mobil"), Tests die eine echte Breakpoint-Reaktion prüfen (useIsDesktop)
|
||||
// überschreiben das gezielt selbst.
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Separat von vite.config.ts, damit das eigentliche Build-Setup (PWA-Plugin,
|
||||
// Tailwind) unangetastet bleibt - Tests brauchen weder Service-Worker-
|
||||
// Generierung noch das Tailwind-Vite-Plugin.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
css: false,
|
||||
// e2e/*.spec.ts gehören Playwright (eigener Testrunner, andere API) -
|
||||
// sonst versucht Vitest sie mitzusammeln und crasht.
|
||||
exclude: ["e2e/**", "node_modules/**"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user