Flutter 必不可少的自动化测试
Flutter中包含了三种测试分类:
- 单元测试 Unit Test
- Widget测试 Widget Test
- 集成测试 Integration Test
一般来说,一个测试良好的App应该包含很多的单元测试和Widget测试,来达到较高的代码覆盖率,然后再加上足够的集成测试来覆盖所有重要的使用场景。下图展示了三种测试分类从修复难度、外部依赖、执行速度、可信任度四个方面的特点,
下面分别说明三类测试的一般性规则和编写方式。
事先添加好测试相关的依赖:
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
执行结果如下图所示:
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实现页面切换的场景。
界面如下所示:
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);
});
});
}
- 对Widget的操作可以通过WidgetTester类调用,例如滚动、点击、输入文本、页面返回等
- 查找指定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目录,并添加测试驱动设置文件
目录结构如下:
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的服务端。
查看测试结果
终端中出现以下日志,说明测试执行成功。
在build目录下产生integration_response_data.json文件,里面记录此次测试过程中的性能参数,例如平均每帧的绘制时间、90%帧的绘制时间、最差的那一帧绘制时间等。
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服务的方案上。