给 composable 写测试
自定义 Composables · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
Composable 是纯逻辑,比组件更容易测试。这一节掌握用 Vitest 测试 composable 的两种方式(在组件上下文中运行、脱离组件直接测试纯 ref 逻辑)、mock fetch 的技巧,以及「哪些行为值得测」的判断。
问题场景
useFetch 写完,手工点页面验证通过。三个月后你给它加了「请求序号」,回归时忘了测卸载场景——上线后切换页面偶发报错。composable 是全应用复用的逻辑,回归成本最高,最该有测试。
核心解释
测试环境准备:Vitest + jsdom(environment: "jsdom")。两种测试策略:
策略一:纯逻辑 composable 直接测。不依赖生命周期钩子的 composable(useToggle、useCounter、useLocalStorage 的读写部分)可以在测试里直接调用,断言返回的 ref:
// tests/useToggle.test.js
import { describe, expect, it } from "vitest";
import { useToggle } from "../src/composables/useToggle";
describe("useToggle", () => {
it("初始值为 false,toggle 后变 true", () => {
const { value, toggle } = useToggle();
expect(value.value).toBe(false);
toggle();
expect(value.value).toBe(true);
});
it("set 可以指定目标值", () => {
const { value, set } = useToggle();
set(true);
expect(value.value).toBe(true);
set(true);
expect(value.value).toBe(true);
});
});
策略二:依赖生命周期的 composable 放进宿主组件测。useFetch 用了 onUnmounted,脱离组件会警告。用一个最小组件承载它:
// tests/useFetch.test.js
import { describe, expect, it, vi, beforeEach } from "vitest";
import { defineComponent, h, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { useFetch } from "../src/composables/useFetch";
function mountComposable(composable) {
let exposed;
const Host = defineComponent({
setup() {
exposed = composable();
return () => h("div");
},
});
const wrapper = mount(Host);
return { exposed, wrapper };
}
describe("useFetch", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ title: "Vue" }) })));
});
it("成功后 data 有值、loading 归零", async () => {
const { exposed } = mountComposable(() => useFetch("/api/course"));
expect(exposed.loading.value).toBe(true);
await vi.waitFor(() => expect(exposed.loading.value).toBe(false));
expect(exposed.data.value).toEqual({ title: "Vue" });
expect(exposed.error.value).toBeNull();
});
it("HTTP 失败时 error 有信息", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 500 })));
const { exposed } = mountComposable(() => useFetch("/api/course"));
await vi.waitFor(() => expect(exposed.error.value).toBeTruthy());
expect(exposed.error.value).toContain("500");
});
});
要点:mount 提供组件上下文(生命周期钩子可用);vi.stubGlobal 替换 fetch,测试不依赖真实网络;vi.waitFor 等待异步状态稳定,比固定 setTimeout 可靠。
测什么、不测什么:测「对外可观察的行为」——成功给数据、失败给错误、卸载取消请求、URL 变化重取;不测内部实现(用了几个 ref、是否用 watchEffect)。测试应该在你重构实现后依然通过。
卸载场景的测法:wrapper.unmount() 后断言 fetch 收到的 signal.aborted 为 true:
it("卸载时取消在途请求", async () => {
let capturedSignal;
vi.stubGlobal(
"fetch",
vi.fn((url, opts) => {
capturedSignal = opts.signal;
return new Promise(() => {}); // 永不返回,模拟在途
})
);
const { wrapper } = mountComposable(() => useFetch("/api/slow"));
wrapper.unmount();
expect(capturedSignal.aborted).toBe(true);
});
示例拆解
一个「防抖搜索」composable 的完整测试,覆盖三件事:防抖只发一次、过期结果不覆盖、空关键词不发请求:
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { defineComponent, h } from "vue";
import { mount } from "@vue/test-utils";
import { ref } from "vue";
import { useDebouncedSearch } from "../src/composables/useDebouncedSearch";
describe("useDebouncedSearch", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("连续输入只在停顿后发一次请求", async () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => [] }));
vi.stubGlobal("fetch", fetchMock);
const keyword = ref("");
const { results } = mountComposable(() => useDebouncedSearch(keyword, 300));
keyword.value = "v";
keyword.value = "vu";
keyword.value = "vue";
await vi.advanceTimersByTimeAsync(300);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain("q=vue");
});
});
假定时器让「等 300ms」变成瞬时,测试稳定且快。注意用 advanceTimersByTimeAsync 而不是同步版,因为它要等内部的 Promise 微任务。
动手任务
- 给
useToggle写三个测试(初始值、toggle、set 幂等),跑npx vitest run确认通过。 - 给
useFetch写「成功 / HTTP 错误 / 卸载取消」三个测试。 - 故意改坏
useFetch(比如删掉 AbortError 判断),确认测试能抓到回归。
常见错误
- 直接调用依赖生命周期的 composable:控制台警告且钩子不执行。用宿主组件包一层。
- 用固定
setTimeout等异步:机器快慢导致偶发失败。用vi.waitFor或假定时器。 - mock 整个 fetch 模块而不是全局函数:测试和实现耦合。
vi.stubGlobal("fetch")更稳定。 - 测内部实现:断言「调用了 watchEffect」这类,重构就红。测行为。
检查与复盘
- 能区分「纯逻辑」和「依赖生命周期」两类 composable 的测试写法
- 能用假定时器测防抖,用 vi.waitFor 测异步状态
- 能写出「卸载取消请求」的断言
参考资料
- Vitest 官方文档:假定时器与 waitFor
- Vue Test Utils 官方文档:mount 与 unmount
- Vue 官方文档「组合式函数」:可测试性设计
读完这一课了?
标记完成后,工作台会安排接下来的学习。