Flutter自动化测试保姆级实践分享

Flutter自动化测试保姆级实践分享

Fair 动态化工具:

github:github.com/wuba/fair

借助AST ——> DSL 之间转化、解析,实现UI+Logic动态化的工具,为Flutter开发同学提供动态化方案。

图片[2]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

单元测试:

Flutter apps test 分为 unittest、widget test、integration test

unittest

入门参考地址:docs.flutter.dev/cookbook/te…

进阶参考:pub.dev/packages/te…

以上两个建议都看,很快就能看完

Integration Testing:docs.flutter.dev/testing/int…

添加依赖库

dev_dependencies: test: 

latest_version 代表版本,版本查看地址如下:

pub.dev/packages/te…

图片[3]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

点击复制按钮,出现 提示,转移到目标yaml文件中粘贴即可:

图片[4]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

使用时注意提示:

The current Dart SDK version is 2.17.0. Because my_flutter_driver depends on test >=1.21.5 which requires SDK version >=2.18.0-146.0.dev <3.0.0, version solving failed. pub get failed (1; Because my_flutter_driver depends on test >=1.21.5 which requires SDK version >=2.18.0-146.0.dev <3.0.0, version solving failed.)

我最终用了1.21.0:

图片[5]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

需要有两个文件,目标文件和执行测试文件

counter.dart 是要被测试的目标文件,counter_test.dart 是执行测试的文件。

csharp复制代码class Counter {
  int value = 0;

  void increment() => value++;

  void decrement() => value--;
}

In this example, create two files: counter.dart and counter_test.dart.

The counter.dart file contains a class that you want to test and resides in the lib folder. The counter_test.dart file contains the tests themselves and lives inside the test folder.

In general, test files should reside inside a test folder located at the root of your Flutter application or package. Test files should always end with _test.dart, this is the convention used by the test runner when searching for tests.

When you’re finished, the folder structure should look like this:

意思是,我们应该在lib文件夹下创建目标文件counter.dart,在test文件夹下,创建所有的要执行测试的文件

bash复制代码counter_app/
  lib/
    counter.dart
  test/
    counter_test.dart

test 文件夹是自动生成的,或许flutter项目创建时,也或许是test被引入时。

图片[6]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

在执行测试文件中:

arduino复制代码import 'package:test/test.dart';

如果flutter项目中并未找到,用如下代替:

arduino复制代码import 'package:flutter_test/flutter_test.dart';

命令行运行:

css复制代码flutter --no-color test --machine --start-paused --plain-name description test/counter_test.dart
图片[7]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

右键直接运行:

图片[8]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

测试通过后:

左侧框是测试对象信息,右侧是测试通过的梳理。

上述写法其实是有错误的(但是官方demo里就是这么写的,不知现在更新了没有),只要不出现变编译问题,测试结果都是通过,我们修改如下:

scss复制代码test('description', (){
    final counter = Counter();
    counter.increment();
    expect(counter.value, 2, reason: "start at 1 返回值错误");
  });

重新运行看结果:错误的提示信息也已打印出来 “sart at 1 返回值错误”

图片[9]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

Combine multiple tests in a group:同时进行多项测试(一个测试失败不影响下一个继续)

注意 test.dart 引用失败用flutter_test(import ‘package:flutter_test/flutter_test.dart’;)

ini复制代码import 'package:counter_app/counter.dart';
import 'package:test/test.dart';

void main() {
  group('Counter', () {
    test('value should start at 0', () {
      expect(Counter().value, 0);
    });

    test('value should be incremented', () {
      final counter = Counter();

      counter.increment();

      expect(counter.value, 1);
    });

    test('value should be decremented', () {
      final counter = Counter();

      counter.decrement();

      expect(counter.value, -1);
    });
  });
}

注意以下三种写法的问题:第三种写法,不会爆出异常,而是测试通过。

scss复制代码group('Counter', () {
  test('value should start at 0', () => {
    expect(Counter().value, 0, reason: "start at 0 返回值错误")
  });
  test('value should be incremented', () {
    final counter = Counter();
    counter.increment();
    expect(counter.value, 1);
  });
  test('value should be decremented', () => (){
      final  counter = Counter();
      counter.decrement();
      expect(counter.value, 20, reason: "返回值错误");
  });
});

IDE运行:右侧 Run ‘Counter’ 或者点击左侧运行按钮

图片[10]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

Tests faile:1,passed:2, 测试通过2个,有一个case测试失败,未达到预期。左下角是每一个case 测试情况。差号的case 就是没通过。

图片[11]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

命令行执行测试文件 .dart

flutter test test/counter_test.dart

会将main() 方法里面的四个test都走一遍,这个时候,不需要group,四个test并列也可以了。

ini复制代码import 'package:flutter_test/flutter_test.dart';
import '../lib/counter.dart';

void main() {
  test('description', () => (){
    final counter = Counter();
    counter.increment();
    expect(counter.value, 1);
  });

  group('Counter', () {
    test('value should start at 0', () => {
      expect(Counter().value, 0, reason: "start at 0 返回值错误")
    });
    test('value should be incremented', () {
      final counter = Counter();
      counter.increment();
      expect(counter.value, 1);
    });
    test('value should be decremented', () => (){
        final  counter = Counter();
        counter.decrement();
        expect(counter.value, 20, reason: "返回值错误");
    });
  });

}

命令行运行后效果:

图片[12]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

最终日志意思是:3个测试成功,2个测试失败。

指定测试文件中的description执行测试

css复制代码flutter --no-color test --machine --start-paused --plain-name Counter test/counter_test.dart

–plain-name:这个命令行,只会执行counter_test.dart 里面的 group(‘Counter’) 的测试。

测试尝试如下:测试通过2例,1个case测试未通过。

图片[13]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

Widgets_Tests

参考文档:docs.flutter.dev/cookbook/te…

对这块熟悉的可以直接忽略跳到Fair测试需求模块

测试某个widget:

csharp复制代码testWidgets('Counter increments smoke test', (WidgetTester tester) async {
    // Build our app and trigger a frame.
    await tester.pumpWidget(const MyApp());
  });

相较于test,testWidgets多了tester,允许对widget进行更多操作。

pumpWidget:

对目标widget进行重建

scss复制代码tester.pump();//立即刷新

pump(Duration duration): 刷新tester里面的东西,比如刷新widget

官方解释:

Repeatedly calls pump() with the given duration until there are no longer any frames scheduled. This, essentially, waits for all animations to complete.

个人感觉上面这句话比代码注释里解释的要好。

scss复制代码tester.pumpAndSettle();//刷新所有;比如多个widget、frames等

查找WidgetsTree里面某个widget:

scss复制代码testWidgets('MyWidget has a title and message', (tester) async {
    await tester.pumpWidget(const MyWidget(title: 'T', message: 'M'));
    final titleFinder = find.text('T');
    
    expect(titleFinder, findsOneWidget);//有且仅有一个目标widget
    expect(titleFinder, findsNothing);//一个都没有
    expect(titleFinder, findsWidgets);//有一个或者多个
    expect(titleFinder, findsNWidgets(10));//有10个
    expect(find.text('0'), matchesGoldenFile(key, version: 10));//
}

matchesGoldenFile:

matchesGoldenFile(key, version: 10):Verifies that a widget’s rendering matches a particular bitmap image (“golden file” testing).

/// The [key] may be either a [Uri] or a [String] representation of a URL.

vbnet复制代码 /// The [version] is a number that can be used to differentiate historical
/// golden files. This parameter is optional.

matchesGoldenFile 一般与expectLater 搭配。

perl复制代码 /// await expectLater(
///   find.text('Save'),
///   matchesGoldenFile('save.png'),
/// ); 
ruby复制代码 /// await expectLater(
///   find.byType(MyWidget),
///   matchesGoldenFile('goldens/myWidget.png'),
/// ); 

测试WidgetsTree里面某个widget点击事件:

csharp复制代码await tester.tap(find.byIcon(Icons.add));

其它:

tapAt(): 指定位置触摸; tap() 默认执行于中心位置:topAt(location:center).

press(): 与tap()不同的是,返回gesture对象,并允许继续操作。tap() 是一个void函数。

更多:

longPress()、longPressAt()、fling()、flingFrom()、drag()、dragFrom()、timeDrag()、timeDragFrom()。

filing() 会引发pump刷新。

Integration_tests:

● 对App或者App大部分功能进行测试;

● 可以直接在设备上运行测试;

● 与widget tests写法基本一致;

● 可以在Firebase Test Lab上进行集成测试.

● 参见:docs.flutter.dev/testing/int…

bash复制代码lib/
  ...
integration_test/
  foo_test.dart
  bar_test.dart
test/
  # Other unit tests go here.

运行指定的test.dart

bash复制代码flutter test integration_test/foo_test.dart -d 

运行所有的:

bash复制代码flutter test integration_test

详细使用参见:github.com/flutter/flu…

可以在TestLab里面进行集成测试:

Migrating from flutter_driver: 可忽略

参见:docs.flutter.dev/testing/int…

Example:

参见:github.com/flutter/web…

driver:测试点击:

vbnet复制代码test('tap on the first item (Alder), verify selected', () async {
  // find the item by text
  final item = find.text('Alder');

  // Wait for the list item to appear.
  await driver.waitFor(item);

  // Emulate a tap on the tile item.
  await driver.tap(item);

  // Wait for species name to be displayed
  await driver.waitFor(find.text('Alnus'));

  // 'please select' text should not be displayed
  await driver
      .waitForAbsent(find.text('Please select a plant from the list.'));
});

integration_test 测试点击:

scss复制代码testWidgets('tap on the first item (Alder), verify selected',
    (tester) async {
  await tester.pumpWidget(const PlantsApp());

  // wait for data to load
  await tester.pumpAndSettle();

  // find the item by text
  final item = find.text('Alder');

  // assert item is found
  expect(item, findsOneWidget);

  // Emulate a tap on the tile item.
  await tester.tap(item);
  await tester.pumpAndSettle();

  // Species name should be displayed
  expect(find.text('Alnus'), findsOneWidget);

  // 'please select' text should not be displayed
  expect(find.text('Please select a plant from the list.'), findsNothing);
});

通过Flutter官方的支持,引用test/integeration_test/driver 等工具库,可以支持如下测试:

图片[14]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

Fair测试需求:

完整代码见:github.com/wuba/fair

具体路径如下:

图片[15]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

Fair是将Flutter代码,结合analyze编译成json或者bin文件,动态进行页面或者组件的下发和加载解析。举个例子,appbar 写好后进行编译,并在动态加载解析中进行还原。开发过程需要进行多个case场景代码编写跑一遍,验证目标组件是否完整支持,自动化工具可以将这些场景汇总起来,方便对appbar所面临的所有场景case进行测试,验证Fair在appbar支持上的完整性,及时发现不支持的地方进行整改。

图片[16]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

Fair动态化加载容易出问题的地方:

图片[17]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

Fair对.json/.bin 文件解析时抛出的异常,tester也可以捕捉到,提示测试失败及日志

图片[18]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

通过上面的梳理,对Fair中Widget加载测试,可以使用如下

scss复制代码testWidgets('MyWidget has a title and message', (tester) async {
    await tester.pumpWidget(const MyWidget(title: 'T', message: 'M'));
    final titleFinder = find.text('T');          
    expect(titleFinder, findsOneWidget);//有且仅有一个目标widget    
    expect(titleFinder, findsNothing);//一个都没有     
    expect(titleFinder, findsWidgets);//有一个或者多个     
    expect(titleFinder, findsNWidgets(10));//有10个     
    expect(find.text('0'),
    matchesGoldenFile(key, version: 10));// } 

点击测试:

csharp复制代码await tester.tap(find.byIcon(Icons.add));

正常Fair加载UI出错后,会弹框提示:“click show message”,因此通过判断是否出现弹框提示可以初步判断是否加载异常:

css复制代码[{"action":"find.text","text":"click show message!"},{"action":"expect","expect":0}]

UI部分:

图片[19]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

Logic部分:未完待续

核心处理:

操作分类:

可以将所有的操作进行统一封装,对外暴露实体类,用户通过配置json或者实体类数据,进行测试case配置。

图片[20]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

核心代码

图片[21]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

ConfigData代表一个操作case,integration_test_util 封装了对所有操作case的实现以及对ConfigData的解析。

举个例子:查找一个“Hello”的唯一文本框。

ConfigData(action:”find.text”, text:”Hello”) 

图片[22]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

代码实现:

我这边是从找到一个listview目标,进行拖拽查看底部item,点击这个item到新页面后检查是否加载正常,详情见上面 UI部分。

integration_test_util:

kotlin复制代码Future<Finder?> commonTestByConfigData(
    ConfigData configData,
    WidgetTester tester,
    IntegrationTestWidgetsFlutterBinding binding,
    Finder? finder) async {
  switch (configData.action) {
    case 'pumpAndSettle':
      await tester.pumpAndSettle();
      break;
    case 'find.text':
      finder = find.text(configData.text);
      break;
    case 'tap':
      if (finder != null) {
        await tester.tap(finder);
      } else {
        print("暂不支持 tap finder is null");
      }
      break;
    case 'drag':
      if (finder != null) {
        await tester.drag(
            finder, Offset(configData.offsetX, configData.offsetY));
      } else {
        print("暂不支持 drag finder is null");
      }
      break;
    case 'pump':
      await tester.pump();
      break;
    case 'find.byType':
      switch (configData.type) {
        case 'Text':
          finder = find.byType(Text);
          break;
        case 'ListView':
          finder = find.byType(ListView);
          break;
        case 'Drawer':
          finder = find.byType(Drawer);
          break;
        default:
          print("暂不支持 type " + configData.type);
          break;
      }
      break;
       case 'expect':
      if (finder != null) {
        switch (configData.expect) {
          case 0:
            expect(finder, findsNothing);
            break;
          case 1:
            expect(finder, findsOneWidget);
            break;
          default:
            expect(finder, findsNWidgets(configData.expect));
            break;
        }
      } else {
        print("暂不支持 expect finder is null");
      }
      break;
    default:
      print("暂不支持 action " + configData.action);
      break;
  }
  return Future.value(finder);
}

configdat:

ini复制代码class ConfigData{
  // 事件类型  pumpAndSettle(刷新跳转页面) 、pump(刷新页面)、tap(点击)、enterText(输入文字 必须有text)、drag(拖动必须有 offsetX 、offsetX)、find.text(文字查找 必须有text)、find.byKey(查找元素 必须有key)、find.byType(查找元素 必须有type)、expect(预期 必须有expect )、takeScreenshot (截屏)、delayed (延时)
  String action = '';
  // key
  String key = '';
  // 元素类型  (Image、Text、Icon、TextField、ListView 、ListTile、Drawer)
  String type = '';
  // 文字
  String text = '';
  // 期望数量  _FindsWidgetMatcher(null, 0)
  int expect = 0;
  // drag offset.dx
  double offsetX = 0.0;
  // drag offset.dy
  double offsetY = 0.0;
}

一个组件需要执行的测试case往往不止一个,也就是说,不止一个ConfigData需要生成,因此,针对一个组件的一次完整测试,可以封装成一个JsonArray,从该JsonArray中解析出多个ConfigData来。

JsonArray可以如下:

css复制代码[{"action":"pumpAndSettle"},{"action":"delayed"},{"action":"find.text", "text": "fair 模板代码","expect":1},{"action":"expect","expect":1},{"action":"tap"},{"action":"delayed"},{"action":"pump"},{"action":"find.text","text":"click show message!"},{"action":"expect","expect":0},{"action":"find.text","text":"Drawer >>>"},{"action":"expect", "expect":1}]

上面这段Json数组意思为:

pumpAndSettle(刷新页面) —> delayed (延迟操作,目的是等待页面完全渲染结束) —> 找到”fair 模板代码”的文本框—> 验证查找结果,满足有且只有1个 —> tap: 点击这个文本框 —> delayed(延迟操作,目的是等待点击后出现的页面刷新)—>找到’click show message!’的文本框 —> 验证结果,有且只有0个 —>找到’Drawer >>>’的文本框—>验证结果,有且只有一个。

那么,完整处理步骤就可以变成:

JsonArray ——> for.each ——> Json ——> 解析成ConfigData ——> test_util:解析ConfigData,并完成一次测试Case的执行.

图片[23]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

在实际操作中,将JsonArray又封装成了一个实体类,代表一个组件包含的测试实例,以方便可以对多个组件同时组装成List进行测试。

配置说明:

action:对应的是tester里面支持的所有action,如:find.text(“text”), find.byType(type:”ListView”) 等等之类的。

效果检验:

这里只有一个widget的测试case:

scss复制代码final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized() as IntegrationTestWidgetsFlutterBinding;


  testWidgets("通用测试", (tester) async {
    app.main();
    await tester.pumpAndSettle();
    await binding.convertFlutterSurfaceToImage();
    var jsonArray = '[{"action":"delayed"},{"action":"pumpAndSettle"},{"action":"delayed"},{"action":"find.text", "text": "fair 模板代码","expect":1},{"action":"expect","expect":1},{"action":"tap"},{"action":"delayed"},{"action":"pump"},{"action":"find.text", "text": "Drawer >>>"},{"action":"expect", "expect":1},{"action":"tap"},{"action":"delayed"},{"action":"pump"},{"action":"takeScreenshot"},{"action":"find.text","text":"click show message!"},{"action":"expect","expect":0}]';
    await integrationTestByJson( tester, binding,jsonArray);
  });
图片[24]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

多个widget执行各自cases测试的情况:

图片[25]专注Flutter相关技术及工具Flutter自动化测试保姆级实践分享专注Flutter相关技术及工具Flutter经验之谈

团队介绍

Fair团队来自58集团开源小组,设计并实现了Flutter动态化的全流程解决方案——Fair的核心功能,把控模块的功能规划、特性引入和实现进度,当社区无法达成共识时做出最终决定。

如果对Flutter&Fair相关技术感兴趣,

欢迎大家加入我们,一起共建Fair,共建Flutter生态,也欢迎大家为我们点亮star~

Github地址:github.com/wuba/fair
Fair官网:fair.58.com

THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容