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:
+110
-4
@@ -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% 是起点,跑一个迭代后按实际情况调。
|
||||
|
||||
Reference in New Issue
Block a user