Flutter Android 测试别只跑 flutter run:单元、Widget、集成测试与设备回归清单

如果你已经按前几篇把 Flutter Android 多环境配置实战:flavor、dart-define-from-file 与 CI 构建检查清单Flutter Android 发布前别只会点 Build:签名、AGP/JDK 兼容与 AAB 验证清单Flutter Android 冷启动怎么排查:profile 模式、首帧基线与主线程减负清单 跑通,你会很快遇到一个更现实的问题:每次改完登录、列表、缓存、原生插件或启动逻辑,团队还是只能靠 flutter run 手点几遍确认。手工联调能发现明显问题,但它不会稳定留下证据,也不能在 CI 里挡住回归。

这篇文章只补一件长期能力:把 Flutter Android 项目的回归验证拆成单元测试、Widget 测试、集成测试和 Android 设备回归四层,让每次提交都能留下命令、配置、日志和结果目录。目标不是把测试数量做大,而是把“哪些改动应该在哪一层被拦住”说清楚。

一、前置基础:什么时候该从手工联调进入测试基线

这套做法的适用阶段,是已经有真实业务页面和 Android 构建链路的团队,尤其是下面三种场景:

  1. 已经有 flavor、签名和发布命令,但每次发版前还要靠同一个同事手点登录、列表、详情和设置页。
  2. 最近开始做启动优化、崩溃排查或缓存重构,担心修一个点又把另一个点带坏。
  3. CI 已经能构建 APK 或 AAB,但还没有稳定的测试步骤,失败时只有构建日志,没有行为验证。

如果你现在还在频繁改页面结构、接口字段和路由命名,先不要追求“大而全”的测试平台。先把最核心的登录、首屏、离线缓存恢复、关键表单和一个 Android 真机烟测跑通,比一次性铺满所有页面更有价值。

二、本文新增能力:把测试责任按四层拆开

这篇文章新增的不是某一个测试框架,而是一套责任边界:

  1. 单元测试只盯纯 Dart 逻辑,例如模型转换、Repository 合并策略、表单校验器和状态机分支。
  2. Widget 测试验证页面局部交互,例如加载态、错误态、按钮禁用和滚动列表是否按预期渲染。
  3. 集成测试验证“从应用入口到关键流程结束”的完整路径,例如冷启动后自动恢复会话、进入首页、打开详情页再返回。
  4. Android 设备回归负责兜底系统层差异,例如模拟器 API level、动画、权限弹窗、原生插件桥接和 Gradle 任务执行结果。

Flutter 官方测试总览的建议也很明确:大量单元和 Widget 测试配合少量关键集成测试,才是可维护的比例。也就是说,不要把所有问题都塞进 integration_test,否则执行慢、定位难、日志噪音也大。

三、项目结构:目录、依赖和 Android 配置怎么放

先把目录固定下来,后面 CI 和日志归档才好做:

lib/
  app/
    bootstrap/
    router/
  features/
    session/
    feed/
    settings/
test/
  unit/
    session/
    feed/
  widget/
    session/
    feed/
integration_test/
  app_smoke_test.dart
  login_restore_test.dart
android/
  app/
    build.gradle.kts

pubspec.yaml 里先把测试依赖补齐:

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter

如果你的 Android 侧还没有稳定设备任务,可以在 android/app/build.gradle.kts 先配一个 managed device,后面让 CI 跑固定 API level:

android {
    testOptions {
        managedDevices {
            localDevices {
                create("pixel8api34") {
                    device = "Pixel 8"
                    apiLevel = 34
                    systemImageSource = "google"
                }
            }
        }
    }
}

这一步的价值不是“多一个 Gradle 配置”,而是把 Android 设备环境写进仓库,不再依赖某台同事机器上的临时模拟器名称。

把初始化步骤也固定下来,后面新人接手时会顺很多。一个最小可执行顺序可以写进团队 README:

  1. 拉代码后先执行 flutter pub get,确认测试依赖已经同步。
  2. 第一次跑 Android 设备任务前,先用 flutter doctor -vadb devices 看本机 SDK、模拟器和真机连接状态。
  3. 如果项目有 flavor,再确认 --dart-define-from-file、包名后缀和测试用后端地址没有串到生产环境。
  4. 把要归档的日志目录、覆盖率目录和截图目录提前约定好,不要等 CI 失败后再临时找文件。

四、关键实现:单元测试和 Widget 测试先拦逻辑回归

先从最便宜、最稳定的两层开始。比如会话恢复逻辑,经常因为 token 为空、缓存过期或接口返回 null 出回归,这类问题不该等到真机才发现:

void main() {
  test('session guard returns guest when cache is expired', () async {
    final repo = FakeSessionRepository(expiredAt: DateTime(2026, 7, 1));
    final result = await SessionGuard(repo).restore();
    expect(result.isLoggedIn, isFalse);
  });

  testWidgets('login button stays disabled before form is valid', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: LoginPage()));
    expect(find.text('登录'), findsOneWidget);
    expect(tester.widget<ElevatedButton>(find.byType(ElevatedButton)).onPressed, isNull);
  });
}

对应命令不要花哨,先固定成团队都能记住的两条:

flutter test --coverage
flutter test test/unit/session/session_guard_test.dart

coverage/lcov.info 可以交给 CI 收集;而失败时要保留最先报错的测试名,不要只截图 IDE。你真正需要的是能复现的日志,不是“我这里刚才点过是好的”。

如果登录页、列表页和设置页正在拆 feature 模块,Widget 测试特别适合拿来守住边界。它能在不启动完整应用的情况下验证按钮禁用、错误文案、空状态和局部滚动行为。对多数业务页面来说,这一层往往是性价比最高的回归拦截点。

五、关键实现:集成测试和 Android 设备回归拦跨层问题

当登录恢复、首页首屏、路由返回和本地缓存要一起工作时,再把它们抬到 integration_test/。建议先写一条真正贴近发版验收的烟测,而不是一上来把整个业务流程录成一长串点击脚本。

import 'package:integration_test/integration_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('cold start opens home and preserves feed tab', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    expect(find.text('首页'), findsOneWidget);
    await tester.tap(find.text('动态'));
    await tester.pumpAndSettle();
    expect(find.byKey(const ValueKey('feed-list')), findsOneWidget);
  });
}

Flutter 官方文档现在已经把 Android 真机运行方式收敛到:

flutter test integration_test/app_smoke_test.dart

如果你需要在 Android 侧额外验证 build variant 或原生桥接,再补两条命令:

cd android
./gradlew connectedStagingDebugAndroidTest
./gradlew pixel8api34StagingDebugAndroidTest

第一条适合接入已连接设备;第二条适合让 Gradle 按仓库里的 managed device 拉起固定模拟器。对于只在 Android 原生层失败的场景,例如插件 Activity 没注册、AndroidJUnitRunner 配置错误,也可以直接用 adb 重放:

adb shell am instrument -w com.example.app.test/androidx.test.runner.AndroidJUnitRunner

这里建议把执行步骤写死成一张小清单,而不是让每个人自由发挥:

  1. 先跑单元和 Widget 层,确认基础逻辑没有红。
  2. 再跑一条 integration_test 烟测,验证从应用入口到关键页面的主路径。
  3. 最后再跑 Android 设备任务,只检查权限、插件桥接、模拟器 API level 和系统动画等平台问题。

这个顺序能明显减少无效等待。否则你会经常在设备层花十几分钟,最后才发现只是一个纯 Dart 校验器条件写反了。

六、验证方式:让命令、报告和日志能被 CI 复用

一套测试有没有落地,关键看验证方式是否稳定。我的建议是把发布前验证压缩成下面四步:

  1. 本地先跑 flutter test --coverage,确认纯 Dart 和 Widget 层通过。
  2. 连上 Android 模拟器或真机,跑 flutter test integration_test/app_smoke_test.dart
  3. 进入 android/connected...AndroidTest 或 managed device 任务,专门看 Android 设备结果。
  4. 归档 coverage/lcov.info、Flutter 命令输出、Gradle HTML 报告路径和失败截图。

其中 Android 官方文档给出的 connectedAndroidTest 报告目录是 module/build/reports/androidTests/connected/。这类路径一定要写进 CI artifacts;否则团队只知道“昨晚红了”,不知道具体红在哪一层、哪一个设备和哪一个 build variant。

另外,测试设备要先关掉系统动画,不然集成测试容易因为过渡动画导致偶发失败。你可以手工在开发者选项里关闭,也可以在测试机初始化步骤里统一处理。

如果你们已经有发版前检查表,我建议把测试结果也写进同一份记录:哪条命令通过、哪条命令失败、失败时保留了什么日志、最终是否允许继续打包。把测试命令和发布命令放在一张清单里,团队才不会把“验证”理解成谁有空谁点一下。

七、常见避坑:不要把所有失败都归咎于 Flutter

这里最常见的坑有五个:

  1. 把接口 Mock、状态恢复、页面渲染和真机权限全塞进一个集成测试,结果执行慢且失败原因不清楚。
  2. 只写成功路径,不写缓存失效、空列表、401 重登和弱网超时,导致测试看起来很多,真正的风险分支却没人守。
  3. flavor 已经拆分,但测试命令没有带对应 build variant,最后 CI 跑的是默认 debug,线上用的是 staging 或 production。
  4. 只看控制台是否打印 All tests passed,不收集 HTML 报告和日志,复盘时无法定位具体 case。
  5. Android 设备动画、登录状态残留、上一次安装的测试包没卸干净,导致同一条脚本偶发红绿跳变。

真正的避坑重点不是“多写断言”,而是让每一层只承担它该承担的失败类型。

八、复盘清单:一次提交至少回答这五个问题

每次把测试接进仓库后,我会要求提交人自己复盘五件事:

  1. 这次改动最可能先在哪一层失败,是单元、Widget、集成还是 Android 设备层?
  2. 如果命令失败,日志里第一条有效报错是什么,能不能不打开 IDE 就复现?
  3. 这次新增的测试是覆盖了新能力,还是只把旧手工步骤机械搬进脚本?
  4. 报告目录、覆盖率文件和失败截图有没有进入 CI artifacts?
  5. 下次再改登录、缓存、启动或原生插件时,这套测试能否在 10 分钟内给出可执行结论?

只要这五个问题答不清,这套测试大概率还停留在“有人跑过”,没有进入“团队可依赖”。

九、下一步进阶方向

把这套基线跑稳以后,下一篇最自然的进阶不是继续堆测试数量,而是把 Android 设备回归和性能门槛接起来:例如基于固定设备做启动基线、滚动卡顿基线,或者把关键 smoke case 接进 CI 的分片执行。到那一步,测试就不只是兜底功能回归,而是开始参与发布门禁、性能复盘和长期维护。