09|测试、工具与工程实践练习答案
第九章 / 参考答案。每个异步断言都写明在等 Vue、Promise、响应还是浏览器状态。
01|可取消搜索组件
保存为 src/SearchBox.vue。取消表示请求尽力停止;序号则保证无法取消的旧 Promise 也不能覆盖新结果。这是两条独立保护。
<script setup lang="ts">
import { onUnmounted, ref } from 'vue'
const props = defineProps<{ search: (query: string, signal: AbortSignal) => Promise<string[]> }>()
const query = ref('')
const results = ref<string[]>([])
const error = ref('')
const pending = ref(false)
let controller: AbortController | undefined
let revision = 0
async function submit() {
const value = query.value.trim()
if (!value) { error.value = '请输入查询'; return }
controller?.abort()
const current = ++revision
controller = new AbortController()
pending.value = true
error.value = ''
results.value = []
try {
const rows = await props.search(value, controller.signal)
if (current === revision) results.value = rows
} catch {
if (current === revision) error.value = '搜索失败'
} finally {
if (current === revision) pending.value = false
}
}
onUnmounted(() => { revision++; controller?.abort() })
</script>
<template>
<form @submit.prevent="submit">
<input v-model="query" aria-label="查询">
<button>搜索</button>
</form>
<p v-if="pending" role="status">搜索中</p>
<p v-if="error" role="alert">{{ error }}</p>
<ul><li v-for="(result, i) in results" :key="i">{{ result }}</li></ul>
</template>按钮没有在 pending 时禁用,因为合同允许第二次搜索替换第一次。保存为 src/SearchBox.spec.ts,使用 环境页 的 deferred helper:
import { flushPromises, mount } from '@vue/test-utils'
import { expect, test, vi } from 'vitest'
import SearchBox from './SearchBox.vue'
import { deferred } from './test/deferred'
test('拒绝空白、取消旧请求、只显示最新结果', async () => {
const old = deferred<string[]>()
const latest = deferred<string[]>()
const search = vi.fn<(query: string, signal: AbortSignal) => Promise<string[]>>()
.mockReturnValueOnce(old.promise).mockReturnValueOnce(latest.promise)
const wrapper = mount(SearchBox, { props: { search } })
await wrapper.get('input').setValue(' ')
await wrapper.get('form').trigger('submit')
expect(search).not.toHaveBeenCalled()
expect(wrapper.get('[role=alert]').text()).toContain('请输入查询')
await wrapper.get('input').setValue(' old ')
await wrapper.get('form').trigger('submit')
const signal = search.mock.calls[0]![1]
expect(search.mock.calls[0]![0]).toBe('old')
expect(signal.aborted).toBe(false)
await wrapper.get('input').setValue('new')
await wrapper.get('form').trigger('submit')
expect(signal.aborted).toBe(true)
expect(search).toHaveBeenCalledTimes(2)
latest.resolve(['new result'])
await flushPromises()
expect(wrapper.findAll('li').map(x => x.text())).toEqual(['new result'])
old.resolve(['stale result'])
await flushPromises()
expect(wrapper.findAll('li').map(x => x.text())).toEqual(['new result'])
expect(wrapper.find('[role=status]').exists()).toBe(false)
})
test('最新请求失败与卸载清理', async () => {
const gate = deferred<string[]>()
const search = vi.fn((_query: string, _signal: AbortSignal) => gate.promise)
const wrapper = mount(SearchBox, { props: { search } })
await wrapper.get('input').setValue('vue')
await wrapper.get('form').trigger('submit')
gate.reject(new Error('offline'))
await flushPromises()
expect(wrapper.get('[role=alert]').text()).toBe('搜索失败')
expect(wrapper.findAll('li')).toHaveLength(0)
const signal = search.mock.calls[0]![1]
wrapper.unmount()
expect(signal.aborted).toBe(true)
})运行 npx vitest run src/SearchBox.spec.ts。移除 current === revision 的成功保护后,第一题的最后一次结果断言应失败,证明测试没有只覆盖顺利完成顺序。
02|可暂停轮询
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
export function usePolling(load: () => Promise<void>, intervalMs: number) {
if (!Number.isFinite(intervalMs) || !(intervalMs > 0)) throw new RangeError('intervalMs must be positive')
const active = ref(true)
const lastError = shallowRef<unknown>(null)
let timer: ReturnType<typeof setInterval> | undefined
let inFlight = false
async function tick() {
if (!active.value || inFlight) return
inFlight = true
try { await load(); lastError.value = null }
catch (error) { lastError.value = error }
finally { inFlight = false }
}
function pause() {
active.value = false
if (timer) clearInterval(timer)
timer = undefined
}
function resume() {
if (active.value && timer) return
active.value = true
timer = setInterval(tick, intervalMs)
}
onMounted(resume)
onUnmounted(pause)
return { active, pause, resume, lastError }
}把函数保存为 src/usePolling.ts,正文的 withSetup 保存为 src/test/withSetup.ts。以下 src/usePolling.spec.ts 逐步推进时间;每次断言后仍由环境页自动恢复真实计时器。
import { expect, test, vi } from 'vitest'
import { usePolling } from './usePolling'
import { withSetup } from './test/withSetup'
import { deferred } from './test/deferred'
test('间隔校验、计时与暂停恢复', async () => {
expect(() => usePolling(async () => {}, 0)).toThrow(RangeError)
vi.useFakeTimers()
const load = vi.fn().mockResolvedValue(undefined)
const { result, unmount } = withSetup(() => usePolling(load, 1000))
try {
await vi.advanceTimersByTimeAsync(3000)
expect(load).toHaveBeenCalledTimes(3)
result.pause()
await vi.advanceTimersByTimeAsync(2000)
expect(load).toHaveBeenCalledTimes(3)
result.resume(); result.resume()
await vi.advanceTimersByTimeAsync(1000)
expect(load).toHaveBeenCalledTimes(4)
unmount()
await vi.advanceTimersByTimeAsync(2000)
expect(load).toHaveBeenCalledTimes(4)
} finally { unmount() }
})
test('pending 不重叠,失败保留身份并可恢复', async () => {
vi.useFakeTimers()
const gate = deferred<void>()
const expected = new Error('offline')
const load = vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValue(undefined)
const { result, unmount } = withSetup(() => usePolling(load, 1000))
try {
await vi.advanceTimersByTimeAsync(3000)
expect(load).toHaveBeenCalledTimes(1)
gate.reject(expected)
await vi.advanceTimersByTimeAsync(0)
expect(result.lastError.value).toBe(expected)
result.pause(); result.resume()
await vi.advanceTimersByTimeAsync(1000)
expect(load).toHaveBeenCalledTimes(2)
expect(result.lastError.value).toBe(null)
} finally { unmount() }
})当前函数不会取消已经开始的 load,只保证不再启动新轮询;需要取消请求时应给 load 传 AbortSignal,并在卸载后阻止结果写回,这属于第四章资源管理的组合变体。
03|失败后草稿不丢
import { expect, test } from '@playwright/test'
test('失败保留草稿,重试只成功一次', async ({ page }) => {
let calls = 0
await page.route('**/api/drafts', async route => {
calls++
await route.fulfill(calls === 1
? { status: 503, body: '{"code":"unavailable"}' }
: { status: 201, body: '{"id":"d1"}' })
})
await page.goto('/drafts/new')
const title = page.getByLabel('标题')
await title.fill('Hydration notes')
await page.getByRole('button', { name: '保存' }).click()
await expect(page.getByRole('alert')).toBeVisible()
await expect(title).toHaveValue('Hydration notes')
await expect(title).toBeFocused()
const response = page.waitForResponse(r => r.url().includes('/api/drafts') && r.status() === 201)
await page.getByRole('button', { name: '重试' }).click()
await response
await expect(page.getByRole('status')).toHaveText('已保存')
expect(calls).toBe(2)
})配置 trace: 'retain-on-failure' 或团队选定策略。没有 waitForTimeout,每一步等待的是可观察状态或指定响应。
04|类型正确但运行失败
export type User = { id: string; displayName: string }
export function parseUser(input: unknown): User {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new TypeError('user must be an object')
}
const value = input as Record<string, unknown>
if ('sessionToken' in value) throw new TypeError('sensitive field forbidden')
if (typeof value.id !== 'string' || !value.id.trim()) throw new TypeError('id required')
if (typeof value.displayName !== 'string' || !value.displayName.trim()) {
throw new TypeError('displayName required')
}
return { id: value.id, displayName: value.displayName }
}运行时测试对四类输入分别断言返回对象或 toThrow(TypeError)。组件中用 defineEmits<{ select: [id: string] }>(),并保留 // @ts-expect-error number is forbidden 后的 emit('select', 1);若类型约束意外变宽,@ts-expect-error 自身会报“没有错误”。
05|定位慢点击责任层
| 假设 | 需要证据 | 可证伪信号 |
|---|---|---|
| API 500ms | Network 请求 start/end、Server-Timing/request id | 响应 20ms 已到,之后才长时间卡住 |
| render 500ms | Vue profiler、Vue performance marks、主线程函数 | 组件更新很短,主要时间在 layout |
| layout 500ms | Performance 中 Recalculate Style/Layout、DOM 数 | 主要长任务在 JS 且没有大布局段 |
重放脚本固定 production release、数据种子与一次点击。证据确认前不先提缓存/memo/虚拟化;下一步每次只改变一个候选边界。
06|迁移前后产物合同
真实构建器的 manifest 字段不同,先各自适配成同一个输入格式;这里固定 routes: string[]、assets: string[]、entries: { name, bytes, code }[]。code 为脚本内容,bytes 为真实文件字节数。阈值来自旧链与产品预算,下面的数字只用于跑通失败路径。
artifact-contract.json:
{
"routes": ["/login", "/drafts/new"],
"requiredAssets": ["logo.svg"],
"forbiddenGlobals": ["legacyGlobal"],
"maxEntryBytes": 1000,
"supportedBrowsers": ["本项目实际浏览器清单"]
}check-artifacts.mjs:
import { readFileSync } from 'node:fs'
const [contractFile, oldFile, newFile] = process.argv.slice(2)
if (!contractFile || !oldFile || !newFile) throw new Error('需要 contract old new 三个文件路径')
const read = file => JSON.parse(readFileSync(file, 'utf8'))
const contract = read(contractFile)
const issues = []
for (const [label, manifest] of [['old', read(oldFile)], ['new', read(newFile)]]) {
for (const route of contract.routes) {
if (!manifest.routes.includes(route)) issues.push({ label, kind: 'route', target: route })
}
for (const asset of contract.requiredAssets) {
if (!manifest.assets.includes(asset)) issues.push({ label, kind: 'asset', target: asset })
}
for (const entry of manifest.entries) {
if (entry.bytes > contract.maxEntryBytes) issues.push({ label, kind: 'bytes',
target: entry.name, excess: entry.bytes - contract.maxEntryBytes })
for (const forbidden of contract.forbiddenGlobals) {
if (entry.code.includes(forbidden)) issues.push({ label, kind: 'global', target: entry.name, forbidden })
}
}
}
console.log(JSON.stringify(issues, null, 2))
process.exitCode = issues.length ? 1 : 0两个 manifest 先都写成以下 JSON,运行 node check-artifacts.mjs artifact-contract.json old.json new.json 应输出 []:
{"routes":["/login","/drafts/new"],"assets":["logo.svg"],"entries":[{"name":"app.js","bytes":800,"code":"console.log('ready')"}]}再从 new 删除 logo,把 bytes 改1100、code改 legacyGlobal.start(),应同时列出三项且退出失败。字符串搜索只是这个小练习的保守扫描,注释/字符串也会匹配;生产检查需解析脚本或用构建图验证引用。supportedBrowsers 是执行浏览器回归的输入,不能从“manifest存在”证明所有目标浏览器兼容。
07|可替换 AppDialog
先建立可运行供应方替身 src/VendorDialog.vue。它实现协议,不声称具备完整弹窗可访问性:
<script setup lang="ts">
defineProps<{ visible: boolean; busy: boolean }>()
const emit = defineEmits<{ dismiss: []; accept: [] }>()
</script>
<template>
<section v-if="visible" role="dialog" aria-labelledby="app-dialog-title" @keydown.esc="emit('dismiss')">
<slot />
<button :disabled="busy" @click="emit('accept')">确认</button>
<button @click="emit('dismiss')">关闭</button>
</section>
</template>src/AppDialog.vue:
<script setup lang="ts">
import { nextTick, onMounted, watch } from 'vue'
import VendorDialog from './VendorDialog.vue'
const props = defineProps<{ open: boolean; pending: boolean; trigger: HTMLElement | null }>()
const emit = defineEmits<{ 'update:open': [value: boolean]; confirm: [] }>()
async function updateFocus(open: boolean) {
await nextTick()
if (open) document.getElementById('app-dialog-title')?.focus()
else props.trigger?.focus()
}
onMounted(() => { if (props.open) void updateFocus(true) })
watch(() => props.open, value => { void updateFocus(value) })
function confirm() { if (!props.pending) emit('confirm') }
</script>
<template>
<VendorDialog :visible="open" :busy="pending" @dismiss="emit('update:open', false)" @accept="confirm">
<h2 id="app-dialog-title" tabindex="-1">确认保存</h2>
</VendorDialog>
</template>本例只允许页面同时有一个 AppDialog,因此标题 id 固定;多实例时改成 useId() 并把 labelledby 协议交给供应方。trigger 由打开弹窗的按钮提供,组件不猜页面上的第一个按钮。
src/AppDialog.spec.ts:
import { flushPromises, mount } from '@vue/test-utils'
import { expect, test } from 'vitest'
import AppDialog from './AppDialog.vue'
test('输入输出与焦点不依赖供应方 class', async () => {
const trigger = document.createElement('button')
document.body.append(trigger)
const wrapper = mount(AppDialog, { attachTo: document.body,
props: { open: false, pending: false, trigger } })
try {
trigger.focus()
await wrapper.setProps({ open: true })
await flushPromises()
expect(document.activeElement).toBe(wrapper.get('h2').element)
expect(wrapper.get('[role=dialog]').attributes('aria-labelledby')).toBe('app-dialog-title')
await wrapper.get('[role=dialog]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:open')).toEqual([[false]])
await wrapper.setProps({ open: false })
await flushPromises()
expect(document.activeElement).toBe(trigger)
await wrapper.setProps({ open: true, pending: true })
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toBeUndefined()
await wrapper.setProps({ pending: false })
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([[]])
} finally { wrapper.unmount(); trigger.remove() }
})这里挂入 document 才能观察 jsdom 的 activeElement。真实浏览器仍需验证 focus trap、Tab/Shift+Tab 循环和原生事件;替换 Vendor 后,业务测试保留,只有适配器改映射。
08|验证依赖图
type Edge = readonly [string, string]
export function validateEdges(edges: Edge[], allowed: Record<string, string[]>) {
return edges.filter(([from, to]) =>
!Object.hasOwn(allowed, from) || !Object.hasOwn(allowed, to) || !allowed[from]!.includes(to)
)
}
export function findCycle(edges: Edge[]) {
const graph = new Map<string, string[]>()
for (const [from, to] of edges) graph.set(from, [...(graph.get(from) ?? []), to])
const visiting = new Set<string>()
const visited = new Set<string>()
function visit(node: string, path: string[]): string[] | null {
if (visiting.has(node)) return [...path.slice(path.indexOf(node)), node]
if (visited.has(node)) return null
visiting.add(node)
for (const next of graph.get(node) ?? []) {
const cycle = visit(next, [...path, node])
if (cycle) return cycle
}
visiting.delete(node)
visited.add(node)
return null
}
for (const node of graph.keys()) {
const cycle = visit(node, [])
if (cycle) return cycle
}
return null
}未知 from/to 应由 allowed 缺失而成为非法边。同层是否允许必须显式写进 allowed。断言三节点环返回首尾相同的完整路径。
09|安全事件投影与去重
const limit = (value: string, n: number) => Array.from(value).slice(0, n).join('')
const normalized = (value: string) => value.replace(/[0-9a-f]{8,}/gi, ':id')
export function toClientEvent(error: unknown, context: {
release: string; routePattern: string; operation: string; requestId?: string
}) {
if (!['/orders/:id', '/drafts/new'].includes(context.routePattern)) {
throw new TypeError('routePattern 必须来自受控路由表')
}
const value = error instanceof Error ? error : new Error(String(error))
return {
kind: 'error' as const,
release: context.release,
routePattern: context.routePattern,
operation: context.operation,
at: new Date().toISOString(),
requestId: context.requestId,
name: value.name,
message: limit(value.message, 200)
}
}
export function dedupeKey(event: ReturnType<typeof toClientEvent>) {
return [event.release, event.routePattern, event.operation,
event.name, normalized(event.message)].join('|')
}
export async function reportSafely(send: () => Promise<void>) {
try { await send() } catch { /* 监控失败不得形成递归全局错误 */ }
}允许列表只限制字段,不净化字段值。调用者必须先给出受控路由 pattern,有限集合检查用于防止把 fullPath 误传入本题;不是通用URL参数化算法。错误 message 同样应由业务层提供安全错误码/文案,不能把原始上游响应体当 message。若调用方有更宽对象,测试还应断言 email/token/query 未被复制;传 /orders/alice?token=x 应抛 TypeError。同错误来自多个入口得到同 key,不同 release 得到不同 key;reportSafely(() => Promise.reject(expected)) 必须 resolve,且不会触发新的上报。
import assert from 'node:assert/strict'
const context = {
release: 'web-3', routePattern: '/orders/:id', operation: 'submit',
requestId: 'req-1', email: 'private@example.com', token: 'secret'
}
const first = toClientEvent(new Error('order deadbeef123 failed'), context)
const second = toClientEvent(new Error('order cafebabe456 failed'), context)
assert.equal('email' in first, false)
assert.equal('token' in first, false)
assert.equal(first.message!.length <= 200, true)
assert.equal(dedupeKey(first), dedupeKey(second))
assert.notEqual(dedupeKey(first), dedupeKey({ ...first, release: 'web-4' }))
assert.throws(() => toClientEvent(new Error('x'), {
...context, routePattern: '/orders/alice?token=secret'
}), TypeError)
await assert.doesNotReject(() => reportSafely(() => Promise.reject(new Error('collector offline'))))两条不同十六进制 id 的消息归一为同一个 key,但不同 release 保持分离。若把整个 context 展开进事件,敏感字段断言会失败;若监控发送失败再次抛出,doesNotReject 会失败。
发布判断使用同一份显式合同:
type ReleaseSample = { attempts: number; failures: number; p95Ms: number }
function assertSample(value: ReleaseSample) {
if (!Number.isInteger(value.attempts) || value.attempts <= 0 ||
!Number.isInteger(value.failures) || value.failures < 0 ||
value.failures > value.attempts ||
!Number.isFinite(value.p95Ms) || value.p95Ms < 0) {
throw new TypeError('invalid release sample')
}
}
export function decideRollout(baseline: ReleaseSample, candidate: ReleaseSample) {
assertSample(baseline)
assertSample(candidate)
if (candidate.attempts < 500) return 'hold' as const
const oldRate = baseline.failures / baseline.attempts
const newRate = candidate.failures / candidate.attempts
if (newRate > 0.01 && newRate > oldRate * 3) return 'rollback' as const
if (candidate.p95Ms > baseline.p95Ms * 1.5) return 'rollback' as const
return 'continue' as const
}下面的断言分别锁定三个结果和非法输入。保存为 Vitest 用例时把 assert 换成 expect 也可以,合同不变。
import assert from 'node:assert/strict'
const baseline = { attempts: 1000, failures: 2, p95Ms: 400 }
assert.equal(decideRollout(baseline, { attempts: 200, failures: 20, p95Ms: 900 }), 'hold')
assert.equal(decideRollout(baseline, { attempts: 1000, failures: 3, p95Ms: 450 }), 'continue')
assert.equal(decideRollout(baseline, { attempts: 1200, failures: 36, p95Ms: 460 }), 'rollback')
assert.equal(decideRollout(baseline, { attempts: 1000, failures: 3, p95Ms: 601 }), 'rollback')
assert.throws(
() => decideRollout(baseline, { attempts: 10, failures: 11, p95Ms: 100 }),
TypeError
)第一个用例即使失败率很高也返回 hold,证明样本门槛先于扩量判断;第三和第四个用例分别隔离错误率与性能回滚条件。执行回滚后仍要从真实入口确认旧 HTML 引用的 hash 资源可获取,并观察新事件重新带上旧 release。纯函数只决定动作,不能证明部署或缓存已经恢复。
答案页自检
- 等待对象明确,没有随机 sleep。
- 错误分支会排除成功 UI,并保留原始错误身份或稳定错误码。
- 工具、架构和发布题都能输出可执行失败,而不是只有建议清单。
00|证明清理发生
Counter 中导入 onUnmounted,声明 const emit = defineEmits<{ closed: [] }>(),注册 onUnmounted(() => emit('closed'))。原测试末尾增加:
wrapper.unmount()
expect(wrapper.emitted('closed')).toEqual([[]])外层数组是一条事件记录,内层空数组是这次事件没有参数。若把钩子写成 onMounted,应再在卸载前断言事件为 undefined,避免只看最终数量而误认清理发生。