前言
通常一个项目的功能越多,手工测试就越困难,我们可以通过自动化测试来提高程序的稳定性和功能的完整性,并且还可以快速发现和定位问题,提高开发效率,一套完整的自动化测试有助于应用程序在发布前的正确性。
Flutter官方自动化测试分为以下三类:
单元测试
添加插件依赖
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
其中(图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语言,并且有一定的编码能力。