SKILL.md
readonlyread-only
name
flutter-add-widget-test
description
使用 `WidgetTester` 實作元件層級測試,驗證 UI 渲染與使用者互動(點擊、滾動、輸入文字)。適用於確認特定 widget 顯示正確資料並如預期回應事件。
撰寫 Flutter Widget 測試
目錄
設定與配置
在撰寫 widget 測試前,請確保測試環境已正確配置。
- 在
pubspec.yaml的dev_dependencies區段中加入flutter_test依賴。 - 將所有測試檔案放在專案根目錄的
test/資料夾中。 - 所有測試檔名須以
_test.dart結尾(例如widget_test.dart)。
核心元件
使用以下 flutter_test 元件來與 widget 樹互動並進行驗證:
WidgetTester:在測試環境中建構與互動 widget 的主要介面。由testWidgets()函式自動提供。Finder:在測試環境中定位 widget(例如find.text('Submit')、find.byType(TextField)、find.byKey(Key('submit_btn')))。Matcher:驗證由Finder定位的 widget 是否存在或其狀態(例如findsOneWidget、findsNothing、findsNWidgets(2)、matchesGoldenFile)。
工作流程:實作 Widget 測試
複製以下檢查清單以追蹤實作新 widget 測試的進度。
任務進度
- [ ] 步驟 1:定義測試。 使用
testWidgets('description', (WidgetTester tester) async { ... })。 - [ ] 步驟 2:建構 widget。 呼叫
await tester.pumpWidget(MyWidget())來渲染 UI。如果 widget 需要繼承的方向性或主題資料,請將其包裝在MaterialApp或Directionalitywidget 中。 - [ ] 步驟 3:定位元素。 為目標 widget 建立
Finder物件。 - [ ] 步驟 4:驗證初始狀態。 使用
expect(finder, matcher)驗證初始渲染。 - [ ] 步驟 5:模擬互動。 執行手勢或輸入(例如
await tester.tap(buttonFinder))。 - [ ] 步驟 6:重建 widget 樹。 呼叫
await tester.pump()或await tester.pumpAndSettle()來處理狀態變更。 - [ ] 步驟 7:驗證更新後的狀態。 使用
expect()驗證互動後的 UI。 - [ ] 步驟 8:執行並驗證。 執行
flutter test test/your_test_file_test.dart。 - [ ] 步驟 9:回饋迴圈。 檢視測試輸出 -> 找出失敗的 matcher -> 調整 widget 邏輯或測試斷言 -> 重新執行直到通過。
互動與狀態管理
根據要測試的互動或狀態變更類型,套用以下條件邏輯:
- 如果測試靜態渲染: 呼叫
await tester.pumpWidget()一次,然後立即執行expect()斷言。 - 如果測試標準狀態變更(例如按鈕點擊):
- 呼叫
await tester.tap(finder)。 - 呼叫
await tester.pump()以觸發單一影格重建。
- 呼叫
- 如果測試動畫、轉場或非同步 UI 更新:
- 觸發動作(例如
await tester.drag(finder, Offset(500, 0)))。 - 呼叫
await tester.pumpAndSettle()以重複 pumping 影格,直到沒有更多排程影格(動畫完成)。
- 觸發動作(例如
- 如果測試文字輸入: 呼叫
await tester.enterText(textFieldFinder, 'Input string')。 - 如果測試動態或長列表中的項目: 呼叫
await tester.scrollUntilVisible(itemFinder, 500.0, scrollable: listFinder)以確保目標 widget 在互動前已被渲染。
範例
高擬真度 Widget 測試實作
目標 Widget(lib/todo_list.dart):
import 'package:flutter/material.dart';
class TodoList extends StatefulWidget {
const TodoList({super.key});
@override
State<TodoList> createState() => _TodoListState();
}
class _TodoListState extends State<TodoList> {
final todos = <String>[];
final controller = TextEditingController();
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Column(
children: [
TextField(controller: controller),
Expanded(
child: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return Dismissible(
key: Key('$todo$index'),
onDismissed: (_) => setState(() => todos.removeAt(index)),
child: ListTile(title: Text(todo)),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
todos.add(controller.text);
controller.clear();
});
},
child: const Icon(Icons.add),
),
),
);
}
}
測試實作(test/todo_list_test.dart):
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/todo_list.dart';
void main() {
testWidgets('新增與刪除待辦事項', (WidgetTester tester) async {
// 1. 建構 widget
await tester.pumpWidget(const TodoList());
// 2. 驗證初始狀態
expect(find.byType(ListTile), findsNothing);
// 3. 在 TextField 中輸入文字
await tester.enterText(find.byType(TextField), '買菜');
// 4. 點擊新增按鈕
await tester.tap(find.byType(FloatingActionButton));
// 5. 重建 widget 以反映新狀態
await tester.pump();
// 6. 驗證項目已新增
expect(find.text('買菜'), findsOneWidget);
// 7. 滑動項目以刪除
await tester.drag(find.byType(Dismissible), const Offset(500, 0));
// 8. 持續重建 widget 直到刪除動畫結束
await tester.pumpAndSettle();
// 9. 驗證項目已移除
expect(find.text('買菜'), findsNothing);
});
}






