文档简介: 用 Vitest 覆盖 Query 项目的纯逻辑单元:fetch 封装、Query Key 工厂、乐观更新器函数——不挂载 React,跑得飞快。
目标读者: 需要为 Query 项目建立测试底座的开发者
前置知识: Todo App、TypeScript 基础
文档信息(用途、难度与维护记录)
| 属性 | 内容 |
|---|---|
| 模块 | 03-tanstack-stack |
| 象限 | 操作指南 |
| 难度 | ⭐⭐ |
| 标签 | #vitest #单元测试 #tanstack-query |
| 更新日期 | 2026年9月 |
npm install -D vitest @vitest/coverage-v8 @vitejs/plugin-react@vitest/coverage-v8 只在收集覆盖率时使用,应与 Vitest 使用匹配版本并提交锁文件;详见 Vitest 覆盖率文档。覆盖率阈值是项目选择,80% 不代表行为正确,必须有能发现错误实现的边界断言。
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'node:path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'node', // 纯逻辑用 node;渲染测试文件再切 jsdom(见集成测试)
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
reporter: ['text', 'lcov'],
include: ['src/api/**', 'src/hooks/**', 'src/shared/**'],
thresholds: { lines: 80 }, // 数据层是核心,阈值设高些
},
},
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
}){ "scripts": { "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" } }// src/api/todos.test.ts
import { describe, it, expect, vi, afterEach } from 'vitest'
import { todoApi } from './todos'
describe('todoApi', () => {
afterEach(() => vi.unstubAllGlobals())
it('list:正常返回解析后的 JSON', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify([{ id: 1, title: 'a', done: false }]), { status: 200 }),
))
await expect(todoApi.list()).resolves.toHaveLength(1)
})
it('list:非 2xx 抛出带状态码的错误', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 500 })))
await expect(todoApi.list()).rejects.toThrow('请求失败 500')
})
it('remove:204 无响应体也是成功', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 204 })))
await expect(todoApi.remove(1)).resolves.toBeUndefined()
expect(fetch).toHaveBeenCalledWith('/api/todos/1', { method: 'DELETE' })
})
})// src/api/keys.test.ts
import { describe, it, expect } from 'vitest'
import { todoKeys } from './todos'
describe('todoKeys', () => {
it('detail 键必须以 details 为前缀(前缀失效依赖此结构)', () => {
expect(todoKeys.detail(7)).toEqual(['todos', 'detail', 7])
expect(todoKeys.detail(7).slice(0, 2)).toEqual(todoKeys.details().slice(0, 2))
})
it('all 是一切键的最长公共前缀', () => {
expect(todoKeys.lists().slice(0, 1)).toEqual(todoKeys.all)
expect(todoKeys.detail(1).slice(0, 1)).toEqual(todoKeys.all)
})
})把 setQueryData 的 updater 写成独立纯函数,就能脱离 React 单测:
// src/features/board/optimistic.ts
import type { Board } from '../../api/board'
export function applyMove(board: Board, cardId: string, toColumn: string, index: number): Board {
const card = board.cards[cardId]
const target = board.columns.find((col) => col.id === toColumn)
if (!card || !target) return board
// index 指先移除卡片后的目标列插入位置,可等于目标列长度。
const size = target.cardIds.filter((id) => id !== cardId).length
if (!Number.isInteger(index) || index < 0 || index > size) return board
const columns = board.columns.map((col) => {
const ids = col.cardIds.filter((id) => id !== cardId)
if (col.id === toColumn) ids.splice(index, 0, cardId)
return { ...col, cardIds: ids }
})
return { ...board, columns, cards: { ...board.cards, [cardId]: { ...card, columnId: toColumn } } }
}// src/features/board/optimistic.test.ts
import { describe, it, expect } from 'vitest'
import { applyMove } from './optimistic'
const board = {
columns: [
{ id: 'c1', title: '待办', cardIds: ['a', 'b'] },
{ id: 'c2', title: '进行中', cardIds: [] },
],
cards: {
a: { id: 'a', columnId: 'c1', title: 'A', version: 1 },
b: { id: 'b', columnId: 'c1', title: 'B', version: 1 },
},
}
describe('applyMove', () => {
it('跨列移动:源列移除、目标列按 index 插入', () => {
const next = applyMove(board, 'a', 'c2', 0)
expect(next.columns[0].cardIds).toEqual(['b'])
expect(next.columns[1].cardIds).toEqual(['a'])
})
it('卡片不存在时返回原对象(不可变约定)', () => {
expect(applyMove(board, 'ghost', 'c2', 0)).toBe(board)
})
it('目标不存在或位置非法时不丢卡片', () => {
expect(applyMove(board, 'a', 'missing', 0)).toBe(board)
for (const index of [-1, 0.5, NaN, 1]) {
expect(applyMove(board, 'a', 'c2', index)).toBe(board)
}
})
it('同列重排以移除后的列表为准,且不修改输入', () => {
const before = structuredClone(board)
const next = applyMove(board, 'a', 'c1', 1)
expect(next.columns[0].cardIds).toEqual(['b', 'a'])
expect(next.cards.a.columnId).toBe('c1')
expect(board).toEqual(before)
expect(next.cards.b).toBe(board.cards.b)
})
})Board 类型来自看板项目的 src/api/board.ts。此函数只负责本地变换,不解决网络并发与回滚。未知目标必须先检查,否则先从源列删除后无法插入目标列,会使卡片消失。用 Object.freeze 或深冻结夹具还可以进一步检测输入突变。
| 层 | 工具 | 覆盖 | 本文范围 |
|---|---|---|---|
| 单元 | Vitest + vi.stubGlobal | 纯函数、错误分支 | ✅ |
| 服务模拟 | MSW | hook 与真实 HTTP 语义 | → 02 |
| 集成 | Testing Library | hook + 组件 + 缓存协作 | → 03 |
| 端到端 | Playwright | 真实浏览器全链路 | → 04 |
将乐观更新的数据变换提成纯函数,可以直接验证空列表、目标不存在和重复修改。需要验证 Hook 与缓存交互时,应使用独立 QueryClient 和 Provider;测试类别取决于验证边界,不存在“单元测试绝不能挂 Provider”的规则。
保留 Query 的真实行为,优先替换网络边界,并控制重试和时间以获得可预测反馈。至少验证首次失败、重试成功、后台失败保留已有数据,避免只断言 mock 被调用过。
- 📄 Mock 服务:MSW - 下一层:带真实 HTTP 语义的测试
- 📄 集成测试 - renderHook 与组件级验证
- 📄 入门项目 Todo App - 被测代码来源
- 📄 Query 进阶 - 乐观更新器逻辑出处
- 📄 CI/CD 流水线 - 把本层测试挂进流水线
选择项目中的 queryKey 工厂和分页解析函数:同一资源的相同参数产生等价键,不同账号或分页不得误共用;非法页码回到约定默认值或明确拒绝。把账号字段故意从键里删除,至少一个隔离测试应失败。验收:恢复后测试通过,改变执行顺序仍通过;这组纯函数测试不需要真实网络,也不能证明 Provider 和界面订阅正确。
以上是在个人或隔离测试环境中的练习,不是本轮已执行记录;实际运行范围见仓库文档质量报告。