01|组件测试
第九章 / 01。目标:测试组件做了什么,而不是它用哪个 ref、method 或 CSS class 做到。
先完成 测试运行环境。本节文件都放在该项目 src/ 下,运行 npx vitest run src/SaveForm.spec.ts。先有完整被测组件,再讨论怎样控制它的输入。
被测组件:保存的四个状态
新建 src/SaveForm.vue:
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ save: (name: string) => Promise<void> }>()
const name = ref('')
const status = ref<'idle' | 'saving' | 'saved'>('idle')
const error = ref('')
async function submit() {
if (status.value === 'saving') return
const value = name.value.trim()
error.value = ''
if (!value) { error.value = '请输入名称'; return }
status.value = 'saving'
try { await props.save(value); status.value = 'saved' }
catch { status.value = 'idle'; error.value = '保存失败,请重试' }
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="name" aria-label="名称">
<button :disabled="status === 'saving'">保存</button>
<p role="status">{{ status === 'saving' ? '保存中' : status === 'saved' ? '已保存' : '未保存' }}</p>
<p v-if="error" role="alert">{{ error }}</p>
</form>
</template>同步校验失败不调用请求;有效提交先写 saving 再等待;成功与失败分别决定最终 DOM。按钮禁用只是交互提示,函数中的检查才防止程序再次派发 submit 导致并发保存。下一段保存为 src/SaveForm.spec.ts。
公开合同决定断言
组件的输入是 props、slots、provide 和用户事件;输出是 DOM 语义、焦点、emits 与可观察副作用。优先通过 label/role/可见文本操作,断言私有 wrapper.vm 会把测试绑定到实现。
import { flushPromises, mount } from '@vue/test-utils'
import { expect, test, vi } from 'vitest'
import SaveForm from './SaveForm.vue'
test('从等待到成功', async () => {
let resolve!: () => void
const save = vi.fn(() => new Promise<void>(ok => { resolve = ok }))
const wrapper = mount(SaveForm, { props: { save } })
await wrapper.get('input[aria-label="名称"]').setValue(' Vue ')
await wrapper.get('form').trigger('submit')
expect(wrapper.get('[role=status]').text()).toBe('保存中')
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
resolve()
await flushPromises()
expect(wrapper.get('[role=status]').text()).toBe('已保存')
expect(save).toHaveBeenCalledWith('Vue')
})setValue 和 trigger 返回可等待的 Vue 更新;flushPromises 解决已挂起的非 Vue Promise。两者回答不同问题。这里先断言 loading,再 resolve,避免一步 flush 掉中间态。
失败断言必须排除成功
test('有效输入的请求失败不会误报成功', async () => {
const expected = new Error('offline')
const save = vi.fn().mockRejectedValue(expected)
const wrapper = mount(SaveForm, { props: { save } })
await wrapper.get('input').setValue('Vue')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(save).toHaveBeenCalledOnce()
expect(wrapper.get('[role=alert]').text()).toContain('保存失败')
expect(wrapper.text()).not.toContain('已保存')
})若 production code 错误地吞掉异常并显示成功,这个用例必须变红。快照可辅助观察稳定结构,但不能替代事件参数、错误分支和焦点合同。
组件测试不必一律 stub 子组件。公共交互通常连真实子组件一起测;路由、网络或昂贵平台依赖可注入替身,但替身要保持真实接口形状。
练习:可取消搜索组件
在同一项目实现并测试 src/SearchBox.vue。props 为 search(query: string, signal: AbortSignal): Promise<string[]>;模板包含一个带 aria-label="查询" 的输入、提交表单和结果 ul > li。测试 SearchBox:空白提交不调用 search 并显示 alert;有效查询 trim 后调用一次;第二次提交会 abort 第一次请求;只有最新结果显示。使用两个可控 Promise 和 AbortSignal,不得 sleep;断言旧请求即使最后 resolve 也不能覆盖新结果。
参考答案:第 9 章第 01 题。