SKILL.md
唯讀
名稱
flutter-add-widget-test
描述
使用 `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 需要繼承方向性(Directionality)或主題(Theme)資料,請將其包裹在MaterialApp或Directionality中。 - [ ] 步驟 3:定位元素。 為目標 Widget 實例化
Finder物件。 - [ ] 步驟 4:驗證初始狀態。 使用
expect(finder, matcher)驗證首次繪製的結果。 - [ ] 步驟 5:模擬互動。 執行手勢或輸入操作(例如
await tester.tap(buttonFinder))。 - [ ] 步驟 6:重新建構元件樹。 呼叫
await tester.pump()或await tester.pumpAndSettle()來處理狀態變更。 - [ ] 步驟 7:驗證更新後的狀態。 使用
expect()驗證互動後的 UI。 - [ ] 步驟 8:執行並驗證。 執行
flutter test test/your_test_file_test.dart。 - [ ] 步驟 9:除錯循環。 檢視測試輸出結果 -> 找出失敗的 Matcher -> 調整 Widget 邏輯或測試斷言(Assertion) -> 重新執行直到測試通過。
互動與狀態管理
根據所測試的互動類型或狀態變更,套用以下對應邏輯:
- 若測試靜態繪製: 呼叫一次
await tester.pumpWidget(),隨即執行expect()斷言驗證。 - 若測試一般的狀態變更(例如點擊按鈕):
- 呼叫
await tester.tap(finder)。 - 呼叫
await tester.pump()以觸發單一畫面的重新建構。
- 呼叫
- 若測試動畫、過渡效果或非同步 UI 更新:
- 觸發動作(例如
await tester.drag(finder, Offset(500, 0)))。 - 呼叫
await tester.pumpAndSettle()持續更新畫面,直到沒有新的畫面排程為止(即動畫播放完畢)。
- 觸發動作(例如
- 若測試文字輸入: 呼叫
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('Add and remove a todo item', (WidgetTester tester) async {
// 1. Build the widget
await tester.pumpWidget(const TodoList());
// 2. Verify initial state
expect(find.byType(ListTile), findsNothing);
// 3. Enter text into the TextField
await tester.enterText(find.byType(TextField), 'Buy groceries');
// 4. Tap the add button
await tester.tap(find.byType(FloatingActionButton));
// 5. Rebuild the widget to reflect the new state
await tester.pump();
// 6. Verify the item was added
expect(find.text('Buy groceries'), findsOneWidget);
// 7. Swipe the item to dismiss it
await tester.drag(find.byType(Dismissible), const Offset(500, 0));
// 8. Build the widget until the dismiss animation ends
await tester.pumpAndSettle();
// 9. Verify the item was removed
expect(find.text('Buy groceries'), findsNothing);
});
}






