Mock Drift 实战案例:Kiosk App 的 5 个真实 Bug

日期: 2026-02-07  |  项目: Celoria Kiosk iPad App  |  关联文档: Flutter 测试策略

核心发现:Kiosk App 的 E2E 测试原先使用 MockInterceptor 拦截所有 HTTP 请求,返回手写的假数据。改为连接真实后端后,一次性暴露了 5 个生产级 Bug,全部是 Mock 数据与真实 API 响应不一致导致的。这些 Bug 在 Mock 测试下永远不会被发现。

1. 什么是 Mock Drift

Mock Drift(Mock 漂移)是指:测试中使用的 Mock 数据与真实后端 API 的实际行为之间逐渐产生差异的现象。

开发者对 API 的理解 手写 Mock 数据 测试全部通过 ✅

真实后端 API 返回不同的格式 App 上线后崩溃 💥

Mock 数据是开发者凭自己对后端 API 的理解手写的,而不是从真实后端获取的。一旦理解有偏差——字段名记错、嵌套层级搞错、数据类型搞混——Mock 就和真实后端产生了分歧。而且这种分歧是静默的:Mock 测试依然全绿,但 App 连真实后端就炸。

2. 五个真实 Bug 详解

Bug #1:登录请求缺少 login_type 字段

现象 调用 POST /api/auth/login 返回 HTTP 400 错误 "Invalid login type",用户点登录按钮无任何反应。

原因 后端登录 API 要求请求体必须包含 login_type 字段(值为 'email'),用于区分邮箱登录、手机号登录、第三方登录。但 api_client.dartadminLogin() 只传了 emailpassword,漏掉了这个必填字段。

{ email, password }
Mock 直接匹配路径返回 200,不校验请求体
{ email, password, login_type: 'email' }
后端中间件校验 login_type,缺失则返回 400

修复api_client.dartadminLogin() 请求体中补上 'login_type': 'email'

教训 Mock 只按路径匹配,不校验请求体是否合法。后端的参数校验逻辑被完全跳过。

Bug #2:Token 字段名不匹配

现象 登录请求成功了(HTTP 200),但 App 提示 "Invalid response from server",无法进入选店步骤。

原因 后端登录成功后返回的 JSON 中,token 放在 data.access_token 字段里。但 admin_setup_cubit.dart 只检查了 data['token']respData['token'],没有检查 access_token。Token 始终为 null,走到了错误分支。

// Mock 返回
{ "data": { "token": "fake-jwt-123" } }
// 真实后端返回
{ "data": { "access_token": "eyJhbG..." } }

修复admin_setup_cubit.dart 的 token 提取链中增加 respData?['access_token'] 作为第三优先级回退。

教训 字段名是最容易记错的地方。token vs access_token vs jwt — 不同 API 命名习惯不同,只有连真实后端才能验证。

Bug #3:API 响应嵌套格式错误(4 处)

现象 登录选店成功后,进入预约流程,服务列表、技师列表、时间段列表全部为空,页面只显示空白网格。

原因 后端 API 返回的数据不是扁平数组,而是嵌套在对象中:

API 端点Mock 假设的格式真实后端格式
/api/centers { data: [ ... ] } { data: { centers: [ ... ] } }
/api/public/booking/services { data: [ ... ] } { data: { services: [ ... ], categories: [ ... ] } }
/api/public/booking/employees { data: [ ... ] } { data: { employees: [ ... ] } }
/api/public/booking/slots { data: [ ... ] } { data: { slots: [ ... ] } }

代码中写的 data['data'] as List 对真实后端返回的 Map 做类型转换,Dart 的 as List? 返回 null,走到 ?? [] 变成空列表。不报错但什么都不显示 — 这是最危险的静默失败。

修复 在 4 个方法中都改为先检查 data['data'] 的实际类型:

final rawData = data['data'];
final rawList = rawData is List
    ? rawData
    : (rawData is Map ? (rawData['services'] as List? ?? []) : []);

教训 API 的响应嵌套层级是最常见的 Mock Drift 来源。开发者通常简化 Mock 数据结构(省略中间层级),导致解析代码从未被真正测试过。

Bug #4:价格字段类型错误导致运行时崩溃

现象 修复 Bug #3 后,服务列表 API 请求成功了,但 Cubit 的 error 状态显示:NoSuchMethodError: Class 'String' has no instance method 'toDouble'. Receiver: "45.00"

原因 后端 PostgreSQL 中 price 列是 numeric / decimal 类型,经过 JSON 序列化后变成字符串 "45.00" 而非数字 45.0。代码中 (s['price'] ?? 0).toDouble() 在 String 上调用 .toDouble()(这是 num 的方法),直接抛 NoSuchMethodError

// Mock 数据
{ "price": 45.0, "duration": 30 }
// Dart 类型: num → .toDouble() 正常
// 真实后端数据
{ "price": "45.00", "duration": 30 }
// Dart 类型: String → .toDouble() 崩溃!

修复 改为防御性类型检查:

final rawPrice = s['price'] ?? 0;
final price = rawPrice is num
    ? rawPrice.toDouble()
    : double.tryParse(rawPrice.toString()) ?? 0.0;

教训 JSON 中 "45.00"(字符串)和 45.0(数字)在 JavaScript 端看起来差不多,但在 Dart 强类型语言中是完全不同的类型。Mock 数据用 Dart 原生类型构造,永远不会碰到这种类型不匹配。

Bug #5:iOS ATS 拦截 HTTP 请求

现象 App 安装到 iPad 模拟器后,点登录完全没反应。没有错误弹窗,没有 loading,没有任何视觉反馈。

原因 iOS 9 起强制启用 App Transport Security(ATS),默认禁止所有 HTTP 明文请求,只允许 HTTPS。本地开发连接 http://localhost:3000 是 HTTP,iOS 直接在系统层静默拦截了请求。Dio 收到网络错误,被 catch 吞掉。

Mock 测试路径(Bug 不可见):
App Dio MockInterceptor 拦截(不经过网络) 返回假数据 ✅

真实运行路径(Bug 暴露):
App Dio iOS 网络层 ATS 拦截 HTTP 请求 ❌ 静默失败

修复Info.plist 中添加 ATS 例外:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>

NSAllowsLocalNetworking 只放行 localhost HTTP,外部请求仍强制 HTTPS。生产环境使用 https://api.celoria.ai,不受影响。

教训 Mock 测试完全绕过了真实网络栈。任何涉及网络配置、TLS、证书验证、代理设置的问题,Mock 测试一个都抓不到。

3. Bug 分类总结

Bug 分类 Mock 为什么抓不到
#1 缺少 login_type 请求参数缺失 Mock 不校验请求体,只匹配路径
#2 token vs access_token 响应字段名错误 Mock 数据的字段名是开发者手写的
#3 嵌套响应格式 响应结构层级错误 Mock 简化了嵌套结构
#4 price 字符串类型 响应字段类型错误 Mock 用 Dart 原生类型,不经过 JSON 序列化
#5 ATS 拦截 HTTP 网络配置问题 Mock 在 Dart 层拦截,不经过真实网络
共同规律:这 5 个 Bug 全部是「App 端代码与真实后端的契约不一致」导致的。Mock 数据总是完美匹配 App 的预期格式(因为是同一个开发者写的),所以永远无法暴露这种不一致。

4. 防止 Mock Drift 的策略对比

策略 A:契约测试(Contract Testing,如 Pact)

前端和后端各自声明"我提供/期望什么格式",CI 自动验证两边是否匹配。

优点精确,自动化,能在后端改 API 时立即告警
缺点引入额外工具链(Pact broker),后端和前端都要写契约,维护成本高
适合多团队、前后端完全分离开发的大型项目

对我们:过重。我们是同一个团队维护前后端,引入 Pact 的 ROI 不高。

策略 B:从 OpenAPI Schema 自动生成 Mock

后端已有 Swagger 文档(/api/docs),从 OpenAPI spec 自动生成 Mock 数据,而非手写。

优点Mock 和文档绑定,后端改了 schema 前端 mock 自动更新
缺点Swagger 文档本身可能与实际行为不一致(写的 token,代码返回 access_token);需要工具链支持
适合API 文档维护得非常严格、有自动化校验的项目

对我们:有价值但有前提。我们的 Swagger 文档覆盖率还不够完善,schema 和实际返回值有差距。如果先提升文档质量,这个方案可以作为未来方向。

策略 C:录制-回放(Record & Replay)

第一次跑测试时录制真实 API 响应存成 fixture 文件,之后用录制的数据跑测试。定期重新录制刷新。

优点Mock 数据保证和真实后端一致(录制那一刻)
缺点后端改了 API 后如果没重新录制,照样 drift;fixture 文件膨胀;时间相关的数据难处理
适合API 变更频率低、响应数据量小的项目

对我们:不太适合。我们的 API 还在快速迭代,录制的 fixture 很快就过时,维护负担和手写 Mock 差不多。

策略 D:分层测试 — Mock 测 UI,真实后端测集成 ✅ 我们的选择

不同测试层使用不同数据源,各司其职:

测试层数据来源目的运行频率
Widget/Unit 测试Mock 数据UI 逻辑、状态管理、边界场景每次 commit
E2E 测试真实后端前后端契约、完整业务流程每日 / PR 合并前

对我们:最务实。不引入新工具,不增加维护负担。这次发现的 5 个 Bug 全部会被真实后端 E2E 测试捕获。

5. 为什么策略 D 对我们最务实

核心逻辑:与其花大力气让 Mock 数据永远和真实后端同步(策略 A/B/C 的目标),不如直接连真实后端,让不一致在测试阶段立刻暴露。

5.1 团队规模决定策略

我们是同一个团队维护前后端,不存在"后端团队改了 API 没通知前端"的协作问题。引入 Pact 这样的契约测试工具是为了解决跨团队协作问题,对我们来说是杀鸡用牛刀。

5.2 零额外工具链

不需要安装 Pact broker,不需要 OpenAPI codegen,不需要录制回放框架。只需要:

  1. 后端在本地跑起来(npm run start:backend
  2. 种子数据加载(npm run db:seed:dev
  3. 运行 E2E 测试(flutter test integration_test/

5.3 Mock 测试没有白费

Mock 测试仍然有不可替代的价值:

5.4 两层互补覆盖所有风险

风险类型Mock 测试真实后端 E2E
UI 渲染错误✅ 能发现✅ 能发现
空数据/错误状态处理✅ 能发现❌ 数据通常不为空
API 字段名/类型不匹配❌ Mock 和 App 同源✅ 立刻暴露
请求参数缺失/错误❌ Mock 不校验请求✅ 后端返回 400
网络/TLS/ATS 配置问题❌ 不经过真实网络✅ 走完整网络栈
认证/权限流程❌ Mock 跳过认证✅ 走真实 JWT 流程
数据库约束/业务规则❌ 没有数据库✅ 走真实 DB 校验

6. 执行要点

关键约束:E2E 测试必须在 CI 中定期运行,不能只在本地手动跑。否则后端改了 API,E2E 测试没跑,Mock Drift 照样漏过去。

6.1 Kiosk App 当前 E2E 测试状态

测试状态说明
Flow 1: Admin Setup✅ PASS登录 → 选店 → 进入 Kiosk 模式
Flow 2: Check-in⏭️ SKIP需要当天有预约(数据条件)
Flow 3: Booking⏭️ SKIP需要当天有可用时间段
Flow 4: Anonymous Booking⏭️ SKIP需要当天有可用时间段
Flow 5: Navigation ×3✅ PASS返回键、页面切换
Flow 5: Language Toggle✅ PASS中英文切换

Flow 2-4 的 SKIP 不是 Bug,是测试数据条件不满足(当天无可用时段)。测试设计为 graceful skip — 条件不满足时标记跳过而非失败。

6.2 测试前置条件

# 1. 后端运行中
npm run start:backend    # http://localhost:3000

# 2. 种子数据已加载
npm run db:seed:dev

# 3. 运行 E2E 测试
cd kiosk_app
flutter test integration_test/app_test.dart -d <iPad-device-id>

7. 结论

Mock 测试和真实后端测试不是二选一,而是互补的两层防御。

这次 Kiosk App 的实践证明:只有 Mock 测试等于只有一半的防御。5 个生产级 Bug 全部躲过了 Mock 测试,在第一次连接真实后端时集体暴露。

文档版本: v1.0 | 最后更新: 2026-02-07 | 作者: Claude Code + Hex