Flutter 必不可少的自动化测试

Flutter 必不可少的自动化测试

Flutter中包含了三种测试分类:

  • 单元测试 Unit Test
  • Widget测试 Widget Test
  • 集成测试 Integration Test

一般来说,一个测试良好的App应该包含很多的单元测试和Widget测试,来达到较高的代码覆盖率,然后再加上足够的集成测试来覆盖所有重要的使用场景。下图展示了三种测试分类从修复难度、外部依赖、执行速度、可信任度四个方面的特点,

Flutter 必不可少的自动化测试

下面分别说明三类测试的一般性规则和编写方式。
事先添加好测试相关的依赖:

dev_dependencies:
  flutter_test:
    sdk: flutter
  # 测试依赖
  # 单元测试
  test: ^1.14.4
  # 集成测试
  integration_test: ^1.0.1

单元测试

单元测试是测试一个单独的功能、方法或类。
单元测试目的是是验证一段代码逻辑在不同输入条件下的正确性。
如果在单元测试有外部依赖,一般通过mocked方式解决。
单元测试中一般不应读写磁盘、渲染屏幕、接收用户输入。

单元测试和Widget测试代码都放在test目录下。
假设有一个验证手机号格式的方法:

class ValidateUtil {
 ///验证手机号格式
  static Future<bool> isMobile(String mobile) async {
    if (mobile == null || mobile.length == 0) {
      return false;
    }
    RegExp mobileRegExp = RegExp(r"1\d{10}$");
    return mobileRegExp.hasMatch(mobile);
  }
}

为此方法添加一个单元测试如下:

import 'package:test/test.dart';

void main() {
  group('All validate util method test', () {
    test('One correct mobile should pass', () async {
      String mobile = '15330059740';
      bool result = await ValidateUtil.isMobile(mobile);
      expect(result, true);
    });
  });
}

执行单元测试的命令如下:

//执行指定文件中所有的测试用例
flutter test test/utils/validate_util_test.dart

//执行所有的单元测试
flutter test

执行结果如下图所示:

Flutter 必不可少的自动化测试

Widget测试

Widget测试,也可以叫做组件测试,是测试一个单独的UI控件。
Widget测试的目的是验证Widget是否显示正常、是否正常交互。
Widget测试依赖测试环境来提供Widget的声明周期上下文。
为Widget测试提供的测试环境,比正式的、完整的UI系统要简单的多。

Widget测试时Flutter独有的,可以在其中测试每个选中的单独的Widget。
和单元测试不同,Widget测试使用testWidget()方法声明一个测试用例,Widget测试使用TestFlutterWidgetBinding类来提供类似于真实运行环境的资源,例如屏幕尺寸、动画调度能力等。

Widget测试框架提供了finders查找器来查找指定的Widget(例如text(),byType(),byIcon()),从而验证是否与预期匹配。

下面以TabBar+TabBarView为例,测试点击Tab实现页面切换的场景。
界面如下所示:

Flutter 必不可少的自动化测试

void main() {
  BuildContext _context;

  //因为用到了多语言,所以需要在MaterialApp中初始化多语言配置
  //才能使用AppLocalizations.of(_context)获取文案信息,避免硬编码在代码里
  Widget wrapperAppEnv(Widget child) {
    return MaterialApp(
      home: new Builder(builder: (BuildContext context) {
        _context = context;
        return child;
      }),
      supportedLocales: AppLocalizations.supportedLocales,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
    );
  }

  group('Home Page Widget Tests', () {
    testWidgets('Test tab switch', (tester) async {
      //pumpWidget渲染指定的Widget
      await tester.pumpWidget(wrapperAppEnv(HomePage()));
      //断言有三个Tab类型的Widget
      expect(find.byType(Tab), findsNWidgets(3));
      //断言 默认显示了订阅方式设置页面
      expect(find.byType(MethodSet), findsOneWidget);

      String subsPaySetTitle = AppLocalizations.of(_context).subs_pay_set;
      //点击付费设置按钮
      await tester.tap(find.text(subsPaySetTitle));
      await tester.pumpAndSettle();
      //断言 显示了付费设置页面
      expect(find.byType(PaySet), findsOneWidget);

      String subsContentSetTitle =
          AppLocalizations.of(_context).subs_content_set;
      //点击内容设置按钮
      await tester.tap(find.text(subsContentSetTitle));
      await tester.pumpAndSettle();
      //断言 显示了订阅内容设置页面
      //根据类型查找ContentSet,居然失败,奇葩。。。
      // expect(find.byType(ContentSet), findsOneWidget);
      //只能找子Widget
      expect(find.text(AppLocalizations.of(_context).add_subscribe),
          findsOneWidget);

      //点击方式设置按钮
      await tester
          .tap(find.text(AppLocalizations.of(_context).subs_method_set));
      await tester.pumpAndSettle();
      //断言 显示了订阅方式设置页面
      expect(find.byType(MethodSet), findsOneWidget);
    });
  });
}
  1. 对Widget的操作可以通过WidgetTester类调用,例如滚动、点击、输入文本、页面返回等
  2. 查找指定Widget则通过find类调用,常用的Widget查找方式有:文本(find.text)、类型(find.byType)、图标(find.byIcon)、Widget对象(find.byWidget)等。如果都不能满足的话,可以自定义查找条件(byWidgetPredicate)。

执行测试的命令还是 flutter test。

集成测试

集成测试是测试一个完整的App或App的大部分功能。
集成测试的目的是验证所有的Widget和服务,可以按照预期正常的工作。甚至,可以通过集成测试来验证App的性能。
集成测试一定要运行在真机或模拟器上,处于测试中的App和测试代码是隔离的,以免影响测试结果。

这个integration_test包用来执行集成测试。integration_test包就是Flutter版本的Selenium WebDriver(通用Web测试),Protractor(Angular测试),Espresso(Android集成测试工具),或Earl Gray(iOS)。这个包内部使用flutter_driver来在设备上驱动测试。

为了能编写集成测试用例,必须先对应用程序进行检测(instrument the app)。Instrumenting the app意思是对App进行配置,以便驱动程序能够访问App的GUI和方法,然后运行自动化测试。集成测试的用例一般放在integration_test目录下。

1. 在项目根目录下创建integration_test目录,并添加测试驱动设置文件

目录结构如下:
Flutter 必不可少的自动化测试

driver.dart文件代码如下:

import 'package:integration_test/integration_test_driver.dart';

Future<void> main() async {
  ///启用集成测试驱动,然后等待测试用例执行
  await integrationDriver();
}

测试用例执行的结果存放在integration_response_data.json文件中

2.编写测试用例

在integration_test目录下创建app_test.dart文件,并添加如下代码:

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  group('Testing  App Performance Tests', () {
    //确保测试驱动初始化完成
    final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized()
        as IntegrationTestWidgetsFlutterBinding;
    //设置帧绘制策略,fullyLive即显示框架请求的每一帧,适用于有较多动画的测试场景
    binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
  });
}

以上代码用于在执行测试用例之前初始化测试驱动,以及配置测试过程中的策略。如帧绘制策略、网络请求策略、设备事件路由策略、超时时间等。

然后就可以再group中添加测试用例了,这里我们复用了Widget测试中的Tab切换的测试用例。

testWidgets('Test tab switch', (tester) async {
      //指定App的入口Widget
      await tester.pumpWidget(MyTestApp());
      //断言有三个Tab类型的Widget
      expect(find.byType(Tab), findsNWidgets(3));
      //断言 默认显示了订阅方式设置页面
      expect(find.byType(MethodSet), findsOneWidget);

      //watchPerformance()方法记录了测试用例的操作,并生成时间轴摘要,
      //然后作为响应数据发送回driver.dart文件中的测试驱动程序
      await binding.watchPerformance(() async {
        //点击付费设置按钮
        await tester.tap(find.text('付费管理'));
        await tester.pumpAndSettle();
        //断言 显示了付费设置页面
        expect(find.byType(PaySet), findsOneWidget);

        //点击内容设置按钮
        await tester.tap(find.text('关注设置'));
        await tester.pumpAndSettle();
        //断言 显示了订阅内容设置页面
        //根据类型查找ContentSet,居然失败,奇葩。。。
        // expect(find.byType(ContentSet), findsOneWidget);
        //只能找子Widget
        expect(find.text('添加关注'), findsOneWidget);

        //点击方式设置按钮
        await tester.tap(find.text('订阅设置'));
        await tester.pumpAndSettle();
        //断言 显示了订阅方式设置页面
        expect(find.byType(MethodSet), findsOneWidget);
      }, reportKey: 'tab_switch_summary');
    });

3.执行测试用例

 flutter drive --driver integration_test/driver.dart --target integration_test/app_test.dart --profile --device-id OE106

这里我是用一台Android手机执行了测试用例,如果需要在浏览器中运行话,需要额外安装Selenium的服务端。

查看测试结果

终端中出现以下日志,说明测试执行成功。
Flutter 必不可少的自动化测试

在build目录下产生integration_response_data.json文件,里面记录此次测试过程中的性能参数,例如平均每帧的绘制时间、90%帧的绘制时间、最差的那一帧绘制时间等。

Flutter 必不可少的自动化测试

4.问题

获取文本文案问题

和Widget测试一样,在集成测试时同样遇到了通过AppLocalizations.of(_context)获取文本文案的问题。
要想在测试用例中通过变量的方式获取对应文本文案,必须执行MaterialApp的构建,在里面添加本地化的配置。而且必须获取到BuildContext才能创建AppLocalizations的实例。
所以通过改造应用入口类(即runApp()方法传入的Widget),传入自定义的Widget作为MaterialApp的home属性,来实现获取BuildContext目的。

自定义应用入口类如下:

class MyApp extends StatelessWidget {
  final Widget home;

  const MyApp({Key key, this.home}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      onGenerateTitle: (context) => AppLocalizations.of(context).subs_auto,
      theme: ThemeData(
        // This is the theme of your application.
        //
        // Try running your application with "flutter run". You'll see the
        // application has a blue toolbar. Then, without quitting the app, try
        // changing the primarySwatch below to Colors.green and then invoke
        // "hot reload" (press "r" in the console where you ran "flutter run",
        // or simply save your changes to "hot reload" in a Flutter IDE).
        // Notice that the counter didn't reset back to zero; the application
        // is not restarted.
        primaryColor: Colors.blue,
        accentColor: Colors.blue,
      ),
      supportedLocales: AppLocalizations.supportedLocales,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      home: home == null ? HomePage() : home,
    );
  }
}

在执行测试用例时,将需要测试的Widget通过home参数传入进去,从而间接得到BuildContext实例,然后在渲染完成后,使用BuildContext构建AppLocalizations实例。

AppLocalizations appLocalizations;
BuildContext _context;
testWidgets('Test tab switch', (tester) async {
 //指定App的入口Widget
 await tester.pumpWidget(MyApp(
    //通过传入一个Builder,把BuildContext引用保存下来
   home: Builder(builder: (BuildContext context) {
     _context = context;
     return HomePage();
   }),
 ));
 //构造AppLocalizations实例
 appLocalizations = AppLocalizations.of(_context);

持续集成服务

Flutter官网介绍的持续集成服务有两种方式:

  • 第一种是fastlane工具与Appcicle/Travis/Cirrus云端的服务结合
  • 第二种是利用第三方的网页版的CI/CD服务,例如Codemagix/Bitrise

上面两种方案都是适用于没有自己的服务器,并且代码托管在Github上的。而且使用更高级的功能是需要付费的。如果代码库是私有的,例如自建的gitlab或Bitbucket,可以选择自建一个Jenkins服务,Jenkins有大量插件可用,需要一定的配置和维护。

对于项目初期,构建要求不是很高的时候,建议采用第二种方案,操作非常简单。随着项目扩大,则可以切换到自建Jenkins服务的方案上。

上一篇:Jmeter查看结果树之查看响应的13种方法[详解]


下一篇:项目经理每次都压缩测试周期!Tester做好这些就对了