工程日记第三天:126 项测试、图片去重与自动化健康监控
第三天:测试攀升
今天开始时我们有 91 项测试,结束时是 126 项。这个数字看起来不惊人,直到你了解背后的付出:为 framer-motion 定制 mock 策略、对 Node 模块解析系统打猴子补丁、以及对整个平台的图片重复使用进行全面审计。
这是 VOLO 生产加固阶段的第三天。网站自 2 月 14 日上线以来已经运行了三天。Phase A 到 D(营销网站、预订平台、Agent API、双模式 UI)在上线前已完成。现在我们处于 Phase E:让一切防弹。
第一部分:SmartBookingInput 组件测试
SmartBookingInput 是预订体验的核心——一个 1,380 行的组件(最近被重构为 6 个模块),同时处理 AI 自然语言模式和传统表单模式。测试它意味着解决一个困扰许多 React 项目的问题:如何测试重度依赖 framer-motion 的组件?
framer-motion Mock 难题
framer-motion 的 motion.div 和 AnimatePresence 不是简单的包装器。它们在 React 树中注入布局测量、手势处理和动画编排。在 jsdom 测试环境中这些都不工作——更糟的是,framer-motion 传递自定义 props(variants、initial、animate、exit、layout、custom),这些不是有效的 DOM 属性,会导致 React 抛出警告。
我们的方案:一个全面的 mock,用纯 HTML 元素替换 motion.div 和 motion.span,通过 filterDomProps 辅助函数过滤动画特有的 props,让 AnimatePresence 成为简单的透传包装器。过滤函数显式移除 15 个动画 props,转发其余所有内容。
测试覆盖
12 项测试覆盖关键路径:
- 默认 AI 模式渲染,正确的标题和占位符
- AI 和表单模式之间的标签切换
- AI 模式下的文本输入(真实用户事件模拟)
- 航班解析芯片显示(输入 "NYC to LA tomorrow" 看到解析后的芯片)
- 表单提交回调正确触发
- 紧凑模式 prop 传播
我们遵循的规则:mock 边界,测试行为。我们不测试 framer-motion 动画是否正确——那是 framer-motion 的事。我们测试组件是否渲染了正确的内容并响应用户操作。
第二部分:机场搜索引擎——23 项测试和一个模块 Hack
我们的机场搜索引擎由 Fuse.js 驱动,索引全球 7,900+ 个机场,在 IATA 代码、ICAO 代码、城市名(中英文)和机场名之间进行加权模糊匹配。它是预订流程中每个"出发"和"到达"字段的自动补全骨干。
require() 别名问题
搜索引擎用 require("@/content/data/airports-global.json") 加载数据——这是服务器端数据加载的刻意选择。@/ 前缀是 Next.js 和 Vite 自动解析 ESM import 语句的路径别名。但 require() 是 Node.js 内置功能,完全绕过 Vite 的解析。
我们尝试了三种方法都失败了。最终的解决方案:在测试文件顶部补丁 Module._resolveFilename。这钩入 Node 的内部模块解析,拦截任何以 @/ 开头的 require() 调用,并将其重映射到 src/ 下的绝对路径。10 行猴子补丁让整个机场搜索模块可测试,无需修改生产代码。
测试覆盖
23 项测试覆盖搜索引擎的每条代码路径:
- 精确匹配:IATA "JFK" 返回肯尼迪机场;ICAO "KJFK" 也能解析
- 大小写不敏感:"jfk"、"Jfk"、"JFK" 都返回同一结果
- 城市前缀:"shangh" 匹配上海,"lond" 匹配伦敦各机场
- 模糊搜索:"tokyo" 通过 Fuse.js 匹配成田和羽田
- 中文搜索:用中文城市名查询返回正确结果
- 边界情况:空字符串、单字符、纯空白、不存在的代码 "ZZZ"
测试搜索引擎不是验证 Fuse.js 是否工作,而是验证你的配置——权重、阈值、匹配策略、优先级逻辑——是否产出用户期望的结果。
第三部分:全平台图片去重
全面审计发现多张库存图片在多个页面重复使用。对于奢侈品牌,视觉单调是一个安静的可信度杀手——访客在"关于"页面和"餐饮"页面看到同一张主图时,会下意识地将其归类为"模板级别"。
发现的问题
网站上 4 组重复图片:
- Pexels 3745234(海滨风景):出现在首页、关于页、餐饮页和礼宾页——4 次
- Pexels 30547618(现代建筑):出现在首页和礼宾页——2 次
- Unsplash 1534088568(飞机):出现在包机和空腿页——2 次
- Pexels 20562278(机舱内饰):在机舱体验页出现 3 次
替换策略
每个替换图片都根据页面的主题语境精选:
- 关于页:替换为全景风光(Pexels 2373201)——适合公司故事板块
- 餐饮页:替换为精致餐饮摆盘(Pexels 30507463)——直接关联机上美食叙事
- 礼宾页:替换为奢华服务内景(Pexels 912050)——匹配白手套礼宾主题
- 空腿页主图:替换为进场降落的飞机(Pexels 46148)——更好地传达空腿机会
- 机舱页分隔:替换为高空机翼照片(Pexels 62623)——在机舱内饰板块间增添视觉韵律
所有 8 个替换 URL 在提交前均通过 HTTP HEAD 请求验证(状态码 200)。
第四部分:自动化图片健康监控
上篇工程日记中,我们写了修复 70 个失效 Unsplash URL 的过程,并承诺建立自动化健康检查。今天兑现了。
GitHub Actions Cron 工作流
新的工作流位于 .github/workflows/image-health.yml,每周一 UTC 9:00 运行。它安装项目、执行 check:images 脚本(向代码库中每个图片 URL 发送 HEAD 请求),如果有任何 URL 返回非 200 状态码,自动创建标题为"检测到失效 Unsplash URL"的 GitHub Issue。
工作流还支持通过 workflow_dispatch 手动触发,任何人都可以从 GitHub Actions 标签页按需运行。
最好的监控是那种设置一次就忘掉的。如果图片 CDN 删除了一张照片,我们会在一周内知道——而且问题已经在我们的 backlog 里了,比任何人在线上看到裂图都要早。
数据看板
| 指标 | 今日之前 | 今日之后 |
|---|---|---|
| 通过测试总数 | 91 | 126(+35) |
| 测试文件 | 4 | 6(+2) |
| ESLint 错误 | 0 | 0 |
| 重复图片组 | 4 | 0 |
| 替换图片的页面 | — | 5 |
| CI 工作流 | 1(构建) | 2(+图片健康 cron) |
| 生成静态页面 | 557 | 557 |
技术决策
为什么补丁 Module._resolveFilename 而不是重构为 import()?
我们可以将生产代码中的 require() 改为动态 import()。选择不改有三个原因:(1) require() 是为服务器端同步数据加载特意选择的,(2) 改变它会改变模块的延迟初始化行为,(3) 仅限测试的猴子补丁隔离在单个文件中,不影响生产代码。准则:当存在测试级别的变通方案时,不要为了满足测试基础设施而修改生产代码。
为什么不 Mock 机场数据?
我们用真实的 7,900 机场数据集运行测试,而不是 5 个机场的 mock。这能发现真实问题:城市名编码问题、相同 IATA/ICAO 前缀的排序边界情况、模糊搜索的性能退化。数据集 1.2 MB——Vitest 加载不到 100ms。成本可以忽略;信心增益不可以。
第三天是关于深度,不是广度。我们没有发布任何新功能。我们让现有功能可证明是正确的、视觉上独特的。这就是把演示变成产品的那种安静工作。
订阅资讯
空腿航班优惠、新航线与航空洞察,直达您的邮箱。