这篇文章最初发布在Codemagic博客上。
单元测试和widget测试是测试类、函数和widget的一个很好的方法。然而,测试所有这些移动部件作为一个整体一起工作也同样重要。
当处理有许多屏幕的复杂应用程序时,手动测试多个用户旅程是很难的,而且容易出错。
集成测试可以帮助我们实现这一过程的自动化,我们可以用它们在真实设备或模拟器上测试我们的Flutter应用程序。
因此,在这篇文章中,我们将学习如何使用Flutter驱动编写集成测试,并看看它们与widget测试有什么不同。
我们将使用它们来测试一个用Flutter和Firebase构建的现有开源应用程序的用户旅程。
最后,我们将看到如何用Codemagic来运行它们,无论是在模拟器上,还是在由AWS设备场驱动的真实设备上。
应用程序概述
我们将使用这个开源的应用程序作为例子。
这个应用程序展示了如何使用不同的签到方法来与Firebase进行认证。它已经实现了一个由四个步骤组成的签入和签出流程。
这个流程是自动集成测试的一个很好的候选者**(准确的说是UI测试**,下面会详细介绍)。
因此,我们的目标是编写一个测试,通过与UI的交互来循环上述四个步骤,并在每次交互后验证正确的部件是否到位。
而且,由于集成测试与Flutter驱动一起运行,我们将能够在测试执行时看到应用程序在模拟器上运行。
集成测试还是UI测试?
我们的应用程序使用Firebase认证来签入用户。而一般来说,你的应用程序会使用各种服务来与网络或其他外部输入进行对话(例如,位置更新)。
那么,集成测试应该用真实的还是模拟的网络服务来运行?
虽然对整个产品进行端到端的测试是有价值的,但也有一些风险。
- 你不希望用测试代码的网络请求来污染生产环境。
- 使用真实的网络服务进行测试,最好的情况是速度慢,最坏的情况是不可靠。
所以在我的应用程序中,我总是模拟网络服务。这给了我一个更可控的环境,在那里我可以存根网络调用,甚至可以决定他们应该花多长时间来响应。这使得我的测试更加可预测和可靠。
严格来说,这意味着我在写UI测试。因为我在模仿真实用户的行为,通过手势模拟他们与应用程序的互动。为了清楚起见,我将在文章的其余部分坚持使用集成测试这一术语,因为它们在Flutter中是这样称呼的。
说到这里,让我们回到我们的例子。
嘲弄认证服务
在实践中,我们希望对整个应用程序进行集成测试,因此需要在widget树的顶端注入一个模拟的认证服务。
在我的示例项目中,我通过定义这个枚举来完成这个任务。
enum AuthServiceType { firebase, mock }
当我创建Flutter应用程序时,我像这样注入所需的AuthServiceType 。
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
// [initialAuthServiceType] is made configurable for testing
const MyApp({this.initialAuthServiceType = AuthServiceType.firebase});
final AuthServiceType initialAuthServiceType;
@override
Widget build(BuildContext context) {
// top-level providers and MaterialApp here
// create real or mock authentication service based on initialAuthServiceType.
}
}
通过这样的设置,生产应用将始终使用Firebase认证服务,而测试代码可以使用模拟服务。
一般来说,我建议在widget树的顶端注入所有的服务类(在MaterialApp 上面),这样在配置widget和集成测试时,就可以更容易地把它们换下来。
考虑到这一点,我们就准备添加我们的集成测试了。
用Flutter驱动添加集成测试
根据官方文档,集成测试需要两个步骤。
- 将仪器化的应用程序部署到真实设备或模拟器上。
- 从一个单独的测试套件中 "驱动 "该应用程序,沿途检查以确保一切正确。
这与部件测试的运行方式不同,我们使用的测试环境要比完整的UI系统简单得多(而且速度快)。
注意:如果你不熟悉集成测试,我建议阅读Flutter文档中的《集成测试介绍》。
第一步是将flutter_driver 包添加到pubspec.yaml 文件中。
dev_dependencies:
flutter_driver:
sdk: flutter
而为了创建我们的集成测试,我们可以定义一个项目级文件夹,里面有两个文件。
test_driver/
app_test.dart
app.dart
在app.dart 文件里面,我们可以添加以下内容。
// project-specific imports
import 'package:firebase_auth_demo_flutter/main.dart';
import 'package:firebase_auth_demo_flutter/services/auth_service_adapter.dart';
// flutter-specific imports
import 'package:flutter/material.dart';
import 'package:flutter_driver/driver_extension.dart';
void main() {
// This line enables the extension.
enableFlutterDriverExtension();
// Call the `main()` function of the app, or call `runApp` with
// any widget you are interested in testing.
runApp(MyApp(initialAuthServiceType: AuthServiceType.mock));
}
注意我们在创建MyApp widget时是如何传递AuthServiceType.mock 。
然后我们可以开始实现app_test.dart 文件,通过添加标准的模板代码来实现FlutterDriver 。
// Imports the Flutter Driver API.
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
void main() {
FlutterDriver driver;
// Connect to the Flutter driver before running any tests.
setUpAll(() async {
driver = await FlutterDriver.connect();
});
// Close the connection to the driver after the tests have completed.
tearDownAll(() async {
if (driver != null) {
driver.close();
}
});
}
在这之后,我们可以添加一个快速测试来检查我们的Flutter驱动扩展的健康状态。在任何其他测试之前运行这个是个好主意。
test('check flutter driver health', () async {
final health = await driver.checkHealth();
expect(health.status, HealthStatus.ok);
});
信用:Darshan Kawar,他写了这篇关于用Flutter Driver测试Flutter UI的好文章。
接下来,我们可以添加一个方法,用来在UI交互之间添加一个短暂的延迟。正如我们将看到的,这只是用于演示目的。
Future<void> delay([int milliseconds = 250]) async {
await Future<void>.delayed(Duration(milliseconds: milliseconds));
}
有了这些,我们就可以开始写第一个测试了。
test('sign in anonymously, sign out', () async {
// 1. find and tap anonymous sign in button
final anonymousSignInButton = find.byValueKey(Keys.anonymous);
// 2. check to fail early if the auth state is authenticated
await driver.waitFor(anonymousSignInButton);
// 3. add small delay for demo recording
await delay(750);
// 4. interact with UI
await driver.tap(anonymousSignInButton);
});
在第1步,我们使用find 对象,通过键来获得一个widget的句柄。
注意:这与widget测试使用的finder不同,传递给byValueKey 的值需要是一个String ,而不是一个Key 对象。
事实上,如果我们在集成测试中使用这个导入,事情会中断。
import 'package:flutter_test/flutter_test.dart';
相反,我们应该只使用flutter_driver 包中定义的符号。
import 'package:flutter_driver/flutter_driver.dart';
由于我们是通过键来引用部件,我定义了一个Keys 类,以避免生产和测试代码中的重复和硬编码字符串。
class Keys {
// list of widget keys that need to be accessed in the test code
static const String anonymous = 'anonymous';
static const String logout = 'logout';
static const String alertDefault = 'alertDefault';
static const String alertCancel = 'alertCancel';
}
因此,在我的登录页面中,我将有一个按钮,像这样定义。
SignInButton(
key: Key(Keys.anonymous),
text: Strings.goAnonymous,
color: Colors.lime[300],
textColor: Colors.black87,
onPressed: isLoading ? null : () => _signInAnonymously(context),
),
让我们回到正题。下面是我们再次进行的测试。
test('sign in anonymously, sign out', () async {
// 1. find and tap anonymous sign in button
final anonymousSignInButton = find.byValueKey(Keys.anonymous);
// 2. check to fail early if the auth state is authenticated
await driver.waitFor(anonymousSignInButton);
// 3. add small delay for demo recording
await delay(750);
// 4. interact with UI
await driver.tap(anonymousSignInButton);
});
在第2步,我们要求驱动程序等待,直到我们找到匿名登录按钮。在进行任何UI交互之前,检查这一点是很好的做法,以确保我们处于最初的签到页面,并且我们显示的是正确的按钮。
事实上,我们应该始终牢记,应用程序的状态可以在不同的测试中发生变化。换句话说,多个集成测试是在 "共享 "同一个应用程序,如果每个测试不返回到最初的应用程序状态,就会引入副作用。
第3步增加了一个小的延迟,第4步向按钮分配了一个轻击手势。
结果,(模拟的)认证服务被调用,并要求用户登录。模拟服务所做的只是等待一个短暂的延迟,并改变其内部状态,以表明用户已经签入。当这种情况发生时,应用程序会自我更新并显示主页。
到目前为止一切顺利,但我们的测试实现还没有完成。
接下来,我们要找到并点击注销按钮。
// find and tap logout button
final logoutButton = find.byValueKey(Keys.logout);
await driver.waitFor(logoutButton);
await delay(750); // for video capture
await driver.tap(logoutButton);
这就像我们之前的代码一样工作。我们通过按键引用一个按钮,并等待驱动程序找到它。一旦主页呈现,所有的UI动画都完成了,我们就会从第一个await 的调用中返回。
而一旦我们有了一个注销按钮,我们就可以点击它了。
在这个特定的流程中,应用程序显示了一个要求注销确认的对话框。
而现在,我们知道如何处理这个问题了。
// find and tap confirm logout button
final confirmLogoutButton = find.byValueKey(Keys.alertDefault);
await driver.waitFor(confirmLogoutButton);
await delay(750); // for video capture
await driver.tap(confirmLogoutButton);
在我们点击注销确认按钮后,会有另一个调用,以签出模拟认证服务。反过来,这也会更新认证状态。
最后,我们要检查我们是否回到了登录页面。我们可以用最后一行来完成我们的测试。
// try to find anonymous sign in button again
await driver.waitFor(anonymousSignInButton);
总而言之,这是整个测试的代码。
test('sign in anonymously, sign out', () async {
// find and tap anonymous sign in button
final anonymousSignInButton = find.byValueKey(Keys.anonymous);
// Check to fail early if the auth state is authenticated
await driver.waitFor(anonymousSignInButton);
await delay(750); // for video capture
await driver.tap(anonymousSignInButton);
// find and tap logout button
final logoutButton = find.byValueKey(Keys.logout);
await driver.waitFor(logoutButton);
await delay(750); // for video capture
await driver.tap(logoutButton);
// find and tap confirm logout button
final confirmLogoutButton = find.byValueKey(Keys.alertDefault);
await driver.waitFor(confirmLogoutButton);
await delay(750); // for video capture
await driver.tap(confirmLogoutButton);
// try to find anonymous sign in button again
await driver.waitFor(anonymousSignInButton);
});
最后要做的是运行我们的集成测试。
flutter drive --target=test_driver/app.dart
然后,我们可以坐下来放松,因为Flutter驱动程序在我们的模拟器上运行测试(看,没有手😀)。
作为参考,这是Flutter驱动的输出的一个剥离日志。
Starting application: test_driver/app.dart
Running Xcode build...
├─Assembling Flutter resources... 10.8s
└─Compiling, linking and signing... 12.0s
Xcode build done. 27.0s
Configuring the default Firebase app...
flutter: Observatory listening on http://127.0.0.1:62531/q6q6Nbol1fU=/
Configured the default Firebase app __FIRAPP_DEFAULT.
[info ] FlutterDriver: Connecting to Flutter application at http://127.0.0.1:62531/q6q6Nbol1fU=/
[trace] FlutterDriver: Isolate found with number: 844330546624811
[trace] FlutterDriver: Isolate is paused at start.
[trace] FlutterDriver: Attempting to resume isolate
[trace] FlutterDriver: Waiting for service extension
[info ] FlutterDriver: Connected to Flutter application.
00:01 +0: check flutter driver health
00:01 +1: sign in anonymously, sign out
00:07 +2: (tearDownAll)
00:07 +2: All tests passed!
Stopping application instance.
请注意,测试运行了7秒。如果我们去掉人为的延迟,这将运行得更快。但值得注意的是,运行集成测试比部件和单元测试花费的时间更长。
不过,我们现在可以编写涵盖我们应用程序中几乎所有用户流的集成测试,这很不错🚀
而我们的测试人员也会非常高兴,因为他们不再需要手工完成这些工作。🙏
在Codemagic上运行
拥有单元、部件和集成测试是好事。
但如果能在我们每次打开或更新拉动请求时自动运行它们,那就更好了。
而这正是Codemagic的用武之地。
Codemagic提供了一个用户友好的用户界面,使我们很容易实现应用程序的自动化测试和交付。
说到测试,我们可以配置我们的构建来运行Flutter测试(单元和小工具)和Flutter驱动测试(集成)。
不仅如此,我们还可以选择在iOS模拟器、Android模拟器上运行,甚至在AWS设备场上运行的真实设备上运行。
而由于最终用户将在真实设备上使用你的应用程序,以这种方式运行自动化测试是非常重要的。
因此,对于这个项目,我已经建立了一个AWS账户,并注册了一个AWS访问密钥ID和AWS秘密访问密钥,如这里所述。
在Codemagic上配置好后,我运行了我的测试,并在构建日志中得到了集成测试的预期结果。
Run 'sylph run 2019-10-22 01:09:31.259' completed 1 of 1 jobs.
Result: PASSED
Device minutes: 7.98 (5.57 metered).
Counters:
skipped: 0
warned: 0
failed: 0
stopped: 0
passed: 3
errored: 0
total: 3
Downloading artifacts...
Downloading artifacts to /tmp/sylph_artifacts/sylph_run_2019-10-22_01_09_31.259/test_concurrent_runs/android_pool_1/Google_Pixel_2-Google_Pixel_2-8.0.0
results=[{result: true}]
Concurrent runs completed.
Sylph run completed in 12m:32s:975ms.
Sylph run 'sylph run 2019-10-22 01:09:31.259' succeeded.
结论
在这篇文章中,我们已经看到了如何使用Flutter驱动为我们的Flutter应用程序设置和编写集成测试。
我们更深入地了解了集成测试是如何工作的,以及它们与部件测试有何不同。
我们还看到了如何使用它们来测试用户流,使用我的参考认证演示应用程序。
最后,我们通过建立一个工作流,在Codemagic上运行所有的测试,实现了整个过程的自动化。我们还看到了如何使用AWS设备场在真实设备上进行测试。
如果我们想更进一步,我们甚至可以写集成测试来记录我们应用程序的性能。这样,我们可以确保UI在AWS Device Farm上运行的各种设备上顺利运行。 关于本文涉及的一些主题的更深入的信息,我建议阅读以下资源。
- 集成测试的介绍
- 性能分析
- 用 Flutter 驱动测试 Flutter UI
- 如何在 Flutter 中编写集成测试
- 实用指南。Flutter + Firebase + Codemagic
- 使用 AWS Device Farm 和 Sylph 在真实设备上运行 Flutter 集成测试
参考认证演示的完整源代码可在 GitHub 上获取。