1 如何添加单元测试
1.1 添加单元测试依赖
在 pubspec.yaml 的 dev_dependencies 中添加 flutter_test 依赖,如下:
dev_dependencies:
flutter_test:
sdk: flutter
1.2 创建单元测试文件
在 test 目录下创建单元测试文件,创建时文件命名需要注意:单元测试的文件要以 _test 结尾,其他文件不能以 _test 结尾。原因是 flutter test 会把以 _test 结尾的文件认为是测试文件,其他则是普通的文件。
1.3 添加单元测试用例
假如有一个页面上有一个增加按钮和一个展示计数的文本,当点击增加按钮时计数就会 +1,页面效果如下:
那么该页面对应的主要单元测试代码如下:
void main() {
/// Widget 测试
testWidgets('计数增加测试', (WidgetTester tester) async {
/// 打开页面
await tester.pumpWidget(const MyApp());
/// 验证页面上的内容:有一个文本 "0",没有文本 "1"
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
/// 点击 "+" Icon 使计数 +1,然后刷新页面
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
/// 验证页面上的内容:有一个文本 "1",没有文本 "0"
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
1.4 运行单元测试
1.4.1 Android Studio
如果使用的是 Android Studio,那么在代码的左侧会有多个运行按钮,mian() 旁边的运行按钮用于运行该文件下所有测试用例,测试用例旁边的运行按钮用于运行该测试用例。
点击运行按钮会出现选项弹窗,每个选项对应不通过功能:
- Run 'xxx' :运行单元测试
- Debug 'xxx' :调试单元测试
- Run 'xxx' with Coverage:运行单元测试,并生成覆盖率
运行之后便可查看运行结果,左侧为每个用例的运行结果(绿勾成功、黄叉失败),点击失败的用例可查看失败的原因。
1.4.2 flutter test
首先需要进入项目的根目录,然后根据实际需要执行下列命令:
/// 运行所有测试用例
flutter test
/// 运行单个文件中的测试用例
flutter test test/xxx_test.dart
运行之后同样能看到用例的运行结果( +x 表示成功的数量,-y 表示失败的数量,测试用例描述后面带有 [E] 的是失败的用例)以及失败原因。
如果想要生成覆盖率,在原有命令后面增加参数 --coverage 即可,如:
flutter test --coverage
1.5 查看单元测试覆盖率
1.5.1 Android Studio
在 Android Studio 上选择 Run 'xxx' with Coverage 运行单元测试之后便可直接在被测试的文件上查看覆盖情况,包含了行覆盖率以及代码的覆盖情况(绿色已覆盖、红色未覆盖)。
1.5.2 flutter test
如果是使用 flutter test --coverage 命令运行单元测试,会生成一个覆盖率的文件 coverage/lcov.info,其实在 Android Studio 上运行也会生成这个文件。
从 lcov.info 文件的内容可以看出覆盖率情况:
SF:lib/main.dart
DA:3,0
DA:4,0
DA:8,2
DA:10,1
LF:24
LH:22
end_of_record
这里对文件内容进行简单的说明:
- SF:lib/main.dart:SF 表示文件覆盖率数据开始位置,后面是具体的文件
- DA:3,0:DA 表示行覆盖情况,第一个数字表示代码所在的行,第二个数字表示执行的次数,0 就是未覆盖
- LF:24:LF 表示总行数,后面的数字是具体的行数
- LH:22:LH 表示覆盖的行数,后面的数字是具体的行数
- end_of_record:文件覆盖率数据开始位置,与 SF 成对出现
1.5.3 lcov
虽然从生成的覆盖率文件中也能看出覆盖率,但是不够直观,如果想更直观的查看覆盖情况,可以安装下 lcov 。安装命令如下:
brew install lcov
安装完成后,在项目根目录下执行以下命令:
genhtml coverage/lcov.info -o coverage/html
等命令执行完成后,在 coverage 目录下就生成了一个名为 html 文件夹,打开文件夹中的 index.html 就能看到清晰的覆盖率报告了。
打开具体的文件便能看到每一行代码的覆盖情况,蓝色表示已覆盖,红色表示未覆盖,代码左侧的数字表示覆盖的次数。
2 常用 API 简介
虽然 flutter_test 提供了很多的 API 给大家使用,但实际写单元测试用例的时候,使用频率较高的并没有很多,下面对使用评率较高的 API 进行简单的介绍。
2.1 环境相关
2.1.1 setUpAll
注册一个函数,该函数会在 所有测试用例之前 运行。
可用于测试用例依赖环境的初始化,例如进行接口数据模拟等。
void setUpAll(dynamic Function() body
2.1.2 setUp
注册一个函数,该函数会在 每个测试用例之前 运行。
同样可用于测试用例依赖环境的初始化,与 setUpAll 的不同是:setUpAll 只会运行一次,而 setUp 在每个测试用例运行之前都会运行一次。
void setUp(dynamic Function() body)
2.1.3 tearDownAll
注册一个函数,该函数会在 所有测试用例之后 运行。
void tearDownAll(dynamic Function() body)
2.1.4 tearDown
注册一个函数,该函数会在 每个测试用例之后 运行。
可用于测试用例依赖环境还原,测试用例执行过程中可能会修改一些公共依赖数据,如不进行还原则会影响后面的测试用例的运行结果。
void tearDown(dynamic Function() body)
2.2 用例相关
2.2.1 test
创建测试用例,用于与 Widget 无关的功能的测试,主要参数说明如下:
- description:测试用例描述
- body:测试用例函数体
void test(
Object description,
dynamic Function() body, {
String? testOn,
Timeout? timeout,
dynamic skip,
dynamic tags,
Map<String, dynamic>? onPlatform,
int? retry,
})
2.2.2 testWidgets
创建测试用例,用于与 Widget 相关的功能的测试,主要参数说明如下:
- description:测试用例描述
- callback:测试用例函数体
void testWidgets(
String description,
WidgetTesterCallback callback, {
bool skip = false,
test_package.Timeout? timeout,
Duration? initialTimeout,
bool semanticsEnabled = true,
TestVariant<Object?> variant = const DefaultTestVariant(),
dynamic tags,
})
代码示例:
testWidgets('测试用例描述', (WidgetTester tester) async {
await tester.pumpWidget(MyWidget());
await tester.tap(find.text('保存'));
expect(find.text('成功'), findsOneWidget);
});
2.3 页面相关
2.3.1 pumpWidget
构建 Widget,只负责构建一次,不负责重建,用于打开待测试的 Widget,主要参数说明如下:
- widget:需要构建的 Widget
Future<void> pumpWidget(
Widget widget, [
Duration? duration,
EnginePhase phase = EnginePhase.sendSemanticsUpdate,
])
代码示例:
testWidgets('测试用例描述', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
/// ...
});
2.3.2 pump
指定时间之后触发 Widget 重建,用于 Widget 内容刷新,主要参数说明如下:
- duration:时间间隔,不传递时直接触发重建
Future<void> pump([
Duration? duration,
EnginePhase phase = EnginePhase.sendSemanticsUpdate,
])
2.3.3 pumpAndSettle
以定时间间隔重复调用 pump 方法重建 Widget,用于有动效的 Widget 内容刷新,主要参数说明如下:
- duration:时间间隔
Future<int> pumpAndSettle([
Duration duration = const Duration(milliseconds: 100),
EnginePhase phase = EnginePhase.sendSemanticsUpdate,
Duration timeout = const Duration(minutes: 10),
])
2.4 查找相关
2.4.1 text
查找指定文本的 Widget,主要参数说明如下:
- text:文本
- findRichText:是否查找 RichText
Finder text(
String text, {
bool findRichText = false,
bool skipOffstage = true,
})
代码示例:
expect(find.text('文本'), findsOneWidget);
2.4.2 textContaining
查找包含指定文本的 Widget,主要参数说明如下:
- pattern:包含的文本
- findRichText:是否查找 RichText
Finder textContaining(
Pattern pattern, {
bool findRichText = false,
bool skipOffstage = true,
})
代码示例:
expect(find.textContain('文本'), findsOneWidget);
expect(find.textContain(RegExp(r'(\w+)')), findsOneWidget);
2.4.3 widgetWithText
查找指定 Widget 类型且带有指定文本的 Widget,主要参数说明如下:
- widgetType:Widget 类型
- text:文本
Finder widgetWithText(Type widgetType, String text, { bool skipOffstage = true })
代码示例:
Button(
child: Text('按钮')
)
tester.tap(find.widgetWithText(Button, '按钮'));
2.4.4 byKey
根据 key 查找 Widget。
Finder byKey(Key key, { bool skipOffstage = true })
代码示例:
expect(find.byKey(backKey), findsOneWidget);
2.4.5 byType
根据类型查找 Widget。
Finder byType(Type type, { bool skipOffstage = true })
代码示例:
expect(find.byType(IconButton), findsOneWidget);
2.4.6 byIcon
根据 icon 查找 Widget。
Finder byIcon(IconData icon, { bool skipOffstage = true })
代码示例:
expect(find.byIcon(Icons.inbox), findsOneWidget);
2.4.7 widgetWithIcon
查找指定 Widget 类型且带有指定 Icon 的 Widget,主要参数说明如下:
- widgetType:Widget 类型
- icon:指定的 Icon
Finder widgetWithIcon(Type widgetType, IconData icon, { bool skipOffstage = true })
代码示例:
Button(
child: Icon(Icons.arrow_forward)
)
tester.tap(find.widgetWithIcon(Button, Icons.arrow_forward));
2.5 操作相关
2.5.1 enterText
给指定的文本输入组件焦点,并输入文本,就像由屏幕键盘提供的一样,主要参数说明如下:
- finder:文本输入组件
- text:输入的文本
Future<void> enterText(Finder finder, String text) async
2.5.2 tap
点击指定的 Widget,主要参数说明如下:
- finder:需要点击的 Widget
Future<void> tap(Finder finder, {int? pointer, int buttons = kPrimaryButton})
代码示例:
testWidgets('测试用例描述', (WidgetTester tester) async {
/// ...
/// 点击按钮
await tester.tap(find.byType(FloatingActionButton));
await tester.pump();
/// 验证结果
expect(find.text('文本'), findsOneWidget);
});
2.5.3 drag
试图按照给定的偏移量拖动给定的 Widget,在小部件的中间开始拖动,主要参数说明如下:
- finder:需要拖动的 Widget
- offset:偏移量
Future<void> drag(
Finder finder,
Offset offset, {
int? pointer,
int buttons = kPrimaryButton,
double touchSlopX = kDragSlopDefault,
double touchSlopY = kDragSlopDefault,
bool warnIfMissed = true,
PointerDeviceKind kind = PointerDeviceKind.touch,
})
代码示例:
/// 将 XxxListView 向下拖动 24
await tester.drag(find.byType(XxxListView), Offset(0.0, -24.0));
2.6 验证相关
2.6.1 expect
验证结果是否正确,主要参数说明如下:
actual:实际值,经过测试的方法后的值
matcher:匹配值(预期值)
void expect(
dynamic actual,
dynamic matcher, {
String? reason,
dynamic skip, // true or a String
})
2.7 匹配相关
2.7.1 findsNothing
无任何匹配 Widget。
代码示例:
expect(find.text('文本'), findsNothing);
2.7.2 findsWidgets
匹配到至少一个 Widget。
2.7.3 findsOneWidget
匹配到一个 Widget。
2.7.4 findsNWidgets
匹配到指定数量的 Widget。