diff --git a/frontend/src/components/ui/badge.test.tsx b/frontend/src/components/ui/badge.test.tsx
new file mode 100644
index 0000000..9398eb9
--- /dev/null
+++ b/frontend/src/components/ui/badge.test.tsx
@@ -0,0 +1,34 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Badge } from "./badge";
+
+describe("Badge", () => {
+ it("renders children content", () => {
+ render(Easy);
+ expect(screen.getByText("Easy")).toBeInTheDocument();
+ });
+
+ it("applies default variant styles", () => {
+ render(Test);
+ const badge = screen.getByText("Test");
+ expect(badge).toHaveClass("rounded-full", "text-xs", "font-medium");
+ });
+
+ it("applies outline variant styles", () => {
+ render(Test);
+ const badge = screen.getByText("Test");
+ expect(badge).toHaveClass("border");
+ });
+
+ it("applies custom className", () => {
+ render(Test);
+ const badge = screen.getByText("Test");
+ expect(badge).toHaveClass("custom-class");
+ });
+
+ it("supports aria-label for accessibility", () => {
+ render(Easy);
+ const badge = screen.getByText("Easy");
+ expect(badge).toHaveAttribute("aria-label", "Easy difficulty");
+ });
+});
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts
new file mode 100644
index 0000000..c21dc62
--- /dev/null
+++ b/frontend/src/lib/api.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+describe("API client", () => {
+ const originalFetch = global.fetch;
+
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ });
+
+ it("getQuestions builds correct query string with filters", async () => {
+ let capturedUrl = "";
+ global.fetch = vi.fn().mockImplementation((url: string) => {
+ capturedUrl = url;
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ items: [],
+ total: 0,
+ page: 1,
+ limit: 20,
+ pages: 0,
+ }),
+ });
+ });
+
+ const { getQuestions } = await import("./api");
+ await getQuestions({ page: 2, difficulty: "easy", category: "arrays" });
+
+ expect(capturedUrl).toContain("page=2");
+ expect(capturedUrl).toContain("difficulty=easy");
+ expect(capturedUrl).toContain("category=arrays");
+ });
+
+ it("getQuestions omits empty filters", async () => {
+ let capturedUrl = "";
+ global.fetch = vi.fn().mockImplementation((url: string) => {
+ capturedUrl = url;
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ items: [],
+ total: 0,
+ page: 1,
+ limit: 20,
+ pages: 0,
+ }),
+ });
+ });
+
+ const { getQuestions } = await import("./api");
+ await getQuestions({});
+
+ expect(capturedUrl).not.toContain("?");
+ });
+
+ it("throws error on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ statusText: "Not Found",
+ });
+
+ const { getQuestion } = await import("./api");
+
+ await expect(getQuestion("nonexistent")).rejects.toThrow("API error: 404");
+ });
+
+ it("getStats returns stats data", async () => {
+ const mockStats = {
+ total_questions: 10,
+ by_difficulty: { easy: 3, medium: 5, hard: 2 },
+ by_category: [],
+ by_pattern: [],
+ };
+
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve(mockStats),
+ });
+
+ const { getStats } = await import("./api");
+ const result = await getStats();
+
+ expect(result).toEqual(mockStats);
+ });
+});
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 0000000..d4e9dca
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1,21 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+afterEach(() => {
+ cleanup();
+});
+
+Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 0000000..f9b69fd
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import path from "path";
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ include: ["src/**/*.test.{ts,tsx}"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "json", "html"],
+ include: ["src/**/*.{ts,tsx}"],
+ exclude: ["src/test/**", "src/**/*.test.{ts,tsx}", "src/types/**"],
+ },
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});