Flutter 单元测试入门教程

909 阅读8分钟

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,页面效果如下:

image.png

那么该页面对应的主要单元测试代码如下:

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() 旁边的运行按钮用于运行该文件下所有测试用例,测试用例旁边的运行按钮用于运行该测试用例。

image.png

点击运行按钮会出现选项弹窗,每个选项对应不通过功能:

  • Run 'xxx' :运行单元测试
  • Debug 'xxx' :调试单元测试
  • Run 'xxx' with Coverage:运行单元测试,并生成覆盖率

image.png

运行之后便可查看运行结果,左侧为每个用例的运行结果(绿勾成功、黄叉失败),点击失败的用例可查看失败的原因。

image.png

1.4.2 flutter test

首先需要进入项目的根目录,然后根据实际需要执行下列命令:

 /// 运行所有测试用例
 flutter test
 /// 运行单个文件中的测试用例
 flutter test test/xxx_test.dart

运行之后同样能看到用例的运行结果( +x 表示成功的数量,-y 表示失败的数量,测试用例描述后面带有 [E] 的是失败的用例)以及失败原因。

image.png

如果想要生成覆盖率,在原有命令后面增加参数 --coverage 即可,如:

 flutter test --coverage

1.5 查看单元测试覆盖率

1.5.1 Android Studio

在 Android Studio 上选择 Run 'xxx' with Coverage 运行单元测试之后便可直接在被测试的文件上查看覆盖情况,包含了行覆盖率以及代码的覆盖情况(绿色已覆盖、红色未覆盖)。

image.png

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 就能看到清晰的覆盖率报告了。

image.png

打开具体的文件便能看到每一行代码的覆盖情况,蓝色表示已覆盖,红色表示未覆盖,代码左侧的数字表示覆盖的次数。

image.png

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。