Skip to content

09|测试、工具与工程实践练习答案 ​

第九章 / 参考答案。每个异步断言都写明在等 Vue、Promise、响应还是浏览器状态。

01|可取消搜索组件 ​

保存为 src/SearchBox.vue。取消表示请求尽力停止;序号则保证无法取消的旧 Promise 也不能覆盖新结果。这是两条独立保护。

vue
<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:

ts
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|可暂停轮询 ​

ts
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 逐步推进时间;每次断言后仍由环境页自动恢复真实计时器。

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|失败后草稿不丢 ​

ts
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|类型正确但运行失败 ​

ts
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 500msNetwork 请求 start/end、Server-Timing/request id响应 20ms 已到,之后才长时间卡住
render 500msVue profiler、Vue performance marks、主线程函数组件更新很短,主要时间在 layout
layout 500msPerformance 中 Recalculate Style/Layout、DOM 数主要长任务在 JS 且没有大布局段

重放脚本固定 production release、数据种子与一次点击。证据确认前不先提缓存/memo/虚拟化;下一步每次只改变一个候选边界。

06|迁移前后产物合同 ​

真实构建器的 manifest 字段不同,先各自适配成同一个输入格式;这里固定 routes: string[]、assets: string[]、entries: { name, bytes, code }[]。code 为脚本内容,bytes 为真实文件字节数。阈值来自旧链与产品预算,下面的数字只用于跑通失败路径。

artifact-contract.json:

json
{
  "routes": ["/login", "/drafts/new"],
  "requiredAssets": ["logo.svg"],
  "forbiddenGlobals": ["legacyGlobal"],
  "maxEntryBytes": 1000,
  "supportedBrowsers": ["本项目实际浏览器清单"]
}

check-artifacts.mjs:

js
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 应输出 []:

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。它实现协议,不声称具备完整弹窗可访问性:

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:

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:

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|验证依赖图 ​

ts
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|安全事件投影与去重 ​

ts
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,且不会触发新的上报。

ts
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 会失败。

发布判断使用同一份显式合同:

ts
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 也可以,合同不变。

ts
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'))。原测试末尾增加:

ts
wrapper.unmount()
expect(wrapper.emitted('closed')).toEqual([[]])

外层数组是一条事件记录,内层空数组是这次事件没有参数。若把钩子写成 onMounted,应再在卸载前断言事件为 undefined,避免只看最终数量而误认清理发生。

用心记录每一步成长