测行为,不测实现
Vue 组件测试 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这一节是测试的价值观:为什么「测行为」的测试能活过重构,「测实现」的测试会在每次改动后报废。掌握用可访问性查询(role/label)定位元素、用用户视角写断言、以及在测试失败时判断「是 bug 还是测试太脆」。
问题场景
一个按钮组件的测试断言 wrapper.find(".btn-primary").exists()。产品要求改主色,class 改成 .btn-brand——20 个测试全红,功能其实没变。另一组测试断言「点击后调用了 store.add」,后来组件改成先校验再调用,测试红了一片,但用户可见行为完全正确。测试写成了「实现说明书」,而不是「行为契约」。
核心解释
用用户视角定位元素。优先级:可见文本 > 语义角色(role/label)> 测试 id > class/结构。Vue Test Utils 支持:
// 推荐:按可访问名称找(与屏幕阅读器看到的一致)
wrapper.get("button[aria-label='删除']");
wrapper.get('[role="dialog"]');
wrapper.get("input[placeholder='搜索课程']");
// 不推荐:依赖样式类与 DOM 结构
wrapper.get(".btn-primary > span:nth-child(2)");
按 role/label 定位还有一个副作用:它强迫组件具备基本的可访问性,测试与体验双赢。
断言「用户看到什么」,而不是「内部发生了什么」:
// 测实现:绑定了某个 store 方法
expect(store.add).toHaveBeenCalled();
// 测行为:界面上出现了一件商品
expect(wrapper.text()).toContain("购物车 1 件");
后者在「组件内部改成先校验再调用」后依然通过——契约没变。
什么时候可以断言实现:子组件的 props/emits 契约(那是对外 API)、对外暴露的 store action(业务契约)。内部私有函数、内部 ref 名称不算契约。
测试命名即规格:it("勾选后已完成计数变为 1") 比 it("test toggle") 有用得多。失败时从名字就能判断是哪个行为坏了。推荐中文命名——这个仓库的课程和注释都以中文为主。
脆弱测试的三个信号:改样式就红;改内部函数名就红;失败信息说不清「用户看到了什么错」。出现任一信号,就该重写断言。
覆盖率的位置:覆盖率是「有没有测到」的指标,不是「测得对不对」的指标。100% 覆盖率的实现测试依然可能漏掉真实 bug。优先保证核心流程(下单、登录、提交)的行为测试,而不是追求数字。
示例拆解
同一段需求,两种测试写法的对比:
<!-- 待测组件:搜索框 + 结果列表 -->
<script setup>
import { computed, ref, watch } from "vue";
const keyword = ref("");
const results = ref([]);
watch(keyword, async (q) => {
if (!q) {
results.value = [];
return;
}
const res = await fetch(`/api/courses?q=${encodeURIComponent(q)}`);
results.value = await res.json();
});
</script>
<template>
<div>
<input v-model="keyword" placeholder="搜索课程" />
<p v-if="!keyword">输入关键词开始搜索</p>
<ul v-else>
<li v-for="r in results" :key="r.id">{{ r.title }}</li>
</ul>
</div>
</template>
// 实现测试(脆弱):断言内部 watch 的行为、mock 调用细节
// 行为测试(推荐):输入 → 等待 → 断言用户看到的结果
import { flushPromises, mount } from "@vue/test-utils";
import CourseSearch from "../src/components/CourseSearch.vue";
it("输入关键词后展示搜索结果", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: true,
json: async () => [{ id: 1, title: "Vue 3 全景" }],
})));
const wrapper = mount(CourseSearch);
expect(wrapper.text()).toContain("输入关键词开始搜索");
await wrapper.get("input[placeholder='搜索课程']").setValue("vue");
await flushPromises();
expect(wrapper.text()).toContain("Vue 3 全景");
expect(wrapper.text()).not.toContain("输入关键词开始搜索");
});
it("清空关键词后回到空状态", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => [] })));
const wrapper = mount(CourseSearch);
await wrapper.get("input[placeholder='搜索课程']").setValue("vue");
await flushPromises();
await wrapper.get("input[placeholder='搜索课程']").setValue("");
expect(wrapper.text()).toContain("输入关键词开始搜索");
});
这两个测试不关心组件内部用 watch 还是 watchEffect、用 ref 还是 shallowRef——重构内部实现,测试不动。它们关心的是「用户输入后看到什么」,这就是契约。
动手任务
- 给一个组件写两条「行为测试」,再故意把内部实现重写一遍(比如 watch 改成 watchEffect),确认测试仍然通过。
- 找一个依赖 class 选择器的测试,改成按 role/placeholder 定位。
- 给一个失败测试写清楚的名字和断言信息,让只看失败输出就能知道「哪个用户行为坏了」。
常见错误
- 按 class 定位元素:样式一改测试全红。用 role/label/placeholder。
- 断言内部函数被调用:实现一变就红。断言用户可见结果。
- 测试名字是 test1/test2:失败时无法定位。用行为描述命名。
- 为了覆盖率测无意义的 getter:占时间、维护成本高、拦不住 bug。把时间花在核心流程。
检查与复盘
- 能说出「行为测试」与「实现测试」的判断标准
- 能按 role/label/placeholder 定位元素写断言
- 能识别脆弱测试的三个信号并重写
参考资料
- Vue 官方文档「测试」:测试类型与取舍
- Testing Library 官方文档:查询优先级(可迁移到 VTU)
读完这一课了?
标记完成后,工作台会安排接下来的学习。