在上一篇文章中,我與你分享了如何分析并優化 Flutter 應用的性能問題。通過在真機上以分析模式運行應用,我們可以借助于性能圖層的幫助,找到引起性能瓶頸的兩類問題,即 GPU 渲染問題和 CPU 執行耗時問題。然后,我們就可以使用 Flutter 提供的渲染開關和 CPU 幀圖(火焰圖),來檢查應用中是否存在過度渲染或是代碼執行耗時長的情況,從而去定位并著手解決應用的性能問題了。
在完成了應用的開發工作,并解決了代碼中的邏輯問題和性能問題之后,接下來我們就需要測試驗收應用的各項功能表現了。移動應用的測試工作量通常很大,這是因為為了驗證真實用戶的使用體驗,測試往往需要跨越多個平臺(Android/iOS)及不同的物理設備手動完成。
隨著產品功能不斷迭代累積,測試工作量和復雜度也隨之大幅增長,手動測試變得越來越困難。那么,在為產品添加新功能,或者修改已有功能時,如何才能確保應用可以繼續正常工作呢?
答案是,通過編寫自動化測試用例。
所謂自動化測試,是把由人驅動的測試行為改為由機器執行。具體來說就是,通過精心設計的測試用例,由機器按照執行步驟對應用進行自動測試,并輸出執行結果,最后根據測試用例定義的規則確定結果是否符合預期。
也就是說,自動化測試將重復的、機械的人工操作變為自動化的驗證步驟,極大的節省人力、時間和硬件資源,從而提高了測試效率。
在自動化測試用例的編寫上,Flutter 提供了包括單元測試和 UI 測試的能力。其中,單元測試可以方便地驗證單個函數、方法或類的行為,而 UI 測試則提供了與 Widget 進行交互的能力,確認其功能是否符合預期。
接下來,我們就具體看看這兩種自動化測試用例的用法吧。
## 單元測試
單元測試是指,對軟件中的最小可測試單元進行驗證的方式,并通過驗證結果來確定最小單元的行為是否與預期一致。所謂最小可測試單元,一般來說,就是人為規定的、最小的被測功能模塊,比如語句、函數、方法或類。
在 Flutter 中編寫單元測試用例,我們可以在 pubspec.yaml 文件中使用 test 包來完成。其中,test 包提供了編寫單元測試用例的核心框架,即定義、執行和驗證。如下代碼所示,就是 test 包的用法:
~~~
dev_dependencies: test:
~~~
> 備注:test 包的聲明需要在 dev\_dependencies 下完成,在這個標簽下面定義的包只會在開發模式生效。
與 Flutter 應用通過 main 函數定義程序入口相同,Flutter 單元測試用例也是通過 main 函數來定義測試入口的。不過,**這兩個程序入口的目錄位置有些區別**:應用程序的入口位于工程中的 lib 目錄下,而測試用例的入口位于工程中的 test 目錄下。
一個有著單元測試用例的 Flutter 工程目錄結構,如下所示:
:-: 
圖 1 Flutter 工程目錄結構
接下來,我們就可以在 main.dart 中聲明一個用來測試的類了。在下面的例子中,我們聲明了一個計數器類 Counter,這個類可以支持以遞增或遞減的方式修改計數值 count:
~~~
class Counter {
int count = 0;
void increase() => count++;
void decrease() => count--;
}
~~~
實現完待測試的類,我們就可以為它編寫測試用例了。**在 Flutter 中,測試用例的聲明包含定義、執行和驗證三個部分:**定義和執行決定了被測試對象提供的、需要驗證的最小可測單元;而驗證則需要使用 expect 函數,將最小可測單元的執行結果與預期進行比較。
所以,在 Flutter 中編寫一個測試用例,通常包含以下兩大步驟:
1. 實現一個包含定義、執行和驗證步驟的測試用例;
2. 將其包裝在 test 內部,test 是 Flutter 提供的測試用例封裝類。
在下面的例子中,我們定義了兩個測試用例,其中第一個用例用來驗證調用 increase 函數后的計數器值是否為 1,而第二個用例則用來判斷 1+1 是否等于 2:
~~~
import 'package:test/test.dart';
import 'package:flutter_app/main.dart';
void main() {
// 第一個用例,判斷 Counter 對象調用 increase 方法后是否等于 1
test('Increase a counter value should be 1', () {
final counter = Counter();
counter.increase();
expect(counter.value, 1);
});
// 第二個用例,判斷 1+1 是否等于 2
test('1+1 should be 2', () {
expect(1+1, 2);
});
}
~~~
選擇 widget\_test.dart 文件,在右鍵彈出的菜單中選擇“Run ‘tests in widget\_test’”,就可以啟動測試用例了。
:-: 
圖 2 啟動測試用例入口
稍等片刻,控制臺就會輸出測試用例的執行結果了。當然,這兩個用例都能通過測試:
~~~
22:05 Tests passed: 2
~~~
**如果測試用例的執行結果是不通過,Flutter 會給我們怎樣的提示呢?**我們試著修改一下第一個計數器遞增的用例,將它的期望結果改為 2:
~~~
test('Increase a counter value should be 1', () {
final counter = Counter();
counter.increase();
expect(counter.value, 2);// 判斷 Counter 對象調用 increase 后是否等于 2
});
~~~
運行測試用例,可以看到,Flutter 在執行完計數器的遞增方法后,發現其結果 1 與預期的 2 不匹配,于是報錯:
:-: 
圖 3 單元測試失敗示意圖
上面的示例演示了單個測試用例的編寫方法,而**如果有多個測試用例**,它們之間是存在關聯關系的,我們可以在最外層使用 group 將它們組合在一起。
在下面的例子中,我們定義了計數器遞增和計數器遞減兩個用例,驗證遞增的結果是否等于 1 的同時判斷遞減的結果是否等于 -1,并把它們組合在了一起:
~~~
import 'package:test/test.dart';
import 'package:counter_app/counter.dart';
void main() {
// 組合測試用例,判斷 Counter 對象調用 increase 方法后是否等于 1,并且判斷 Counter 對象調用 decrease 方法后是否等于 -1
group('Counter', () {
test('Increase a counter value should be 1', () {
final counter = Counter();
counter.increase();
expect(counter.value, 1);
});
test('Decrease a counter value should be -1', () {
final counter = Counter();
counter.decrease();
expect(counter.value, -1);
});
});
}
~~~
同樣的,這兩個測試用例的執行結果也是通過。
**在對程序的內部功能進行單元測試時,我們還可能需要從外部依賴(比如 Web 服務)獲取需要測試的數據。**比如下面的例子,Todo 對象的初始化就是通過 Web 服務返回的 JSON 實現的。考慮到調用 Web 服務的過程中可能會出錯,所以我們還處理了請求碼不等于 200 的其他異常情況:
~~~
import 'package:http/http.dart' as http;
class Todo {
final String title;
Todo({this.title});
// 工廠類構造方法,將 JSON 轉換為對象
factory Todo.fromJson(Map<String, dynamic> json) {
return Todo(
title: json['title'],
);
}
}
Future<Todo> fetchTodo(http.Client client) async {
final response =
await client.get('https://xxx.com/todos/1');
if (response.statusCode == 200) {
// 請求成功,解析 JSON
return Todo.fromJson(json.decode(response.body));
} else {
// 請求失敗,拋出異常
throw Exception('Failed to load post');
}
}
~~~
考慮到這些外部依賴并不是我們的程序所能控制的,因此很難覆蓋所有可能的成功或失敗方案。比如,對于一個正常運行的 Web 服務來說,我們基本不可能測試出 fetchTodo 這個接口是如何應對 403 或 502 狀態碼的。因此,更好的一個辦法是,在測試用例中“模擬”這些外部依賴(對應本例即為 http.client),讓這些外部依賴可以返回特定結果。
在單元測試用例中模擬外部依賴,我們需要在 pubspec.yaml 文件中使用 mockito 包,以接口實現的方式定義外部依賴的接口:
~~~
dev_dependencies:
test:
mockito:
~~~
要**使用 mockito 包來模擬 fetchTodo 的依賴 http.client**,我們首先需要定義一個繼承自 Mock(這個類可以模擬任何外部依賴),并以接口定義的方式實現了 http.client 的模擬類;然后,在測試用例的聲明中,為其制定任意的接口返回。
在下面的例子中,我們定義了一個模擬類 MockClient,這個類以接口聲明的方式獲取到了 http.Client 的外部接口。隨后,我們就可以使用 when 語句,在其調用 Web 服務時,為其注入相應的數據返回了。在第一個用例中,我們為其注入了 JSON 結果;而在第二個用例中,我們為其注入了一個 403 的異常。
~~~
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;
class MockClient extends Mock implements http.Client {}
void main() {
group('fetchTodo', () {
test('returns a Todo if successful', () async {
final client = MockClient();
// 使用 Mockito 注入請求成功的 JSON 字段
when(client.get('https://xxx.com/todos/1'))
.thenAnswer((_) async => http.Response('{"title": "Test"}', 200));
// 驗證請求結果是否為 Todo 實例
expect(await fetchTodo(client), isInstanceOf<Todo>());
});
test('throws an exception if error', () {
final client = MockClient();
// 使用 Mockito 注入請求失敗的 Error
when(client.get('https://xxx.com/todos/1'))
.thenAnswer((_) async => http.Response('Forbidden', 403));
// 驗證請求結果是否拋出異常
expect(fetchTodo(client), throwsException);
});
});
}
~~~
運行這段測試用例,可以看到,我們在沒有調用真實 Web 服務的情況下,成功模擬出了正常和異常兩種結果,同樣也是順利通過驗證了。
接下來,我們再看看 UI 測試吧。
## UI 測試
UI 測試的目的是模仿真實用戶的行為,即以真實用戶的身份對應用程序執行 UI 交互操作,并涵蓋各種用戶流程。相比于單元測試,UI 測試的覆蓋范圍更廣、更關注流程和交互,可以找到單元測試期間無法找到的錯誤。
在 Flutter 中編寫 UI 測試用例,我們需要在 pubspec.yaml 中使用 flutter\_test 包,來提供編寫**UI 測試的核心框架**,即定義、執行和驗證:
* 定義,即通過指定規則,找到 UI 測試用例需要驗證的、特定的子 Widget 對象;
* 執行,意味著我們要在找到的子 Widget 對象中,施加用戶交互事件;
* 驗證,表示在施加了交互事件后,判斷待驗證的 Widget 對象的整體表現是否符合預期。
如下代碼所示,就是 flutter\_test 包的用法:
~~~
dev_dependencies:
flutter_test:
sdk: flutter
~~~
接下來,我以 Flutter 默認的計時器應用模板為例,與你說明**UI 測試用例的編寫方法**。
在計數器應用中,有兩處地方會響應外部交互事件,包括響應用戶點擊行為的按鈕 Icon,與響應渲染刷新事件的文本 Text。按鈕點擊后,計數器會累加,文本也隨之刷新。
:-: 
圖 4 計數器示例
為確保程序的功能正常,我們希望編寫一個 UI 測試用例,來驗證按鈕的點擊行為是否與文本的刷新行為完全匹配。
與單元測試使用 test 對用例進行包裝類似,**UI 測試使用 testWidgets 對用例進行包裝**。testWidgets 提供了 tester 參數,我們可以使用這個實例來操作需要測試的 Widget 對象。
在下面的代碼中,我們**首先**聲明了需要驗證的 MyApp 對象。在通過 pumpWidget 觸發其完成渲染后,使用 find.text 方法分別查找了字符串文本為 0 和 1 的 Text 控件,目的是驗證響應刷新事件的文本 Text 的初始化狀態是否為 0。
**隨后**,我們通過 find.byIcon 方法找到了按鈕控件,并通過 tester.tap 方法對其施加了點擊行為。在完成了點擊后,我們使用 tester.pump 方法強制觸發其完成渲染刷新。**最后**,我們使用了與驗證 Text 初始化狀態同樣的語句,判斷在響應了刷新事件后的文本 Text 其狀態是否為 1:
~~~
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app_demox/main.dart';
void main() {
testWidgets('Counter increments UI test', (WidgetTester tester) async {
// 聲明所需要驗證的 Widget 對象 (即 MyApp),并觸發其渲染
await tester.pumpWidget(MyApp());
// 查找字符串文本為'0'的 Widget,驗證查找成功
expect(find.text('0'), findsOneWidget);
// 查找字符串文本為'1'的 Widget,驗證查找失敗
expect(find.text('1'), findsNothing);
// 查找'+'按鈕,施加點擊行為
await tester.tap(find.byIcon(Icons.add));
// 觸發其渲染
await tester.pump();
// 查找字符串文本為'0'的 Widget,驗證查找失敗
expect(find.text('0'), findsNothing);
// 查找字符串文本為'1'的 Widget,驗證查找成功
expect(find.text('1'), findsOneWidget);
});
}
~~~
運行這段 UI 測試用例代碼,同樣也順利通過驗證了。
除了點擊事件之外,tester 還支持其他的交互行為,比如文字輸入 enterText、拖動 drag、長按 longPress 等,這里我就不再一一贅述了。如果你想深入理解這些內容,可以參考 WidgetTester 的[官方文檔](https://api.flutter.dev/flutter/flutter_test/WidgetTester-class.html)進行學習。
## 總結
好了,今天的分享就到這里,我們總結一下今天的主要內容吧。
在 Flutter 中,自動化測試可以分為單元測試和 UI 測試。
單元測試的步驟,包括定義、執行和驗證。通過單元測試用例,我們可以驗證單個函數、方法或類,其行為表現是否與預期一致。而 UI 測試的步驟,同樣是包括定義、執行和驗證。我們可以通過模仿真實用戶的行為,對應用進行交互操作,覆蓋更廣的流程。
如果測試對象存在像 Web 服務這樣的外部依賴,為了讓單元測試過程更為可控,我們可以使用 mockito 為其定制任意的數據返回,實現正常和異常兩種測試用例。
需要注意的是,盡管 UI 測試擴大了應用的測試范圍,可以找到單元測試期間無法找到的錯誤,不過相比于單元測試用例來說,UI 測試用例的開發和維護代價非常高。因為一個移動應用最主要的功能其實就是 UI,而 UI 的變化非常頻繁,UI 測試需要不斷的維護才能保持穩定可用的狀態。
“投入和回報”永遠是考慮是否采用 UI 測試,以及采用何種級別的 UI 測試,需要最優先考慮的問題。我推薦的原則是,項目達到一定的規模,并且業務特征具有一定的延續規律性后,再考慮 UI 測試的必要性。
我把今天分享涉及的知識點打包到了[GitHub](https://github.com/cyndibaby905/38_test_app)中,你可以下載下來,反復運行幾次,加深理解與記憶。
## 思考題
最后,我給你留下一道思考題吧。
在下面的代碼中,我們定義了 SharedPreferences 的更新和遞增方法。請你使用 mockito 模擬 SharedPreferences 的方式,來為這兩個方法實現對應的單元測試用例。
~~~
Future<bool>updateSP(SharedPreferences prefs, int counter) async {
bool result = await prefs.setInt('counter', counter);
return result;
}
Future<int>increaseSPCounter(SharedPreferences prefs) async {
int counter = (prefs.getInt('counter') ?? 0) + 1;
await updateSP(prefs, counter);
return counter;
}
~~~
- 前言
- 開篇詞
- 預習篇
- 01丨預習篇 · 從0開始搭建Flutter工程環境
- 02丨預習篇 · Dart語言概覽
- Flutter開發起步
- 03丨深入理解跨平臺方案的歷史發展邏輯
- 04丨Flutter區別于其他方案的關鍵技術是什么?
- 05丨從標準模板入手,體會Flutter代碼是如何運行在原生系統上的
- Dart語言基礎
- 06丨基礎語法與類型變量:Dart是如何表示信息的?
- 07丨函數、類與運算符:Dart是如何處理信息的?
- 08丨綜合案例:掌握Dart核心特性
- Flutter基礎
- 09丨Widget,構建Flutter界面的基石
- 10丨Widget中的State到底是什么?
- 11丨提到生命周期,我們是在說什么?
- 12丨經典控件(一):文本、圖片和按鈕在Flutter中怎么用?
- 13丨ListView在Flutter中是什么?
- 14 丨 經典布局:如何定義子控件在父容器中排版位置?
- 15 丨 組合與自繪,我該選用何種方式自定義Widget?
- 16 丨 從夜間模式說起,如何定制不同風格的App主題?
- 17丨依賴管理(一):圖片、配置和字體在Flutter中怎么用?
- 18丨依賴管理(二):第三方組件庫在Flutter中要如何管理?
- 19丨用戶交互事件該如何響應?
- 20丨關于跨組件傳遞數據,你只需要記住這三招
- 21丨路由與導航,Flutter是這樣實現頁面切換的
- Flutter進階
- 22丨如何構造炫酷的動畫效果?
- 23丨單線程模型怎么保證UI運行流暢?
- 24丨HTTP網絡編程與JSON解析
- 25丨本地存儲與數據庫的使用和優化
- 26丨如何在Dart層兼容Android-iOS平臺特定實現?(一)
- 27丨如何在Dart層兼容Android-iOS平臺特定實現?(二)
- 28丨如何在原生應用中混編Flutter工程?
- 29丨混合開發,該用何種方案管理導航棧?
- 30丨為什么需要做狀態管理,怎么做?
- 31丨如何實現原生推送能力?
- 32丨適配國際化,除了多語言我們還需要注意什么
- 33丨如何適配不同分辨率的手機屏幕?
- 34丨如何理解Flutter的編譯模式?
- 35丨HotReload是怎么做到的?
- 36丨如何通過工具鏈優化開發調試效率?
- 37丨如何檢測并優化FlutterApp的整體性能表現?
- 38丨如何通過自動化測試提高交付質量?
- Flutter綜合應用
- 39丨線上出現問題,該如何做好異常捕獲與信息采集?
- 40丨衡量FlutterApp線上質量,我們需要關注這三個指標
- 41丨組件化和平臺化,該如何組織合理穩定的Flutter工程結構?
- 42丨如何構建高效的FlutterApp打包發布環境?
- 43丨如何構建自己的Flutter混合開發框架(一)?
- 44丨如何構建自己的Flutter混合開發框架(二)?
- 結束語
- 結束語丨勿畏難,勿輕略