flutter-add-widget-test

flutter-add-widget-test

熱門

使用 `WidgetTester` 實作元件層級測試,驗證 UI 渲染與使用者互動(點擊、滾動、輸入文字)。適用於確認特定 widget 顯示正確資料並如預期回應事件。

2525星標
152分支
更新於 2026/6/18
SKILL.md
readonlyread-only
name
flutter-add-widget-test
description

使用 `WidgetTester` 實作元件層級測試,驗證 UI 渲染與使用者互動(點擊、滾動、輸入文字)。適用於確認特定 widget 顯示正確資料並如預期回應事件。

撰寫 Flutter Widget 測試

目錄

設定與配置

在撰寫 widget 測試前,請確保測試環境已正確配置。

  1. pubspec.yamldev_dependencies 區段中加入 flutter_test 依賴。
  2. 將所有測試檔案放在專案根目錄的 test/ 資料夾中。
  3. 所有測試檔名須以 _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 是否存在或其狀態(例如 findsOneWidgetfindsNothingfindsNWidgets(2)matchesGoldenFile)。

工作流程:實作 Widget 測試

複製以下檢查清單以追蹤實作新 widget 測試的進度。

任務進度

  • [ ] 步驟 1:定義測試。 使用 testWidgets('description', (WidgetTester tester) async { ... })
  • [ ] 步驟 2:建構 widget。 呼叫 await tester.pumpWidget(MyWidget()) 來渲染 UI。如果 widget 需要繼承的方向性或主題資料,請將其包裝在 MaterialAppDirectionality widget 中。
  • [ ] 步驟 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() 斷言。
  • 如果測試標準狀態變更(例如按鈕點擊):
    1. 呼叫 await tester.tap(finder)
    2. 呼叫 await tester.pump() 以觸發單一影格重建。
  • 如果測試動畫、轉場或非同步 UI 更新:
    1. 觸發動作(例如 await tester.drag(finder, Offset(500, 0)))。
    2. 呼叫 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);
  });
}