资讯详情

资讯详情

Pinia 异步 action 单测:loading 状态与错误捕获

Pinia 异步 action 单测loading 状态与错误捕获在 Vue 3 应用的业务逻辑架构中Pinia 异步 ActionAsync Actions是承载复杂业务状态流转的核心枢纽它负责在发起网络请求前将isLoading置为true并在请求结束无论成功还是失败的finally阶段将其复位为false它负责拦截后端的各种业务错误码如401 Unauthorized、403 Forbidden、500 Internal Server Error、业务业务自定义CODE_1002 余额不足并将其规范化后写入 Store 的error状态它在用户快速重复触发时需要具备防重入锁或请求取消能力。然而在给这些异步 Action 编写单元测试时很多工程师由于缺乏对JavaScript 微任务调度Microtask Queue、Promise 时序控制、以及异常断言机制的深入理解经常写出极其脆弱的测试用例测试还没等异步请求真正执行完毕就提前去断言store.items导致断言失败无法精准测试“处于请求 Pending 过程中的中间 Loading 态”测试在断言异常抛出时由于未正确处理 Promise Rejection导致整个 Vitest 进程抛出未捕获错误而崩溃。本文将深度拆解在 Vitest 中编写Pinia 异步 Action 单元测试的核心范式与断言技巧。异步 Action 单元测试的时序控制拓扑[触发 Store 异步 Action: store.fetchUserList()] │ ▼ [阶段 1: 验证 Pending 中间态 (In-flight State)] ├── 1. 此时异步 Promise 刚刚挂起 ├── 2. 断言: store.isLoading true └── 3. 断言: store.error null │ ▼ (控制 Mock Promise 的 Resolve / Reject 时机) [阶段 2: 验证 Resolved 成功态] [阶段 3: 验证 Rejected 异常态] ├── await store.fetchUserList() ├── await expect(store.fetchUserList()).rejects.toThrow() ├── 断言: store.isLoading false ├── 断言: store.isLoading false (finally 必须复位!) ├── 断言: store.userList 预期数据 └── 断言: store.error 格式化错误对象 └── 断言: store.error null待测试的典型高健壮性 StoreuseUserManagementStore.ts// src/stores/userManagement.ts import { defineStore } from pinia; import { ref } from vue; export interface User { id: string; name: string; role: string; } export const useUserManagementStore defineStore(userManagement, () { const users refUser[]([]); const isLoading ref(false); const error refstring | null(null); // 异步 Action: 拉取用户列表 (依赖注入 apiClient) const fetchUsers async (apiClient: (params?: any) PromiseUser[]) { // 防重入如果当前正在请求中直接跳过 if (isLoading.value) return; isLoading.value true; error.value null; try { const data await apiClient(); users.value data; } catch (err: any) { const errorMessage err?.message || 网络请求失败请稍后重试; error.value errorMessage; // 将错误向外抛出方便调用端捕获或由全局 ErrorBoundary 接管 throw new Error(errorMessage); } finally { // 核心质量底线无论成功失败必须复位 loading isLoading.value false; } }; return { users, isLoading, error, fetchUsers }; });编写严密的 Vitest 单元测试实战// src/stores/__tests__/userManagement.spec.ts import { describe, it, expect, beforeEach, vi } from vitest; import { setActivePinia, createPinia } from pinia; import { useUserManagementStore, User } from ../userManagement; describe(useUserManagementStore 异步 Action 专项单测, () { beforeEach(() { setActivePinia(createPinia()); }); // // 1. 测试成功路径与 Pending 中间态精准捕获 // it(拉取用户成功时应准确经历 pending 状态并在成功后更新 users 数组, async () { const store useUserManagementStore(); // 核心手艺构造一个可受控延迟 Resolve 的 Promise用来断言中间 loading 态 let resolvePromise: (data: User[]) void; const pendingPromise new PromiseUser[]((resolve) { resolvePromise resolve; }); const mockApiClient vi.fn().mockReturnValue(pendingPromise); // 1. 触发异步 Action (此时不加 await让 Promise 处于进行中) const actionPromise store.fetchUsers(mockApiClient); // 2. 精准断言请求正在飞行中的状态 expect(store.isLoading).toBe(true); expect(store.error).toBeNull(); expect(store.users).toEqual([]); // 3. 手动触发 Promise resolve 模拟网络数据返回 const fakeUsers: User[] [ { id: U1, name: Tan Rui, role: Architect }, { id: U2, name: Alice, role: Developer }, ]; resolvePromise!(fakeUsers); // 4. 等待 Action 彻底执行结束 await actionPromise; // 5. 断言成功完成后的最终状态 expect(store.isLoading).toBe(false); expect(store.error).toBeNull(); expect(store.users).toEqual(fakeUsers); expect(mockApiClient).toHaveBeenCalledTimes(1); }); // // 2. 测试网络异常、错误捕获与 finally 状态自愈 // it(当网络接口抛出 500 异常时应准确捕获错误信息并将 isLoading 严格复位为 false, async () { const store useUserManagementStore(); // Mock API 抛出网络异常 const mockApiClient vi.fn().mockRejectedValue(new Error(服务端数据库连接超时 (500))); // 1. 断言 Action 返回的 Promise 必须被 reject且抛出正确的错误文案 await expect(store.fetchUsers(mockApiClient)).rejects.toThrow(服务端数据库连接超时 (500)); // 2. 核心质量断言错误发生后Store 的 error 状态必须被正确赋值 expect(store.error).toBe(服务端数据库连接超时 (500)); // 3. 核心质量断言即使发生严重异常isLoading 必须在 finally 中 100% 恢复为 false // 杜绝按钮永久处于 loading 禁用的死锁 Bug expect(store.isLoading).toBe(false); expect(store.users).toEqual([]); }); // // 3. 测试防重入锁机制Concurrency / Re-entrancy Protection // it(当请求正在进行中时重复调用 fetchUsers 应被安全拦截严禁发起重复请求, async () { const store useUserManagementStore(); let resolvePromise: (data: User[]) void; const pendingPromise new PromiseUser[]((resolve) { resolvePromise resolve; }); const mockApiClient vi.fn().mockReturnValue(pendingPromise); // 第一次调用 const firstCall store.fetchUsers(mockApiClient); expect(store.isLoading).toBe(true); // 紧接着在第一次尚未返回时发起第二次并发调用 const secondCall store.fetchUsers(mockApiClient); // 断言 mockApiClient 依然只被调用了 1 次 expect(mockApiClient).toHaveBeenCalledTimes(1); // 释放 Promise resolvePromise!([]); await Promise.all([firstCall, secondCall]); expect(store.isLoading).toBe(false); }); });异步 Action 单测三大避坑守则测试 Loading 中间态必须使用“受控 Promise”严禁在测试里写setTimeout盲猜时序通过new Promise(resolve { resolvePromise resolve })手动控制数据返回时机是断言isLoading true的唯一确定性方案异常断言必须使用await expect(...).rejects.toThrow()如果不写await或直接在普通try-catch里测试一旦 Action 内部没有抛出错误测试反而会静默绿灯通过产生致命的“假阳性测试”永远验证finally状态复位在任何涉及 Loading 或锁标志的测试中发生异常后必须显式断言expect(store.isLoading).toBe(false)守住前端状态机最基础的容灾底线。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →