Skip to content

01|组件测试 ​

第九章 / 01。目标:测试组件做了什么,而不是它用哪个 ref、method 或 CSS class 做到。

先完成 测试运行环境。本节文件都放在该项目 src/ 下,运行 npx vitest run src/SaveForm.spec.ts。先有完整被测组件,再讨论怎样控制它的输入。

被测组件:保存的四个状态 ​

新建 src/SaveForm.vue:

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 会把测试绑定到实现。

ts
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 掉中间态。

失败断言必须排除成功 ​

ts
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 题。

用心记录每一步成长