日期: 2026-02-07 | 项目: Celoria Kiosk iPad App | 关联文档: Flutter 测试策略
Mock Drift(Mock 漂移)是指:测试中使用的 Mock 数据与真实后端 API 的实际行为之间逐渐产生差异的现象。
Mock 数据是开发者凭自己对后端 API 的理解手写的,而不是从真实后端获取的。一旦理解有偏差——字段名记错、嵌套层级搞错、数据类型搞混——Mock 就和真实后端产生了分歧。而且这种分歧是静默的:Mock 测试依然全绿,但 App 连真实后端就炸。
login_type 字段现象 调用 POST /api/auth/login 返回 HTTP 400 错误 "Invalid login type",用户点登录按钮无任何反应。
原因 后端登录 API 要求请求体必须包含 login_type 字段(值为 'email'),用于区分邮箱登录、手机号登录、第三方登录。但 api_client.dart 的 adminLogin() 只传了 email 和 password,漏掉了这个必填字段。
{ email, password }
{ email, password, login_type: 'email' }
修复 在 api_client.dart 的 adminLogin() 请求体中补上 'login_type': 'email'。
教训 Mock 只按路径匹配,不校验请求体是否合法。后端的参数校验逻辑被完全跳过。
现象 登录请求成功了(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 命名习惯不同,只有连真实后端才能验证。
现象 登录选店成功后,进入预约流程,服务列表、技师列表、时间段列表全部为空,页面只显示空白网格。
原因 后端 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 #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 原生类型构造,永远不会碰到这种类型不匹配。
现象 App 安装到 iPad 模拟器后,点登录完全没反应。没有错误弹窗,没有 loading,没有任何视觉反馈。
原因 iOS 9 起强制启用 App Transport Security(ATS),默认禁止所有 HTTP 明文请求,只允许 HTTPS。本地开发连接 http://localhost:3000 是 HTTP,iOS 直接在系统层静默拦截了请求。Dio 收到网络错误,被 catch 吞掉。
修复 在 Info.plist 中添加 ATS 例外:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
NSAllowsLocalNetworking 只放行 localhost HTTP,外部请求仍强制 HTTPS。生产环境使用 https://api.celoria.ai,不受影响。
教训 Mock 测试完全绕过了真实网络栈。任何涉及网络配置、TLS、证书验证、代理设置的问题,Mock 测试一个都抓不到。
| Bug | 分类 | Mock 为什么抓不到 |
|---|---|---|
| #1 缺少 login_type | 请求参数缺失 | Mock 不校验请求体,只匹配路径 |
| #2 token vs access_token | 响应字段名错误 | Mock 数据的字段名是开发者手写的 |
| #3 嵌套响应格式 | 响应结构层级错误 | Mock 简化了嵌套结构 |
| #4 price 字符串类型 | 响应字段类型错误 | Mock 用 Dart 原生类型,不经过 JSON 序列化 |
| #5 ATS 拦截 HTTP | 网络配置问题 | Mock 在 Dart 层拦截,不经过真实网络 |
前端和后端各自声明"我提供/期望什么格式",CI 自动验证两边是否匹配。
| 优点 | 精确,自动化,能在后端改 API 时立即告警 |
| 缺点 | 引入额外工具链(Pact broker),后端和前端都要写契约,维护成本高 |
| 适合 | 多团队、前后端完全分离开发的大型项目 |
对我们:过重。我们是同一个团队维护前后端,引入 Pact 的 ROI 不高。
后端已有 Swagger 文档(/api/docs),从 OpenAPI spec 自动生成 Mock 数据,而非手写。
| 优点 | Mock 和文档绑定,后端改了 schema 前端 mock 自动更新 |
| 缺点 | Swagger 文档本身可能与实际行为不一致(写的 token,代码返回 access_token);需要工具链支持 |
| 适合 | API 文档维护得非常严格、有自动化校验的项目 |
对我们:有价值但有前提。我们的 Swagger 文档覆盖率还不够完善,schema 和实际返回值有差距。如果先提升文档质量,这个方案可以作为未来方向。
第一次跑测试时录制真实 API 响应存成 fixture 文件,之后用录制的数据跑测试。定期重新录制刷新。
| 优点 | Mock 数据保证和真实后端一致(录制那一刻) |
| 缺点 | 后端改了 API 后如果没重新录制,照样 drift;fixture 文件膨胀;时间相关的数据难处理 |
| 适合 | API 变更频率低、响应数据量小的项目 |
对我们:不太适合。我们的 API 还在快速迭代,录制的 fixture 很快就过时,维护负担和手写 Mock 差不多。
不同测试层使用不同数据源,各司其职:
| 测试层 | 数据来源 | 目的 | 运行频率 |
|---|---|---|---|
| Widget/Unit 测试 | Mock 数据 | UI 逻辑、状态管理、边界场景 | 每次 commit |
| E2E 测试 | 真实后端 | 前后端契约、完整业务流程 | 每日 / PR 合并前 |
对我们:最务实。不引入新工具,不增加维护负担。这次发现的 5 个 Bug 全部会被真实后端 E2E 测试捕获。
我们是同一个团队维护前后端,不存在"后端团队改了 API 没通知前端"的协作问题。引入 Pact 这样的契约测试工具是为了解决跨团队协作问题,对我们来说是杀鸡用牛刀。
不需要安装 Pact broker,不需要 OpenAPI codegen,不需要录制回放框架。只需要:
npm run start:backend)npm run db:seed:dev)flutter test integration_test/)Mock 测试仍然有不可替代的价值:
| 风险类型 | Mock 测试 | 真实后端 E2E |
|---|---|---|
| UI 渲染错误 | ✅ 能发现 | ✅ 能发现 |
| 空数据/错误状态处理 | ✅ 能发现 | ❌ 数据通常不为空 |
| API 字段名/类型不匹配 | ❌ Mock 和 App 同源 | ✅ 立刻暴露 |
| 请求参数缺失/错误 | ❌ Mock 不校验请求 | ✅ 后端返回 400 |
| 网络/TLS/ATS 配置问题 | ❌ 不经过真实网络 | ✅ 走完整网络栈 |
| 认证/权限流程 | ❌ Mock 跳过认证 | ✅ 走真实 JWT 流程 |
| 数据库约束/业务规则 | ❌ 没有数据库 | ✅ 走真实 DB 校验 |
| 测试 | 状态 | 说明 |
|---|---|---|
| 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 — 条件不满足时标记跳过而非失败。
# 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>
Mock 测试和真实后端测试不是二选一,而是互补的两层防御。
这次 Kiosk App 的实践证明:只有 Mock 测试等于只有一半的防御。5 个生产级 Bug 全部躲过了 Mock 测试,在第一次连接真实后端时集体暴露。
文档版本: v1.0 | 最后更新: 2026-02-07 | 作者: Claude Code + Hex