test: add frontend testing infrastructure with vitest
- Introduce Vitest + React Testing Library + jsdom environment - Add useDragSort hook unit tests covering: * Sorting logic (sortIndex → createdAt → name) * Successful drag operation (API call + cache invalidation) * Failed drag operation (error toast display) * Edge case (no valid target, no API call) - Configure global test setup (i18n mock, auto cleanup) - Update TypeScript configs to include tests/ directory - Add test development plan documentation Test Coverage: ✓ Provider drag-and-drop sorting core logic ✓ React Query cache refresh ✓ Toast notification display ✓ Boundary condition handling Test Results: 4/4 passed (671ms) Next Steps: Sprint 2 - component tests with MSW mock layer
This commit is contained in:
73
docs/TEST_DEVELOPMENT_PLAN.md
Normal file
73
docs/TEST_DEVELOPMENT_PLAN.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 前端测试开发计划
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
- **背景**:v3.5.0 起前端功能快速扩张(供应商管理、MCP、导入导出、端点测速、国际化),缺失系统化测试导致回归风险与人工验证成本攀升。
|
||||||
|
- **目标**:在 3 个迭代内建立覆盖关键业务的自动化测试体系,形成稳定的手动冒烟流程,并将测试执行纳入 CI/CD。
|
||||||
|
|
||||||
|
## 2. 范围与优先级
|
||||||
|
| 范围 | 内容 | 优先级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 供应商管理 | 列表、排序、预设/自定义表单、切换、复制、删除 | P0 |
|
||||||
|
| 配置导入导出 | JSON 校验、备份、进度反馈、失败回滚 | P0 |
|
||||||
|
| MCP 管理 | 列表、启停、模板、命令校验 | P1 |
|
||||||
|
| 设置面板 | 主题/语言切换、目录设置、关于、更新检查 | P1 |
|
||||||
|
| 端点速度测试 & 使用脚本 | 启动测试、状态指示、脚本保存 | P2 |
|
||||||
|
| 国际化 | 中英切换、缺省文案回退 | P2 |
|
||||||
|
|
||||||
|
## 3. 测试分层策略
|
||||||
|
- **单元测试(Vitest)**:纯函数与 Hook(`useProviderActions`、`useSettingsForm`、`useDragSort`、`useImportExport` 等)验证数据处理、错误分支、排序逻辑。
|
||||||
|
- **组件测试(React Testing Library)**:关键组件(`ProviderList`、`AddProviderDialog`、`SettingsDialog`、`McpPanel`)模拟交互、校验、提示;结合 MSW 模拟 API。
|
||||||
|
- **集成测试(App 级别)**:挂载 `App.tsx`,覆盖应用切换、编辑模式、导入导出回调、语言切换,验证状态同步与 toast 提示。
|
||||||
|
- **端到端测试(Playwright)**:依赖 `pnpm dev:renderer`,串联供应商 CRUD、排序拖拽、MCP 启停、语言切换即时刷新、更新检查跳转。
|
||||||
|
- **手动冒烟**:Tauri 桌面包 + dev server 双通道,验证托盘、系统权限、真实文件写入。
|
||||||
|
|
||||||
|
## 4. 环境与工具
|
||||||
|
- 依赖:Node 18+、pnpm 8+、Vitest、React Testing Library、MSW、Playwright、Testing Library User Event、Playwright Trace Viewer。
|
||||||
|
- 配置要点:
|
||||||
|
- 在 `tsconfig` 中共享别名,Vitest 配合 `vite.config.mts`。
|
||||||
|
- `setupTests.ts` 统一注册 MSW/RTL、自定义 matcher。
|
||||||
|
- Playwright 使用多浏览器矩阵(Chromium 必选,WebKit 可选),并共享 `.env.test`。
|
||||||
|
- Mock `@tauri-apps/api` 与 `providersApi`/`settingsApi`,隔离 Rust 层。
|
||||||
|
|
||||||
|
## 5. 自动化建设里程碑
|
||||||
|
| 周期 | 目标 | 交付 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sprint 1 | Vitest 基础设施、核心 Hook 单测(P0) | `pnpm test:unit`、覆盖率报告、10+ 用例 |
|
||||||
|
| Sprint 2 | 组件/集成测试、MSW Mock 层 | `pnpm test:component`、App 主流程用例 |
|
||||||
|
| Sprint 3 | Playwright E2E、CI 接入 | `pnpm test:e2e`、CI job、冒烟脚本 |
|
||||||
|
| 持续 | 回归用例补齐、视觉比对探索 | Playwright Trace、截图基线 |
|
||||||
|
|
||||||
|
## 6. 用例规划概览
|
||||||
|
- **供应商管理**:新增(预设+自定义)、编辑校验、复制排序、切换失败回退、删除确认、使用脚本保存。
|
||||||
|
- **导入导出**:成功、重复导入、校验失败、备份失败提示、导入后托盘刷新。
|
||||||
|
- **MCP**:模板应用、协议切换(stdio/http)、命令校验、启停状态持久化。
|
||||||
|
- **设置**:主题/语言即时生效、目录路径更新、更新检查按钮外链、关于信息渲染。
|
||||||
|
- **端点速度测试**:触发测试、loading/成功/失败状态、指示器颜色、测速数据排序。
|
||||||
|
- **国际化**:默认中文、切换英文后主界面/对话框文案变化、缺失 key fallback。
|
||||||
|
|
||||||
|
## 7. 数据与 Mock 策略
|
||||||
|
- 在 `tests/fixtures/` 维护标准供应商、MCP、设置数据集。
|
||||||
|
- 使用 MSW 拦截 `providersApi`、`settingsApi`、`providersApi.onSwitched` 等调用;提供延迟/错误注入接口以覆盖异常分支。
|
||||||
|
- Playwright 端提供临时用户目录(`TMP_CC_SWITCH_HOME`)+ 伪配置文件,以验证真实文件交互路径。
|
||||||
|
|
||||||
|
## 8. 质量门禁与指标
|
||||||
|
- 覆盖率目标:单元 ≥75%,分支 ≥70%,逐步提升至 80%+。
|
||||||
|
- CI 阶段:`pnpm typecheck` → `pnpm format:check` → `pnpm test:unit` → `pnpm test:component` → `pnpm test:e2e`(可在 nightly 执行)。
|
||||||
|
- 缺陷处理:修复前补充最小复现测试;E2E 冒烟必须陪跑重大功能发布。
|
||||||
|
|
||||||
|
## 9. 工作流与职责
|
||||||
|
- **测试负责人**:前端工程师轮值;负责测试计划维护、PR 流水线健康。
|
||||||
|
- **开发者职责**:提交功能需附新增/更新测试、列出手动验证步骤、如涉及 UI 提交截图。
|
||||||
|
- **Code Review 检查**:测试覆盖说明、mock 合理性、易读性。
|
||||||
|
|
||||||
|
## 10. 风险与缓解
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Tauri API Mock 难度高 | 单测无法稳定 | 抽象 API 适配层 + MSW 统一模拟 |
|
||||||
|
| Playwright 运行时间长 | CI 变慢 | 拆分冒烟/完整版,冒烟只跑关键路径 |
|
||||||
|
| 国际化文案频繁变化 | 用例脆弱 | 优先断言 data-testid/结构,文案使用翻译 key |
|
||||||
|
|
||||||
|
## 11. 输出与维护
|
||||||
|
- 文档维护者:前端团队;每个版本更新后检查测试覆盖清单。
|
||||||
|
- 交付物:测试报告(CI artifact)、Playwright Trace、覆盖率摘要。
|
||||||
|
- 复盘:每次发布后召开 30 分钟测试复盘,记录缺陷、补齐用例。
|
||||||
11
package.json
11
package.json
@@ -10,7 +10,9 @@
|
|||||||
"build:renderer": "vite build",
|
"build:renderer": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\""
|
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest watch"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Jason Young",
|
"author": "Jason Young",
|
||||||
@@ -21,9 +23,14 @@
|
|||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.0.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0",
|
||||||
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
|
|||||||
950
pnpm-lock.yaml
generated
950
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
169
tests/hooks/useDragSort.test.tsx
Normal file
169
tests/hooks/useDragSort.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
|
||||||
|
import type { Provider } from "@/types";
|
||||||
|
import { useDragSort } from "@/hooks/useDragSort";
|
||||||
|
|
||||||
|
const updateSortOrderMock = vi.fn();
|
||||||
|
const toastSuccessMock = vi.fn();
|
||||||
|
const toastErrorMock = vi.fn();
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||||
|
error: (...args: unknown[]) => toastErrorMock(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
providersApi: {
|
||||||
|
updateSortOrder: (...args: unknown[]) => updateSortOrderMock(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface WrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
const wrapper = ({ children }: WrapperProps) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { wrapper, queryClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockProviders: Record<string, Provider> = {
|
||||||
|
a: {
|
||||||
|
id: "a",
|
||||||
|
name: "AAA",
|
||||||
|
settingsConfig: {},
|
||||||
|
sortIndex: 1,
|
||||||
|
createdAt: 5,
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
id: "b",
|
||||||
|
name: "BBB",
|
||||||
|
settingsConfig: {},
|
||||||
|
sortIndex: 0,
|
||||||
|
createdAt: 10,
|
||||||
|
},
|
||||||
|
c: {
|
||||||
|
id: "c",
|
||||||
|
name: "CCC",
|
||||||
|
settingsConfig: {},
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useDragSort", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
updateSortOrderMock.mockReset();
|
||||||
|
toastSuccessMock.mockReset();
|
||||||
|
toastErrorMock.mockReset();
|
||||||
|
consoleErrorSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should sort providers by sortIndex, createdAt, and name", () => {
|
||||||
|
const { wrapper } = createWrapper();
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useDragSort(mockProviders, "claude"),
|
||||||
|
{
|
||||||
|
wrapper,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.sortedProviders.map((item) => item.id)).toEqual([
|
||||||
|
"b",
|
||||||
|
"a",
|
||||||
|
"c",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call API and invalidate query cache after successful drag", async () => {
|
||||||
|
updateSortOrderMock.mockResolvedValue(true);
|
||||||
|
const { wrapper, queryClient } = createWrapper();
|
||||||
|
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useDragSort(mockProviders, "claude"),
|
||||||
|
{
|
||||||
|
wrapper,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDragEnd({
|
||||||
|
active: { id: "b" },
|
||||||
|
over: { id: "a" },
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateSortOrderMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateSortOrderMock).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
{ id: "a", sortIndex: 0 },
|
||||||
|
{ id: "b", sortIndex: 1 },
|
||||||
|
{ id: "c", sortIndex: 2 },
|
||||||
|
],
|
||||||
|
"claude",
|
||||||
|
);
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ["providers", "claude"],
|
||||||
|
});
|
||||||
|
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error toast when drag operation fails", async () => {
|
||||||
|
updateSortOrderMock.mockRejectedValue(new Error("network"));
|
||||||
|
const { wrapper } = createWrapper();
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useDragSort(mockProviders, "claude"),
|
||||||
|
{
|
||||||
|
wrapper,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDragEnd({
|
||||||
|
active: { id: "b" },
|
||||||
|
over: { id: "a" },
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toastSuccessMock).not.toHaveBeenCalled();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not trigger API call when there is no valid target", async () => {
|
||||||
|
const { wrapper } = createWrapper();
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useDragSort(mockProviders, "claude"),
|
||||||
|
{
|
||||||
|
wrapper,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleDragEnd({
|
||||||
|
active: { id: "b" },
|
||||||
|
over: null,
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateSortOrderMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
tests/setupTests.ts
Normal file
23
tests/setupTests.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { afterEach, beforeAll } from "vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await i18n.use(initReactI18next).init({
|
||||||
|
lng: "zh",
|
||||||
|
fallbackLng: "zh",
|
||||||
|
resources: {
|
||||||
|
zh: { translation: {} },
|
||||||
|
en: { translation: {} },
|
||||||
|
},
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -19,6 +19,6 @@
|
|||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "tests/**/*"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["vite.config.mts"]
|
"include": ["vite.config.mts", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
20
vitest.config.ts
Normal file
20
vitest.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./tests/setupTests.ts"],
|
||||||
|
globals: true,
|
||||||
|
coverage: {
|
||||||
|
reporter: ["text", "lcov"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user