test: 添加完整测试套件(52 个文件,326 个用例)

基于 Vitest 搭建测试基础设施,覆盖后端纯函数、API 路由、
前端 hooks、UI 组件和页面级集成测试。
This commit is contained in:
2026-02-28 20:19:14 +08:00
parent 11eeec868e
commit 3ccd1262f9
59 changed files with 8131 additions and 3 deletions
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
const mockGetCurrentPosition = vi.fn();
const mockFetch = vi.fn();
vi.stubGlobal("navigator", {
geolocation: {
getCurrentPosition: mockGetCurrentPosition,
},
});
vi.stubGlobal("fetch", mockFetch);
import { useGeolocation } from "./useGeolocation";
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ name: "黄浦区", formatted: "上海市黄浦区" }),
});
});
describe("useGeolocation", () => {
it("returns success with coords on successful geolocation", async () => {
mockGetCurrentPosition.mockImplementation((success) => {
success({ coords: { latitude: 31.23, longitude: 121.47 } });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.status).toBe("success");
});
expect(result.current.coords).toEqual({ lat: 31.23, lng: 121.47 });
});
it("reverse geocodes after getting coords", async () => {
mockGetCurrentPosition.mockImplementation((success) => {
success({ coords: { latitude: 31.23, longitude: 121.47 } });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.locationName).toBe("黄浦区");
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/location/regeo"),
);
});
it("sets denied status on permission denied", async () => {
mockGetCurrentPosition.mockImplementation((_success, error) => {
error({ code: 1, PERMISSION_DENIED: 1 });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.status).toBe("denied");
});
expect(result.current.coords).toBeNull();
});
it("sets failed status on timeout", async () => {
mockGetCurrentPosition.mockImplementation((_success, error) => {
error({ code: 3, PERMISSION_DENIED: 1, TIMEOUT: 3 });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.status).toBe("failed");
});
});
it("provides retry function", async () => {
mockGetCurrentPosition
.mockImplementationOnce((_success, error) => {
error({ code: 3, PERMISSION_DENIED: 1, TIMEOUT: 3 });
})
.mockImplementationOnce((success) => {
success({ coords: { latitude: 31.23, longitude: 121.47 } });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.status).toBe("failed");
});
await act(async () => {
await result.current.retry();
});
expect(result.current.status).toBe("success");
expect(result.current.coords).toEqual({ lat: 31.23, lng: 121.47 });
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
let esInstances: { onmessage?: (e: MessageEvent) => void; onerror?: () => void; onopen?: () => void; close: ReturnType<typeof vi.fn> }[] = [];
class MockEventSource {
onmessage: ((e: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onopen: (() => void) | null = null;
close = vi.fn();
constructor(_url: string) {
esInstances.push(this);
}
}
vi.stubGlobal("EventSource", MockEventSource);
vi.mock("swr", () => ({
default: vi.fn().mockReturnValue({
data: {
roomId: "ROOM01",
userCount: 2,
match: null,
matchType: null,
matchLikes: 0,
runnerUps: [],
likeCounts: {},
swipeCounts: {},
restaurants: [],
creatorId: "user-1",
locked: false,
users: ["user-1", "user-2"],
userProfiles: {},
scene: "eat",
},
error: null,
isLoading: false,
mutate: vi.fn(),
}),
}));
import { useRoomPolling } from "./useRoomPolling";
beforeEach(() => {
esInstances = [];
});
afterEach(() => {
vi.clearAllMocks();
});
describe("useRoomPolling", () => {
it("returns room data from SWR", () => {
const { result } = renderHook(() => useRoomPolling("ROOM01"));
expect(result.current.userCount).toBe(2);
expect(result.current.users).toEqual(["user-1", "user-2"]);
expect(result.current.scene).toBe("eat");
});
it("creates EventSource connection", () => {
renderHook(() => useRoomPolling("ROOM01"));
expect(esInstances.length).toBeGreaterThan(0);
});
it("returns defaults when no roomId", () => {
const { result } = renderHook(() => useRoomPolling(undefined));
expect(result.current.userCount).toBe(2);
});
it("returns notFound from error state", async () => {
const useSWR = vi.mocked((await import("swr")).default);
useSWR.mockReturnValue({
data: undefined,
error: new Error("NOT_FOUND"),
isLoading: false,
mutate: vi.fn(),
} as never);
const { result } = renderHook(() => useRoomPolling("ROOM01"));
expect(result.current.notFound).toBe(true);
});
it("cleans up EventSource on unmount", () => {
const { unmount } = renderHook(() => useRoomPolling("ROOM01"));
const es = esInstances[esInstances.length - 1];
unmount();
expect(es.close).toHaveBeenCalled();
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { type ReactNode } from "react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
import { useShare } from "./useShare";
const mockShow = vi.fn();
const ctxValue: ToastContextValue = { show: mockShow };
function wrapper({ children }: { children: ReactNode }) {
return React.createElement(
ToastContext.Provider,
{ value: ctxValue },
children,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("useShare", () => {
describe("copyToClipboard", () => {
it("copies text and shows success toast", async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const { result } = renderHook(() => useShare(), { wrapper });
await act(async () => {
await result.current.copyToClipboard("hello");
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello");
expect(mockShow).toHaveBeenCalledWith("已复制");
});
it("uses custom success message", async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const { result } = renderHook(() => useShare(), { wrapper });
await act(async () => {
await result.current.copyToClipboard("hello", "复制成功");
});
expect(mockShow).toHaveBeenCalledWith("复制成功");
});
it("shows error toast on clipboard failure", async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error()) },
});
const { result } = renderHook(() => useShare(), { wrapper });
await act(async () => {
await result.current.copyToClipboard("hello");
});
expect(mockShow).toHaveBeenCalledWith("复制失败,请手动复制");
});
});
describe("share", () => {
it("uses native share when available", async () => {
const mockNativeShare = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
share: mockNativeShare,
canShare: vi.fn().mockReturnValue(true),
});
const { result } = renderHook(() => useShare(), { wrapper });
let shared = false;
await act(async () => {
shared = await result.current.share({ title: "Test", text: "test" });
});
expect(shared).toBe(true);
expect(mockNativeShare).toHaveBeenCalled();
});
it("calls fallback when native share unavailable", async () => {
Object.assign(navigator, {
share: undefined,
canShare: undefined,
});
const fallback = vi.fn();
const { result } = renderHook(() => useShare(), { wrapper });
let shared = false;
await act(async () => {
shared = await result.current.share({ title: "Test" }, fallback);
});
expect(shared).toBe(false);
expect(fallback).toHaveBeenCalled();
});
it("handles AbortError gracefully", async () => {
const abortError = new Error("User aborted");
abortError.name = "AbortError";
Object.assign(navigator, {
share: vi.fn().mockRejectedValue(abortError),
canShare: vi.fn().mockReturnValue(true),
});
const { result } = renderHook(() => useShare(), { wrapper });
let shared = false;
await act(async () => {
shared = await result.current.share({ title: "Test" });
});
expect(shared).toBe(true);
});
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { renderHook } from "@testing-library/react";
import { type ReactNode } from "react";
import { useToast, ToastContext, type ToastContextValue } from "./useToast";
describe("useToast", () => {
it("throws error when used outside provider", () => {
expect(() => {
renderHook(() => useToast());
}).toThrow("useToast must be used within ToastProvider");
});
it("returns context value when inside provider", () => {
const mockShow = () => {};
const ctxValue: ToastContextValue = { show: mockShow };
const wrapper = ({ children }: { children: ReactNode }) => (
<ToastContext.Provider value={ctxValue}>
{children}
</ToastContext.Provider>
);
const { result } = renderHook(() => useToast(), { wrapper });
expect(result.current.show).toBe(mockShow);
});
});