test: 添加完整测试套件(52 个文件,326 个用例)
基于 Vitest 搭建测试基础设施,覆盖后端纯函数、API 路由、 前端 hooks、UI 组件和页面级集成测试。
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user