在 Flutter 中,「測試」是確保功能正常、效能穩定的重要步驟之一。Flutter 提供了不同種的測試方法,包括「單元測試 (unit test)」、widget 測試以及整合測試 (Integration testing)。其中,單元測試的概念,我們在 Day-15 在 Flutter 中自動生成 JSON 序列化程式碼並撰寫單元測試 有稍微提到,可以在 test/
資料夾設自己設計測試函式,並使用 flutter test
進行測試。單元測試主要在測試一個函式在特定輸入下是否能輸出預期值來保證該函式在修改後仍能正常運作。而 widget test 則是用來測試 UI 在一系列互動下是否仍能符合預期。
widget_test
是用來測試單個 widget 的行為,模擬 widget 的生命週期、繪製畫面,以及用戶與 UI 的互動,適合用來測試視覺元素及檢查 widget 的狀態變化。
本次的範例程式碼:https://github.com/ksw2000/ironman-2024/tree/master/flutter-practice/widget_test_practice
首先,我們觀察一個最簡單的例子,使用 flutter create 來建立一個全新的專案
flutter create widget_test_practice
.
└── widget_test_practice/
├── lib/
│ └── main.dart
└── test/
└── widget_test.dart
建立完新專案後,就是大家熟悉的 floatButton 點一下中間數字會加一的預設 APP。相信大家對 main.dart
裡每行指令都瞭若指掌了。接下來我們把目光放在 test/widget_test.dart
中:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:widget_test_practice/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
與之前所講的單元測試不同,widget test 使用 testWidgets
這個函式來做測試,而不是 test
。testWidgets
中的第一個參數用來指定測試的名稱,第二個參數則是主要測試的邏輯,是一個異步函式。
在測試的一開始,我們可以用 tester.pumpWidget
來渲染畫面,他的做用就像 lib/main.dart
裡 main()
函式中的 runApp()
函式。首先我們先渲染 MyApp()
,接著我們使用 find.text()
來尋找 MyApp()
畫面中的 Text
物件或 EditableText
物件。我們也可以調整參數使用尋找 RichText
物件
Finder text(
String text, {
bool findRichText = false
bool skipOffstage = true,
})
由於一開始渲染出來的畫面為 0,所以應該會找到一個值
expect(find.text('0'), findsOneWidget);
而因為畫面中沒有顯示為 1 的 Text
, EditableText
物件,因此預期是找不到
expect(find.text('1'), findsNothing);
接著我們模擬使用者點擊 floatingActionButton,由於主畫面中的 floatingActionButton 含有一個 Icon 物件,且整個 MyApp 只有這個按扭有這個 Icons.add
因此我們可以用 find.byIcon
來定位該按鈕。並且呼叫 tester.tap
來模擬使用者點擊。
FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
)
tap 函式會模擬點擊在 Widget 的正中間。當 warnIfMissed
設為 true
時,如果 Widget 不在螢幕上、或者為透明、或者被設為禁用狀態而無法點擊時,就會噴出警告訊息
Future<void> tap(
FinderBase<Element> finder, {
int? pointer,
int buttons = kPrimaryButton,
bool warnIfMissed = true,
PointerDeviceKind kind = PointerDeviceKind.touch,
}
當我們模擬點擊後,還需要使用 tester.pump()
等待畫面重繪。由於我們已經將等待畫面更新完畢,此時畫面中間的數字會從 0 變成 1。我們可以再呼叫 expect
檢查 UI 是否符合預期。
find
的行為有點類似 Javascript 中尋找 DOM 節點的方法 document.getElementBy...
。那麼在 Javascript 中我們有一種方法是直接對 ID 做搜尋,那麼在 Flutter 中是否也有呢?
我們可以直接針對按鈕加上 key
值
FloatingActionButton(
key: const ValueKey("increment-button"),
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
)
而模擬觸發時則可以利用 find.byKey
來尋找
await tester.tap(find.byIcon(Icons.add));
如果我們有自訂的 Widget,也可以直接使用 find.byType
來定位
class MyButton extends StatelessWidget {
// ...
}
await tester.tap(find.byType(MyButton));
如同 Javascript 對 DOM 的定位,finder 也可以在 widget 中選擇上一層的 widget 或下一層的 widget,比如我們可以使用 find.descendant()
針對某個 widget 下層的所有 widget 中進行尋找;而 find.ancestor()
則可以在某個 widget 的上層 widget 中進行尋找。
舉例:假設我們將 FloatingActionButton 內的 Icon
也增加一個 key
值
floatingActionButton: FloatingActionButton(
key: const ValueKey("increment-button"),
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(
Icons.add,
key: ValueKey("increment-icon"),
),
),
我們可以從 increment-icon
的上層節點中尋找一個 Type
為 FloatingActionButton
的 Widget
find.ancestor(
of: find.byKey(const ValueKey("increment-icon")),
matching: find.byType(FloatingActionButton)));