Flutter自动化测试

1,173 阅读8分钟

前言

通常一个项目的功能越多,手工测试就越困难,我们可以通过自动化测试来提高程序的稳定性和功能的完整性,并且还可以快速发现和定位问题,提高开发效率,一套完整的自动化测试有助于应用程序在发布前的正确性。

Flutter官方自动化测试分为以下三类:

  • 单元测试 测试单一的函数,方法或类。
  • Widget 测试(在其他 UI 框架中指 组件测试)测试单一的 widget 。
  • 集成测试 测试一个完整的应用或者一个应用的大部分功能。

单元测试

添加插件依赖

dev_dependencies:
  flutter_test:
    sdk: flutter

在flutter_test包里面,我们可通过执行顶级函数 expect 来检查结果正确与否,它必须要传入两个参数,然后去比较这两个参数是否一致,我们可以把实际值和期望匹配值传入进行单元测试。如果需要模拟线上环境数据,还可以使用 Mockito 模拟依赖关系。

创建一个类

在lib目录之下,创建文件,以LoginModel类为例

/// 会员模型
class LoginModel {
  String userId = '';
  String password = '';
  String accessToken = '';
  String verifyToken = '';
  String expireTime = '';
  String role = '';
  String realName = '';
  String mobile = '';

  Map<String, dynamic> toMap() {
    var map = <String, dynamic>{
      UserColumn.accountKey: UserColumn.accountValue,
      UserColumn.userId: userId,
      UserColumn.password: password,
      UserColumn.accessToken: accessToken,
      UserColumn.verifyToken: verifyToken,
      UserColumn.expireTime: expireTime,
      UserColumn.realName: realName,
      UserColumn.role: role,
      UserColumn.mobile: mobile,
    };
    return map;
  }

  LoginModel.fromMap(Map map) {
    userId = ObtainValueUtil.getString(map, UserColumn.userId);
    password = ObtainValueUtil.getString(map, UserColumn.password);
    accessToken = ObtainValueUtil.getString(map, UserColumn.accessToken);
    verifyToken = ObtainValueUtil.getString(map, UserColumn.verifyToken);
    expireTime = ObtainValueUtil.getString(map, UserColumn.expireTime);
    realName = ObtainValueUtil.getString(map, UserColumn.realName);
    role = ObtainValueUtil.getString(map, UserColumn.role);
    mobile = ObtainValueUtil.getString(map, UserColumn.mobile);
  }
}

/// 会员
class UserColumn {
  static const String accountKey = 'account_key';
  static const String accountValue = 'unique';
  static const String userId = 'user_id';
  static const String password = 'password';
  static const String accessToken = 'access_token';
  static const String verifyToken = 'verify_token';
  static const String expireTime = 'expire_time';
  static const String role = 'role';
  static const String realName = 'real_name';
  static const String mobile = 'mobile';  
}

/// 数值获取工具类
class ObtainValueUtil {
  /// 获取数值(最终转化为字符串类型)
  static String getString(Map? map, String? key, {String defaultValue = ''}) {
    String value = defaultValue;
    if (null != map && null != key && map.containsKey(key)) {
      value = (map[key]).toString();
      if (value.isEmpty) {
        value = defaultValue;
      }
    }
    return value;
  }
}

创建测试用例

为了测试LoginModel,在test目录之下,创建文件login_model_test.dart,单个测试示例:

void main() {
  test('The field value should match', () {
    final String userId = '1001';
    final String password = 'abc123456';
    final String accessToken = 'zhe-klo-uud-efc-afg-ddx-exo';
    final String verifyToken = '006258';
    final String expireTime = '1669885423';
    final String realName = '张三';
    final String role = '租客';
    final String mobile = '13510923685';
    final Map map = {
      UserColumn.userId: userId,
      UserColumn.password: password,
      UserColumn.accessToken: accessToken,
      UserColumn.verifyToken: verifyToken,
      UserColumn.expireTime: expireTime,
      UserColumn.realName: realName,
      UserColumn.role: role,
      UserColumn.mobile: mobile
    };
    LoginModel loginModel = LoginModel.fromMap(map);
    loginModel.toMap();
    expect(loginModel.userId, userId);
    expect(loginModel.password, password);
    expect(loginModel.accessToken, accessToken);
    expect(loginModel.verifyToken, verifyToken);
    expect(loginModel.expireTime, expireTime);
    expect(loginModel.realName, realName);
    expect(loginModel.role, role);
    expect(loginModel.mobile, mobile);
  });
}

或者在test目录之下,创建文件login_model_test.dart,整合多个测试到一个group示例:

void main() {
  group('LoginModel', () {
    final String userId = '1001';
    final String password = 'abc123456';
    final String accessToken = 'zhe-klo-uud-efc-afg-ddx-exo';
    final String verifyToken = '006258';
    final String expireTime = '1669885423';
    final String realName = '张三';
    final String role = '租客';
    final String mobile = '13510923685';
    final Map map = {
      UserColumn.userId: userId,
      UserColumn.password: password,
      UserColumn.accessToken: accessToken,
      UserColumn.verifyToken: verifyToken,
      UserColumn.expireTime: expireTime,
      UserColumn.realName: realName,
      UserColumn.role: role,
      UserColumn.mobile: mobile
    };
    LoginModel loginModel = LoginModel.fromMap(map);
    loginModel.toMap();
    test('userId should match', () {
      expect(loginModel.userId, userId);
    });
    test('password should match', () {
      expect(loginModel.password, password);
    });
    test('accessToken should match', () {
      expect(loginModel.accessToken, accessToken);
    });
    test('verifyToken should match', () {
      expect(loginModel.verifyToken, verifyToken);
    });
    test('expireTime should match', () {
      expect(loginModel.expireTime, expireTime);
    });
    test('realName should match', () {
      expect(loginModel.realName, realName);
    });
    test('role should match', () {
      expect(loginModel.role, role);
    });
    test('mobile should match', () {
      expect(loginModel.mobile, mobile);
    });
  });
}

在终端执行测试

flutter test test/login_model_test.dart

image.png

image.png

其中(图1)表明一个测试通过,(图2)表明8个测试都通过。通过以上测试,我们可以知道这个LoginModel类没有问题。

通过测试发现问题

LoginModel已经通过了测试,我们再测试一下登陆方法及API

  /// 验证登陆方法
  verifyLoginApi() {
    test('Verify login api', () async {
      LoginLogic logic = LoginLogic();
      await logic.login('15222******', password: '******', callBack: (Map map) {
        String status = ObtainValueUtil.getString(map, 'status');
        expect(status, '1');
      });
    });
  }

在终端运行之后:

震惊!!!我们通过单元测试发现了问题:

MissingPluginException(No implementation found for method getApplicationDocumentsDirectory on channel plugins.flutter.io/path_provider_macos)

以上意思是说在macos平台找不到方法 getApplicationDocumentsDirectory 的实现,既然知道了原因那就好办,加上Platform.isMacOS的判断,如果你的应用程式不需要支持macos可忽略该问题。

小结

从长远来看,当遇到功能重构或新增时,那么之前已经开发好的一些功能我们就可以通过单元测试进行自测,可以提高程式质量和开发效率。单元测试不需要编译打包,我们可以通过编写测试用例来检查代码的质量,提高代码的可靠性,在验证的过程中,开发人员又可以深度了解业务流程,单元测试专注于应用程序的逻辑,通过单元测试,我们可以发现一些问题,例如功能性问题/逻辑性问题/兼容性问题等等,我们可以通过修复这些问题,使得我们的应用程式变得更健壮。

Widget测试

添加插件依赖

dev_dependencies:
  flutter_test:
    sdk: flutter

创建Widget类

import 'package:flutter/material.dart';
import 'package:flutter_common/config/application.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';

/// 测试Widget(仅用于测试)
class TestWidget extends StatelessWidget {
  final Widget? body;
  final PreferredSizeWidget? appBar;

  const TestWidget({Key? key, this.appBar, this.body}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ScreenUtilInit(
        designSize: const Size(375, 667),
        builder: () => MaterialApp(
          navigatorKey: Application().navigatorKey,
          home: Scaffold(
            appBar: appBar,
            body: body,
          ),
        ));
  }
}

创建测试用例

/// widget测试入口
void main() {
  var commonWidget = CommonWidgetTest();
  commonWidget.testAppBar();
  commonWidget.testTextView();
  commonWidget.testButtonView();
}
/// 通用组件相关测试用例
class CommonWidgetTest {
  /// 测试AppBar
  testAppBar() async {
    testWidgets('testAppBar', (WidgetTester tester) async {
      WidgetsFlutterBinding.ensureInitialized();
      final text = 'AppBar';
      final appBar = AppBarWidget.customTitle(Text(text));
      await tester
          .pumpWidget(TestWidget(appBar: appBar, body: BodyLoadingWidget()));
      final textFinder = find.text(text);
      expect(textFinder, findsOneWidget);
    });
  }

  /// 测试TextView
  testTextView() async {
    testWidgets('textViewTest', (WidgetTester tester) async {
      final text = 'text';
      final body =
          TextView(text, style: TextStyle(color: Colors.black, fontSize: 16));
      await tester.pumpWidget(TestWidget(body: body));
      final textFinder = find.text(text);
      expect(textFinder, findsOneWidget);
    });
  }

  /// 测试ButtonView
  testButtonView() async {
    testWidgets('buttonViewTest', (WidgetTester tester) async {
      final text = 'ClickTest';
      final body = ButtonView(text,
          width: 96,
          height: 48,
          style: TextStyle(color: Colors.white, fontSize: 16), onPressed: () {
        print('this button view was clicked');
      });
      await tester.pumpWidget(TestWidget(body: body));
      final textFinder = find.text(text);
      expect(textFinder, findsOneWidget);
      await tester.tap(find.byType(MaterialButton));
      await tester.pump();
    });
  }
}

其中AppBarWidget/BodyLoadingWidget/TextView/ButtonView都是自定义的widget

在终端执行测试

flutter test test/widget/main.dart

以上widget皆通过测试

Widget测试常用API

expect

actual:实际值,可以是任意对象

matcher:预期值,可以是任意对象

使用 Matcher 验证 widget 是否正常工作

Matcher常量说明:

  • findsNothing 验证没有可被查找的 widgets
  • findsOneWidget 验证一个widgets 被找到
  • findsWidgets 验证一个或多个 widgets 被找到
  • findsNWidgets 验证特定数量的 widgets 被找到
  • matchesGoldenFile 验证渲染的 widget 是否与特定的图像匹配(「目标文件」测试)

testWidgets

description:方法描述

callback: WidgetTesterCallback回调

flutter_test提供了testWidgets()函数可以定义一个widget测试,并创建一个可以使用的 WidgetTester

WidgetTester

tap():模拟执行点击事件

drag():模拟执行拖动事件

longPress():模拟执行长按事件

enterText():模拟执行编辑框文本输入事件

dragUntilVisible():拖动到指定widget可见的位置

scrollUntilVisible():滚动到指定widget可见的位置

showKeyboard():弹出输入法键盘

pumpWidget():建立并渲染我们提供的 widget

pump():触发 widget 的重建

pumpAndSettle():在给定期间内不断重复调用 pump 直到完成所有绘制帧

Finder

byKey():使用具体 Key 查找 widget

byType():使用类型查找widget

text():通过指定文本内容找到text widget

byTooltip():通过tooltip属性查找widget

image():通过ImageProvider对象查找图像

byIcon():通过IconData对象查找图标

byWidget():通过给定的widget实例查找widget

小结

和单元测试不同,Widget测试使用testWidget()方法声明一个测试用例,Widget测试具有一定局限性,测试的widget必须要能独立运行,否则无法通过测试,对于不能独立运行的widget,如果可以模拟其依赖环境,也是能进行widget测试的。widget测试可以验证该widget是否满足功能,以及交互是否正常。

集成测试

添加插件依赖

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

集成测试(也称为端到端测试或 GUI 测试),Flutter页面无法直接使用Native测试工具定位元素,于是Google官方推出了Flutter driver 和 Integration test,但它不适用于混合栈APP。从 flutter_driver 迁移

创建测试驱动

在项目的根目录下创建/test_driver目录,然后在该目录下再创建integration_test.dart文件

/test_driver/integration_test.dart:

import 'package:integration_test/integration_test_driver.dart';

Future<void> main() => integrationDriver();

创建测试用例

在项目的根目录下创建/integration_test目录,然后在该目录下再创建xxx.dart文件

这里我们测试一下启动app>点击跳过>点击会员中心>点击登陆按钮

import 'package:flutter/material.dart';
import 'package:flutter_xxx/main.dart' as app;
import 'package:flutter_common/config/widget_key.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

/// 集成测试入口
void main() {
  // 初始化
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('test app', (WidgetTester tester) async {
    // 构建应用
    app.main();
    // 因为项目中有初始化第三方sdk,故延迟两秒后重建
    await tester.pump(Duration(seconds: 2));
    // 根据类型找到跳转按钮对应的widget
    final Finder countdown = find.byType(MaterialButton);
    // 是否能找到这样的一个widget
    expect(countdown, findsOneWidget);
    // 执行点击事件
    await tester.tap(countdown);
    // 页面重建
    await tester.pumpAndSettle();
    // 停留1.5秒观赏一下界面
    await Future.delayed(Duration(milliseconds: 1500));
    // 找到底部导航:会员中心
    final Finder bottomNavigationBarItem =
        find.byTooltip('会员中心');
    // 模拟点击跳转到会员中心
    await tester.tap(bottomNavigationBarItem);
    // 页面重建
    await tester.pumpAndSettle();
    // 根据Key找到跳转登陆页面的按钮
    final Finder login = find.byKey(Key(WidgetKey.keyLogin));
    // 执行登陆按钮点击
    await tester.tap(login);
    // 页面重建
    await tester.pumpAndSettle();
  });
}

在终端执行测试

flutter drive --driver=test_driver/integration_test.dart --target=integration_test/xxx.dart

小结

单元测试和widget测试在测试单一的功能或组件时非常方便,但是它们并不适合对项目整体进行测试,集成测试 可以测试一个完整的应用或者一个应用的大部分功能。集成测试的目标是验证正在测试的所有 widget 和服务是否按照预期的方式一起工作。

--driver 用于指定测试驱动的路径

--targe 用于指定测试用例的路径

另外,我们也可以通过第三方自动化测试框架帮助我们完成自动化测试,例如:

flutter_ui_auto_test :贝壳 flutter UI 自动化测试

appium-flutter-driver :Flutter自动化测试工具,是 Appium 移动端测试工具的一部分

总结

单元测试的维护成本最低、依赖程度最少、执行速度最快,widget测试的依赖程度会相对较高一点,而集成测试会更全面一点,也是执行速度最耗时的。自动化测试有助于节省人力时间和提高测试效率,不论单元测试、widget测试还是集成测试,都需要熟悉dart语言,并且有一定的编码能力。