feat: add engineering conventions and CI gates documentation

- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks.
- Updated README to include the new conventions document.
- Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability.
- Adjusted global exception handling to return numeric error codes.
- Updated tests to reflect changes in error code handling.
This commit is contained in:
Guangfei.Zhao
2026-08-13 19:28:36 +08:00
parent be009ac15e
commit 444db49818
17 changed files with 3362 additions and 239 deletions
+110 -4
View File
@@ -19,17 +19,117 @@ dev_dependencies:
## 分层测试规则
- **domain 层(有 domain 的 feature**use case 用纯 Dart 单元测试,mock 掉 `repository` 接口,覆盖多步骤业务规则的分支(如 [02-layering.md](./02-layering.md) 里 `ConfirmPaymentUseCase` 的状态校验、金额校验)。
- **data 层**repository 实现用单元测试,mock 掉 `Dio`(或用 dio 自带的 `DioAdapter`/假响应),验证请求参数拼装和响应解析是否正确,不发真实网络请求
- **presentation 层(Notifier**:用 `ProviderContainer` + `overrides` 直接测试 `Notifier`/`AsyncNotifier` 的状态流转(见 [03-state-management.md](./03-state-management.md) 的测试示例),不需要启动完整 widget 树。
- **data 层**repository 实现用单元测试,mock 掉 `ApiClient`(见 [05-networking.md](./05-networking.md)),验证请求参数拼装和响应解析是否正确,不发真实网络请求。拦截器本身(`ApiResultInterceptor`/`AuthInterceptor`/`ErrorMappingInterceptor`)单独测,用 `DioAdapter` 造假响应——**401 刷新的串行逻辑必须有测试**,它是最容易写错、出错代价最高的一段(见 05 里关于并发刷新会导致全设备登出的说明)
- **presentation 层(Notifier**:用 `ProviderContainer.test()` + `overrides` 直接测试 `Notifier`/`AsyncNotifier` 的状态流转(见 [03-state-management.md](./03-state-management.md) 的测试示例),不需要启动完整 widget 树。
- **Widget 测试**:只覆盖有实际业务分支的页面(比如列表的 loading/data/error 三态渲染是否正确),纯展示型 widget(无状态分支)不强制要求。
- **集成测试**:只覆盖黄金路径(1-2 条最核心的用户旅程),跑在真实/模拟设备上,验证跨 feature 的路由跳转和端到端流程;不追求覆盖所有页面组合,避免集成测试维护成本超过收益。
- 每个 `feature_*` 包的 `test/` 目录结构镜像 `lib/src/`(如 `test/domain/``test/data/``test/presentation/`),单元测试和 Widget 测试都通过 `melos run test`(见 [01-project-structure.md](./01-project-structure.md))统一跑;集成测试单独一个 CI job,不并入这条批量命令(跑得慢、需要设备/模拟器,不适合每次 `analyze`/`test` 都触发)。
## mocktail 的 `registerFallbackValue`
**用 `any()` 匹配自定义类型的参数时,必须先 `registerFallbackValue`**,否则运行时直接报错。这是 mocktail 最常见的踩坑点,而且报错信息不看文档很难对上号。
```dart
class FakePageQuery extends Fake implements PageQuery {}
class FakeCancelToken extends Fake implements CancelToken {}
void main() {
setUpAll(() {
// 每个会出现在 any() 位置的非基础类型都要注册一次,注册一次即可全局生效
registerFallbackValue(FakePageQuery());
registerFallbackValue(FakeCancelToken());
});
test('...', () {
when(() => repo.fetchOrders(any(), cancelToken: any(named: 'cancelToken')))
.thenAnswer((_) async => const PageResult(items: [], total: 0, page: 1));
});
}
```
`int`/`String`/`bool`/`double` 这些基础类型不需要注册。约定:`registerFallbackValue` 统一写在包的 `test/helpers/fallbacks.dart` 里,各测试文件的 `setUpAll` 调用同一个 `registerAllFallbacks()`,避免每个文件各注册一遍、漏一个就挂。
## 测试里必须关掉 Riverpod 的自动重试
Riverpod 3 的 provider 失败后会自动重试(见 [03-state-management.md](./03-state-management.md))。虽然我们在 `ProviderScope` 上全局关掉了,但**测试不走 `main.dart``ProviderContainer` 默认仍带着重试策略**。后果是断言 `AsyncError` 的测试会 flaky,或者测试跑完报 "A Timer is still pending"。
统一在测试辅助里建 container:
```dart
// test/helpers/container.dart
ProviderContainer makeContainer({List<Override> overrides = const []}) =>
ProviderContainer.test(
retry: (_, __) => null, // 与线上 ProviderScope 的配置保持一致
overrides: overrides,
);
```
所有测试用 `makeContainer()`,不直接 `ProviderContainer.test(...)`——这样将来全局策略变了只改一处。
## 覆盖率门禁
```yaml
# 根 pubspec.yaml 的 melos: scripts:
test:
run: melos exec --dir-exists=test --fail-fast -- flutter test --coverage
coverage:
run: |
dart pub global run coverde value -i coverage/lcov.info --min-coverage 60
```
阈值定 **60%**,只卡**整体**、不卡单文件。理由:
- 卡单文件会逼着大家给 `*.g.dart`、纯展示 widget、`toString()` 这类东西补无意义的测试,产出的是"覆盖率数字"而不是"信心"。
- 60% 不是终点,是**不允许倒退的地板**。真正该高覆盖的是 domain use case 和 repository,这两块应该接近 90%,靠 review 保证而不是靠数字。
- 生成产物(`**/*.g.dart`)、生成的 pigeon 代码要从 lcov 里排除,否则数字会被生成代码稀释得没有参考价值。
覆盖率报告作为 CI artifact 上传,PR 上能看到(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。
## 集成测试在 CI 的运行环境
| 平台 | Runner | 说明 |
|---|---|---|
| Android | 现有 **Linux** runner + Android Emulator | 可行。用 `avdmanager` 起一个无头模拟器(`-no-window -gpu swiftshader`),或用 Docker 镜像。启动慢(1–3 分钟),所以只跑黄金路径 |
| iOS | 需要 **mac runner** | 与 [08-build-flavors.md](./08-build-flavors.md) 的 iOS 构建链路是同一个阻塞项。mac runner 落地前,iOS 集成测试**手工在本机跑**,并在发版 checklist 里列为必做项 |
集成测试的触发时机:**不进每次 push 的流水线**,只在合入 `develop`/`main` 和打 tag 时跑。每次 push 都跑模拟器,流水线时间会从 3 分钟涨到 10 分钟以上,实际效果是大家开始绕过 CI。
集成测试连的是 **UAT 后端**,需要一组固定的测试账号和测试门店,数据由后端侧准备并保证可重复(这一项要和后端对齐)。
## JSBridge 的测试策略
`core_webview` 的 JSBridge(见 [10-webview-h5.md](./10-webview-h5.md))分两块测,**不要试图在 CI 里跑真实 H5 页面**:
1. **协议编解码 → 纯 Dart 单元测试**`{id, method, params}` 的解析、未知 `method` 的处理、参数缺失/类型错误的报错、回包格式、来源域名校验——这些都是纯函数,不需要 WebView,覆盖率应该接近 100%。这是 JSBridge 里最容易出错也最好测的部分。
2. **原生能力调用 → mock 掉 `native_*` 的公共 API 类**。验证"H5 发来 `scan` 请求 → 调了 `NativeScan.startScan` → 回包格式正确",不真的起相机。
3. **端到端联调 → 走契约用例,不进 `melos run test`**。维护一个 H5 侧和 App 侧共用的 bridge 契约用例清单(12 项能力各一条),联调时人工逐条过,作为 checklist 而不是自动化测试。真起 WebView 加载真 H5 的自动化测试在 CI 上又慢又不稳定,投入产出比很差。
## Golden 测试:`core_ui` 做,业务页面不做
**结论**:只对 `core_ui` 里的基础组件(按钮、输入框、卡片、状态占位图)写 golden 测试,`feature_*` 的业务页面不写。
理由:
- `core_ui` 组件被所有 feature 复用,改一处影响面大,而它们的输出是稳定的——正是 golden 测试的适用场景。
- 业务页面的 UI 改动频繁,golden 会变成"每次改 UI 都要 `--update-goldens` 一遍"的负担,而且没人真的去看那张图对不对,最后退化成走过场。
- golden 图片对**渲染环境敏感**(字体、平台、Flutter 版本)。必须在 CI 里用固定环境生成和比对,本机生成的图传上去大概率对不上。所以 golden 测试**只在 Linux runner 上跑**,本地开发时用 `--tags golden` 排除掉。
字体要显式加载,不然 golden 里全是方块:
```dart
setUpAll(() async {
await loadAppFonts(); // golden_toolkit 或自己写的 FontLoader 封装
});
```
## 参考链接
- [Flutter 官方测试文档](https://docs.flutter.dev/testing)
- [mocktail | Dart package](https://pub.dev/packages/mocktail)
- [mocktail: registerFallbackValue](https://pub.dev/packages/mocktail#how-it-works)
- [integration_test 官方文档](https://docs.flutter.dev/testing/integration-tests)
- [Flutter: golden 文件测试](https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html)
## 附录:分层怎么测,日常怎么写
@@ -87,10 +187,11 @@ void main() {
when(() => repository.fetchNearbyStores(any(), any()))
.thenThrow(NetworkException('超时'));
final container = ProviderContainer(
// makeContainer 内部是 ProviderContainer.test(retry: (_, __) => null)
// 自动 dispose + 关掉自动重试,否则这条断言会 flaky
final container = makeContainer(
overrides: [storeRepositoryProvider.overrideWithValue(repository)],
);
addTearDown(container.dispose);
await container.read(storeListNotifierProvider.future).catchError((_) {});
final state = container.read(storeListNotifierProvider);
@@ -146,3 +247,8 @@ void main() {
```
集成测试用真实的(或半真实的、通过测试环境后端的)依赖跑通整条链路,不 mock 掉 repository——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。
## 待确认项
- 集成测试用的 UAT 测试账号/测试门店,以及数据可重复性由后端保证的方式。
- 覆盖率阈值 60% 是起点,跑一个迭代后按实际情况调。