在Flutter中构建漂亮而实用的应用程序是很容易的,但我们如何确保我们能够大规模地提供良好的用户体验?我们可以通过对推送到我们代码库的每一个新变化进行集成和运行自动化测试来做到这一点。
我们这篇文章的目标是
- 为我们的Flutter2应用程序编写测试。
- 在本地运行这些测试。
- 使用Semaphore在云端的持续集成(CI)工作流中自动测试。
Flutter测试
Flutter为测试提供了丰富的支持,从单元和小工具测试到集成测试。不同的测试方法涉及不同程度的复杂性和权衡。
| 测试 | 单元测试 | 小工具 | 集成测试 |
| 信心 | 低 | 高 | 最高 |
| 维护成本 | 低 | 高 | 最高 |
| 依赖性 | 很少 | 更多 | 最多 |
| 执行速度 | 快速 | 快 | 缓慢 |
比较测试的类型
前提条件
我们将使用Semaphore,如果你不熟悉它的工作原理,我建议阅读以下文章:
我们的Todo应用程序是用Flutter 2.0编写的,支持空安全。空值安全功能消除了由空值指针引起的错误,帮助开发人员加快开发时间,使代码维护更容易。

我们的演示应用程序在运行
在继续之前,请确保您的机器中已经安装了:
- Flutter SDK 2.0及以上版本
- Dart SDK 2.12及以上版本
接下来,前往我们的演示程序库,将其分叉并克隆到您的机器上。
semaphoreci-demos / semaphore-demo-flutter2
然后,下载与之相关的依赖项:
$ flutter packages get
最后,连接一个模拟器/仿真器或一个真实的设备并运行该应用程序:
$ flutter run
单元测试
一个单元代表你代码中的一个方法、函数或类。它是你的应用程序的一小部分,可以单独进行测试。
测试驱动开发,也被称为TDD,被认为是编写干净和可维护代码的好方法。我们的演示项目已经包括了应用程序的核心功能,所以我们要练习为它编写测试。
添加测试依赖性
我们将使用Flutter SDK中的flutter_test包。flutter_test ,它是建立在test包之上的,有额外的工具用于测试部件。
要使用该包,请在您的pubspec.yaml 中添加以下内容:
dev_dependencies:
flutter_test:
sdk: flutter
编写单元测试
作为一个例子,我们将为TodoViewModel 类编写测试,如下图所示。这个类包括todos 列表和创建Todo应用程序的方法。作为参考,我们要测试的相关代码是:
# lib/viewmodels/todo_viewmodel.dart
import 'package:flutter/material.dart';
import 'package:semaphoreci_flutter_demo/models/todo_item.dart';
class TodoViewModel extends ChangeNotifier {
List < TodoItem > todos = [];
void addItemToList(TodoItem item) {
todos.add(item);
notifyListeners();
}
void updateItem(TodoItem item) {
final i = todos.indexWhere((t) => t.id == item.id);
if (i != -1) todos[i] = item;
notifyListeners();
}
void deleteItemById(int id) {
todos.removeWhere((t) => t.id == id);
notifyListeners();
}
void deleteAllItems() {
todos.clear();
notifyListeners();
}
}
首先,在测试文件夹中创建一个名为todo_viewmodel_test.dart 的文件。按照惯例,在你的应用程序中搜索测试时,后缀*_test.dart 是必须的。在新文件中输入以下几行:
import 'package:flutter_test/flutter_test.dart';
import 'package:semaphoreci_flutter_demo/features/home/home_viewmodel.dart';
import 'package:semaphoreci_flutter_demo/models/todo_item.dart';
import 'package:semaphoreci_flutter_demo/viewmodels/todo_viewmodel.dart';
void main() {
test('Should show default empty todos array', () {
// Arrange
// Act
// Assert
});
}
💡 可选地,你也可以根据测试的功能来分组。在这种情况下,新文件应该在features/viewmodels/todo_viewmodel_test.dart 中创建。这有助于保持你的测试有条不紊,当你在大型项目中工作时有很大帮助。
我们要使用的测试结构是写好测试的Arrange-Act-Assert模式。这是一个可选的,但也是一个很好的做法--特别是当你刚开始写测试的时候。
接下来,添加测试以检查项目是否已成功添加到列表中:
test('Should get all items added to the list', () {
// Arrange
final todoViewModel = TodoViewModel();
final item = TodoItem(
id: 1,
title: 'Buy groceries',
description: 'Go to the mall and shop for next month’s stock.',
createdAt: 1,
updatedAt: 1,
);
// Act
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(item);
// Assert
expect(todoViewModel.todos.length, 3);
});
让我们消化一下这个测试案例中Arrange-Act-Assert模式的使用:
- 排列:这一步创建了
TodoViewModel的实例,它是ChangeNotifier的子类,以及TodoItem对象。 - 行动:该步骤通过调用
addItemToList方法对TodoViewModel进行操作,该方法添加了相同的TodoItem对象的实例。 - Assert: 这个步骤通过调用方法
addItemToList三次来验证todos是否有三个项目。
最后,添加剩余的CRUD(创建、读取、更新、删除)操作的测试案例:
test('Should update one item in the list', () {
// Arrange
final todoViewModel = TodoViewModel();
final item = TodoItem(
id: 1,
title: 'Buy groceries',
description: 'Go to the mall and shop for next month’s stock.',
createdAt: 1,
updatedAt: 1,
);
final item2Old = TodoItem(
id: 2,
title: 'Buy groceries old',
description: 'Go to the mall and shop for next month’s stock old.',
createdAt: 1,
updatedAt: 1,
);
final item2New = TodoItem(
id: 2,
title: 'Buy groceries new',
description: 'Go to the mall and shop for next month’s stock new.',
createdAt: 1,
updatedAt: 1,
);
// Act
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(item2Old);
todoViewModel.updateItem(item2New);
// Assert
expect(todoViewModel.todos[2].title, 'Buy groceries new');
expect(todoViewModel.todos[2].description, 'Go to the mall and shop for next month’s stock new.');
});
test('Should delete one item by id from the list', () {
// Arrange
final todoViewModel = TodoViewModel();
final item = TodoItem(
id: 1,
title: 'Buy groceries',
description: 'Go to the mall and shop for next month’s stock.',
createdAt: 1,
updatedAt: 1,
);
final itemToDelete = TodoItem(
id: 2,
title: 'Buy groceries deleted',
description: 'Go to the mall and shop for next month’s stock deleted.',
createdAt: 1,
updatedAt: 1,
);
// Act
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(itemToDelete);
todoViewModel.deleteItemById(itemToDelete.id);
// Assert
expect(todoViewModel.todos.length, 2);
});
test('Should delete all items from the list', () {
// Arrange
final todoViewModel = TodoViewModel();
final item = TodoItem(
id: 1,
title: 'Buy groceries',
description: 'Go to the mall and shop for next month’s stock.',
createdAt: 1,
updatedAt: 1,
);
// Act
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(item);
todoViewModel.deleteAllItems();
// Assert
expect(todoViewModel.todos.length, 0);
});
为了验证测试是否正确,运行以下命令之一,所有的测试都应该通过:
$ flutter test test
$ flutter test test/todo_viewmodel_test.dart
$ flutter test test/features
🎯挑战:为HomeViewModel 类编写单元测试。
小工具测试
小部件测试,顾名思义,是用来检查小部件是否真实。这种类型的测试比单元测试更全面。通过使用flutter_test 包,我们能够使用不同的工具来测试我们应用程序的小部件:比如用于构建和与小部件互动,以及使用小部件特定的常量来帮助在测试环境中定位一个或多个小部件。
在本节中,我们将为HomePage ,它位于lib/features/home/home_page.dart ,添加widget测试:

作为参考,这里是HomePage的代码:
# lib/features/home/home_page.dart
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'SemaphoreCI Flutter Demo',
),
),
floatingActionButton: FloatingActionButton(
key: const ValueKey('button.add'),
onPressed: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) =>
const DetailPage(
type: DetailPageType.add,
),
),
);
},
child: const Icon(Icons.add),
),
body: SafeArea(
child: Column(
children: [
Container(
margin: const EdgeInsets.only(
left: 12,
right: 12,
top: 32,
bottom: 24,
),
child: const TextField(
decoration: InputDecoration(
hintText: 'Search',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(10.0),
),
),
),
),
),
Expanded(
child: Consumer < HomeViewModel > (
builder: (_, data, __) => ListView.builder(
itemCount: data.todos.length,
itemBuilder: (_, i) => ListTile(
title: Text(data.todos[i].title),
subtitle: Text(data.todos[i].description),
onTap: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => DetailPage(
type: DetailPageType.edit,
item: data.todos[i],
),
),
);
},
),
),
),
),
],
),
),
);
}
首先,在测试文件夹中创建一个名为home_widget_test.dart 的文件,并添加以下几行:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:semaphoreci_flutter_demo/features/home/home_page.dart';
import 'package:semaphoreci_flutter_demo/features/home/home_viewmodel.dart';
import 'package:semaphoreci_flutter_demo/models/todo_item.dart';
import 'package:semaphoreci_flutter_demo/viewmodels/todo_viewmodel.dart';
void main() {
testWidgets(
'Should display the items in the list',
(WidgetTester tester) async {
// Arrange
// Act
// Assert
},
);
}
在widget测试中,我们使用testWidgets(),它创建了一个新的WidgetTester 的实例,代替了test() 方法。测试器用于在测试环境中构建并与widgets交互。
下一步是设置测试所需的项目:
testWidgets(
'Should display the items in the list',
(WidgetTester tester) async {
// Arrange
final todoViewModel = TodoViewModel();
final item = TodoItem(
id: 1,
title: 'Buy groceries',
description: 'Go to the mall and shop for next month’s stock.',
createdAt: 1609462800,
updatedAt: 1609462800,
);
final titleFinder = find.text('Buy groceries');
final typeFinder = find.byType(ListTile);
...
},
);
在这个测试案例中,我们要检查标题为 "购买杂货 "的项目在主页上的列表中是否可见。
现在,通过使用pumpWidget 方法渲染小工具的用户界面:
testWidgets(
'Should display the items in the list',
(WidgetTester tester) async {
...
// Act
await tester.pumpWidget(
MaterialApp(
home: MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => todoViewModel,
),
ChangeNotifierProxyProvider<TodoViewModel, HomeViewModel>(
create: (_) => HomeViewModel(
todoViewModel: todoViewModel,
),
update: (_, todo, __) => HomeViewModel(todoViewModel: todo),
),
],
child: HomePage(),
),
),
);
},
);
接下来,添加方法调用,调用三次addItemToList :
testWidgets(
'Should display the items in the list',
(WidgetTester tester) async {
...
// Act
...
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(item);
todoViewModel.addItemToList(item);
await tester.pump();
},
);
在大多数情况下,.pump() 应该足够了,特别是当没有小部件重建计划时,例如,由于副作用重绘或动画,在请求小部件重建后。但是如果你需要等待任何框架完成重建,你也可以查看.pumpAndSettle() 。
最后,使用Matcher ,验证项目是否被添加到列表中:
testWidgets(
'Should display the items in the list',
(WidgetTester tester) async {
// Arrange
...
final titleFinder = find.text('Buy groceries');
final typeFinder = find.byType(ListTile);
// Act
...
// Assert
expect(titleFinder, findsNWidgets(3));
expect(typeFinder, findsNWidgets(3));
},
);
通过Matcher ,你可以方便地验证项目是否在小组件中正确呈现:在这种情况下,在HomePage 小组件上。
在`findNWidgets`的基础上还有其他匹配器:
findsOneWidget验证是否正好有一个widget被发现findsNothing检验是否没有发现任何部件findsWidgets验证是否找到了一个或多个小组件matchesGoldenFile验证一个widget的渲染与一个特定的位图图像相匹配("黄金文件 "测试)
在继续前进之前,用fluter test test ,在本地运行测试,并检查所有的测试是否通过。
🎯挑战:为DetailPage 类编写单元测试。
集成测试
与单元和部件测试不同,集成测试允许你测试你的应用程序的各个部分如何一起工作。编写集成测试的成本很高,但如果做得好,会给你带来最大的信心。
要使用integration_test 包,在你的pubspec.yaml 中添加以下内容:
dev_dependencies:
...
integration_test:
sdk: flutter
💡从Flutter 2.0开始,integration_test 被移到Flutter SDK中。
我们要为我们的第一个测试案例写一个集成测试,这个案例是向Todo列表添加一个新的项目。首先,创建一个名为integration_test 的文件夹,并在里面创建一个名为add_new_todo_item_test.dart 的文件,其中包括这些行:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:semaphoreci_flutter_demo/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'Should display the newly added item in the list',
(WidgetTester tester) async {
// Arrange
...
// Act
...
// Assert
...
},
);
}
在上面的设置代码中:
IntegrationTestWidgetsFlutterBinding.ensureInitialized()是类似于运行中的应用程序中的 。如果你需要在运行应用程序之前初始化绑定实例,这将被调用;例如,在做一个本地插件或数据库初始化时。WidgetsFlutterBinding.ensureInitialized()- 集成测试仍然可以利用
flutter_testAPI,例如,testWidgets,用于使用轻拍与小部件交互,以及使用匹配器搜索小部件。
现在你可以设置集成测试的代码:
testWidgets(
'Should display the newly added item in the list',
(WidgetTester tester) async {
// Arrange
app.main();
await tester.pumpAndSettle();
final addFinder = find.byKey(const ValueKey('button.add'));
final titleFinder = find.byKey(const ValueKey('input.title'));
final descriptionFinder = find.byKey(const ValueKey('input.description'));
final saveFinder = find.byKey(const ValueKey('button.save'));
},
);
我们正在使用ValueKey 来查询我们的小部件。例如,FloatingActionButton 的值键为button.add ,因此它与addFinder 相匹配。byKey 是在测试环境中查找一个或多个部件的常用方法之一:
floatingActionButton: FloatingActionButton(
key: const ValueKey('button.add'),
onPressed: () {
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const DetailPage(
type: DetailPageType.add,
),
),
);
},
child: const Icon(Icons.add),
),
继续前进,与小部件互动,并向列表中添加一个新的项目:
testWidgets(
'Should display the newly added item in the list',
(WidgetTester tester) async {
// Arrange
...
// Act
await tester.tap(addFinder);
await tester.pumpAndSettle();
await tester.enterText(titleFinder, 'Buy groceries');
await tester.pumpAndSettle();
await tester.enterText(descriptionFinder, 'Go to the mall and shop for next month’s stock.');
await tester.pumpAndSettle();
await tester.tap(saveFinder);
await tester.pumpAndSettle();
},
);
我们通过以下方式与小部件进行互动:
- 点选
addFinder,在DetailPageType.add模式下打开DetailPage。 - 输入文字
titleFinder,接受输入 "购买杂货"。 - 点击
saveFinder,将该项目保存在待办事项列表中。
最后,验证该项目是否被保存在列表中:
testWidgets(
'Should display the newly added item in the list',
(WidgetTester tester) async {
// Arrange
...
// Act
...
// Assert
expect(find.text('Buy groceries'), findsOneWidget);
expect(find.text('Go to the mall and shop for next month’s stock.'), findsOneWidget);
},
);
由于我们使用了flutter_test APIs,我们可以调用同样的方法来检查项目是否被添加到列表中,例如,如果它被呈现在主页UI中。
是时候运行测试了。确保有一个设备连接到你的电脑上,无论是iOS模拟器(iOS)还是真实设备。要运行这个测试案例的集成测试,请输入以下命令:
$ flutter test integration_test/add_new_todo_item_test.dart

🎯挑战:为编辑和删除一个项目编写集成测试。
自动化Flutter测试
有几种方法可以使您的测试和构建开始自动化。最常见的是专用服务器或基于云的CI/CD 平台。
建立一个专用的CI/CD服务器对于小型应用程序来说似乎是一个不错的选择,但是在维护、安全和长期性能方面也有隐性成本。使用基于云的CI/CD平台,如Semaphore,你可以更专注于构建产品,而不是担心维护另一台服务器。
将您的项目添加到Semaphore
用您的Semaphore账户登录,点击导航栏中的+创建新按钮:

链接您的Github账户并选择您的项目分叉:

点击继续进行工作流程设置,并选择Single JobStarter工作流程。点击 "自定义",打开工作流编辑器:

了解工作流构建器

- 一个工作流程可能包含一个或多个管道,例如,运行测试、创建和部署构建。
- 每个管道有一个或多个块,从左到右执行。
- 每个块 都有一个或多个作业,它定义了在一个管道中需要完成的任务集。默认情况下,Semaphore是按顺序执行块的。
- 作业 是在一个块中并行执行的**。** 一个作业包含一组命令,如果其中任何一个命令失败,流水线将停止,并被标记为失败。
在代理部分,将环境类型改为Docker,并在图像领域输入 "registry.semaphoreci.com/android:30-flutter"。这是由Semaphore提供的许多CI优化的Docker镜像之一。

使用基于Docker的环境。
设置好环境后,创建你的第一个块来安装Flutter和它的依赖项,工作命令应该是:
checkout
cache restore flutter-packages-$SEMAPHORE_GIT_BRANCH-$(checksum pubspec.yaml),flutter-packages-$(checksum pubspec.yaml),flutter-packages
flutter pub get
cache store flutter-packages-$SEMAPHORE_GIT_BRANCH-$(checksum pubspec.yaml),flutter-packages-$(checksum pubspec.yaml),flutter-packages /root/.pub-cache
在这里,我们使用checkout将仓库克隆到CI机器中,并使用cache在运行之间存储Flutter的依赖关系。

最后,运行工作流以检查工作是否有效。

首次运行流水线
添加测试作业
这里我们要并行运行两个作业,并根据代码格式化和林特化检查代码质量是否通过。
创建一个静态代码分析的块。使用编辑工作流按钮,再次打开编辑器。首先,在作业初始命令部分添加以下命令。
flutter format --set-exit-if-changed .
点击添加另一个作业,然后输入linting命令。我们的项目使用very_good_analysis包所设定的标准:
flutter analyze .
最后,在序言部分添加以下命令来克隆版本库并恢复依赖关系,序言部分在块中所有作业之前执行:
checkout
cache restore flutter-packages-$SEMAPHORE_GIT_BRANCH-$(checksum pubspec.yaml),flutter-packages-$(checksum pubspec.yaml),flutter-packages

为单元和部件测试创建一个新块。在Jobs部分添加以下命令,以运行Flutter测试。
checkout
cache restore flutter-packages-$SEMAPHORE_GIT_BRANCH-$(checksum pubspec.yaml),flutter-packages-$(checksum pubspec.yaml),flutter-packages
flutter test test

为集成测试创建一个新的块。对于这些,我们将使用Android的AVD仿真器。要开始,在Jobs 部分添加以下命令。
在序言中,使用这些命令来初始化仿真器:
checkout
cache restore flutter-packages-$SEMAPHORE_GIT_BRANCH-$(checksum pubspec.yaml),flutter-packages-$(checksum pubspec.yaml),flutter-packages
sdkmanager "platform-tools" "platforms;android-29" "build-tools;30.0.0" "emulator"
sdkmanager "system-images;android-29;google_apis;x86"
echo no | avdmanager create avd -n test-emulator -k "system-images;android-29;google_apis;x86"
emulator -avd test-emulator -noaudio -no-boot-anim -gpu off -no-window &
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
adb shell wm dismiss-keyguard
sleep 1
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
创建三个测试作业,每个集成测试一个:
flutter test integration_test/add_new_todo_item_test.dart
flutter test integration_test/edit_existing_item_test.dart
和:
flutter test integration_test/delete_existing_item_test.dart
💡该命令直接使用flutter test <path/to/file> 执行Flutter集成测试。如果你打算增加更多的测试,你也可以创建一个脚本,循环浏览integration_test 目录中包含的所有文件,而不是有单独的flutter测试命令。
这就是了!现在让我们来看看我们的工作流程的运行情况。将set-up-semaphore 分支合并到你的默认分支。

最终的CI流水线
💡 如果你的Semaphore计划允许,而且基本机器的性能不能满足你的需要,可以考虑为集成测试块选择一个具有更多CPU和内存的虚拟机。你可以通过向下滚动块设置,直到到达代理部分并点击覆盖全局代理定义来设置每个块的CI机器设置。你需要重复你在管道开始时做的Docker设置和图像步骤。
最后的话
随着应用程序的扩展,在编写质量和自动化测试方面的投资将在长期内得到回报。人工测试根本不可能实现。专注于用测试构建功能比手动测试应用程序的琐碎部分要好得多。如果你把自动化测试和CI/CD结合起来,你就会有信心,随着应用的发展,不会有任何问题。
你是一个移动开发者吗? 那么,接下来请阅读这些伟大的文章。