Flutter自动化测试保姆级实践分享
Fair 动态化工具:
github:github.com/wuba/fair
借助AST ——> DSL 之间转化、解析,实现UI+Logic动态化的工具,为Flutter开发同学提供动态化方案。
单元测试:
Flutter apps test 分为 unittest、widget test、integration test
unittest
以上两个建议都看,很快就能看完
添加依赖库
dev_dependencies: test:
latest_version 代表版本,版本查看地址如下:
点击复制按钮,出现 提示,转移到目标yaml文件中粘贴即可:
使用时注意提示:
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:
需要有两个文件,目标文件和执行测试文件。
counter.dart 是要被测试的目标文件,counter_test.dart 是执行测试的文件。
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文件夹下,创建所有的要执行测试的文件
counter_app/
lib/
counter.dart
test/
counter_test.dart
test 文件夹是自动生成的,或许flutter项目创建时,也或许是test被引入时。
在执行测试文件中:
import 'package:test/test.dart';
如果flutter项目中并未找到,用如下代替:
import 'package:flutter_test/flutter_test.dart';
命令行运行:
flutter --no-color test --machine --start-paused --plain-name description test/counter_test.dart
右键直接运行:
测试通过后:
左侧框是测试对象信息,右侧是测试通过的梳理。
上述写法其实是有错误的(但是官方demo里就是这么写的,不知现在更新了没有),只要不出现变编译问题,测试结果都是通过,我们修改如下:
test('description', (){
final counter = Counter();
counter.increment();
expect(counter.value, 2, reason: "start at 1 返回值错误");
});
重新运行看结果:错误的提示信息也已打印出来 "sart at 1 返回值错误"
Combine multiple tests in a group:同时进行多项测试(一个测试失败不影响下一个继续)
注意 test.dart 引用失败用flutter_test(import 'package:flutter_test/flutter_test.dart';)
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);
});
});
}
注意以下三种写法的问题:第三种写法,不会爆出异常,而是测试通过。
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' 或者点击左侧运行按钮
Tests faile:1,passed:2, 测试通过2个,有一个case测试失败,未达到预期。左下角是每一个case 测试情况。差号的case 就是没通过。
命令行执行测试文件 .dart
flutter test test/counter_test.dart
会将main() 方法里面的四个test都走一遍,这个时候,不需要group,四个test并列也可以了。
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: "返回值错误");
});
});
}
命令行运行后效果:
最终日志意思是:3个测试成功,2个测试失败。
指定测试文件中的description执行测试
flutter --no-color test --machine --start-paused --plain-name Counter test/counter_test.dart
--plain-name:这个命令行,只会执行counter_test.dart 里面的 group('Counter') 的测试。
测试尝试如下:测试通过2例,1个case测试未通过。
Widgets_Tests
对这块熟悉的可以直接忽略跳到Fair测试需求模块
测试某个widget:
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进行重建
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.
个人感觉上面这句话比代码注释里解释的要好。
tester.pumpAndSettle();//刷新所有;比如多个widget、frames等
查找WidgetsTree里面某个widget:
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.
/// The [version] is a number that can be used to differentiate historical
/// golden files. This parameter is optional.
matchesGoldenFile 一般与expectLater 搭配。
/// await expectLater(
/// find.text('Save'),
/// matchesGoldenFile('save.png'),
/// );
/// await expectLater(
/// find.byType(MyWidget),
/// matchesGoldenFile('goldens/myWidget.png'),
/// );
测试WidgetsTree里面某个widget点击事件:
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大部分功能进行测试;
● 可以直接在设备上运行测试;
● 可以在Firebase Test Lab上进行集成测试.
lib/
...
integration_test/
foo_test.dart
bar_test.dart
test/
# Other unit tests go here.
运行指定的test.dart
flutter test integration_test/foo_test.dart -d
运行所有的:
flutter test integration_test
可以在TestLab里面进行集成测试:
Migrating from flutter_driver: 可忽略
Example:
driver:测试点击:
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 测试点击:
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 等工具库,可以支持如下测试:
Fair测试需求:
完整代码见:github.com/wuba/fair
具体路径如下:
Fair是将Flutter代码,结合analyze编译成json或者bin文件,动态进行页面或者组件的下发和加载解析。举个例子,appbar 写好后进行编译,并在动态加载解析中进行还原。开发过程需要进行多个case场景代码编写跑一遍,验证目标组件是否完整支持,自动化工具可以将这些场景汇总起来,方便对appbar所面临的所有场景case进行测试,验证Fair在appbar支持上的完整性,及时发现不支持的地方进行整改。
Fair动态化加载容易出问题的地方:
Fair对.json/.bin 文件解析时抛出的异常,tester也可以捕捉到,提示测试失败及日志
通过上面的梳理,对Fair中Widget加载测试,可以使用如下
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));// }
点击测试:
await tester.tap(find.byIcon(Icons.add));
正常Fair加载UI出错后,会弹框提示:“click show message”,因此通过判断是否出现弹框提示可以初步判断是否加载异常:
[{"action":"find.text","text":"click show message!"},{"action":"expect","expect":0}]
UI部分:
Logic部分:未完待续
核心处理:
操作分类:
可以将所有的操作进行统一封装,对外暴露实体类,用户通过配置json或者实体类数据,进行测试case配置。
核心代码
ConfigData代表一个操作case,integration_test_util 封装了对所有操作case的实现以及对ConfigData的解析。
举个例子:查找一个“Hello”的唯一文本框。
ConfigData(action:"find.text", text:"Hello")
代码实现:
我这边是从找到一个listview目标,进行拖拽查看底部item,点击这个item到新页面后检查是否加载正常,详情见上面 UI部分。
integration_test_util:
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:
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可以如下:
[{"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的执行.
在实际操作中,将JsonArray又封装成了一个实体类,代表一个组件包含的测试实例,以方便可以对多个组件同时组装成List进行测试。
配置说明:
action:对应的是tester里面支持的所有action,如:find.text("text"), find.byType(type:"ListView") 等等之类的。
效果检验:
这里只有一个widget的测试case:
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);
});
多个widget执行各自cases测试的情况:
团队介绍
Fair团队来自58集团开源小组,设计并实现了Flutter动态化的全流程解决方案——Fair的核心功能,把控模块的功能规划、特性引入和实现进度,当社区无法达成共识时做出最终决定。
如果对Flutter&Fair相关技术感兴趣,
欢迎大家加入我们,一起共建Fair,共建Flutter生态,也欢迎大家为我们点亮star~
Github地址:github.com/wuba/fair Fair官网:fair.58.com
转载自:https://juejin.cn/post/7231805092273422391