點選、拖拽事件和文字輸入

我們建立的大部分 widget 不僅僅需要展示訊息,還需要回應使用者互動。常見的互動有用戶點選按鈕、在螢幕上拖動元件和在 TextField 中輸入文字。

為了測試這些互動,我們需要在測試環境中模擬上述場景,可以使用 WidgetTester 函式庫。

WidgetTester 提供了文字輸入、點選、拖動的相關方法:

在很多情況下,使用者互動會更新應用狀態。在測試環境中,Flutter 在狀態發生改變的時候並不會自動重建 widget。為了保證模擬使用者互動實現後,widget 樹能重建,一定要呼叫 WidgetTester 提供的 pump()pumpAndSettle()

步驟

  1. 建立待測 Widget

  2. 在文字區輸入文字

  3. 點選按鈕,增加待辦清單項

  4. 滑動刪除待辦清單項

1. 建立待測 Widget

在這個範例中,我們將會建立一個簡單的待辦清單應用。其中有三個主要的功能點需要測試:

  1. TextField 中輸入文字

  2. 點選 FloatingActionButton,把文字加入到待辦清單清單

  3. 滑動移除清單中的待辦清單項

為了聚焦在測試上,本章節並不會提供如何建立一個待辦清單應用的具體教程。如果想要知道這個應用是如何建立的,請參考以下章節:

class TodoList extends StatefulWidget {
  const TodoList({super.key});

  @override
  State<TodoList> createState() => _TodoListState();
}

class _TodoListState extends State<TodoList> {
  static const _appTitle = 'Todo List';
  final todos = <String>[];
  final controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _appTitle,
      home: Scaffold(
        appBar: AppBar(
          title: const Text(_appTitle),
        ),
        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: (direction) => todos.removeAt(index),
                    background: Container(color: Colors.red),
                    child: ListTile(title: Text(todo)),
                  );
                },
              ),
            ),
          ],
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            setState(() {
              todos.add(controller.text);
              controller.clear();
            });
          },
          child: const Icon(Icons.add),
        ),
      ),
    );
  }
}

2. 在文字區輸入文字

我們有了一個待辦清單項應用以後,就可以開始編寫測試用例了。在本範例中,我們會先測試在文字區輸入文字。

完成這項任務,需要做到:

  1. 在測試環境建立 Widget

  2. 使用 WidgetTester 中的 enterText() 方法

testWidgets('Add and remove a todo', (tester) async {
  // Build the widget
  await tester.pumpWidget(const TodoList());

  // Enter 'hi' into the TextField.
  await tester.enterText(find.byType(TextField), 'hi');
});

3. 點選按鈕,增加待辦清單項

TextField 中輸入文字後,需要確保能夠點選 FloatingActionButton,將文字作為清單項加入清單中。

這包含了三個步驟:

  1. 使用 tap() 方法模擬點選按鈕

  2. 使用 pump() 方法確保應用狀態發生改變時可以重建 widget

  3. 確保清單清單項展現在螢幕上

testWidgets('Add and remove a todo', (tester) async {
  // Enter text code...

  // Tap the add button.
  await tester.tap(find.byType(FloatingActionButton));

  // Rebuild the widget after the state has changed.
  await tester.pump();

  // Expect to find the item on screen.
  expect(find.text('hi'), findsOneWidget);
});

4. 滑動刪除待辦清單項

最後,我們需要確保滑動刪除的操作能夠正常地從清單中移除清單項。這包含了三個步驟:

  1. 使用 drag() 方法模擬滑動刪除操作。

  2. 使用 pumpAndSettle() 方法使 widget 樹保持重建更新,直到消除的動畫完成。

  3. 確保上述清單項不會再出現在螢幕上

testWidgets('Add and remove a todo', (tester) async {
  // Enter text and add the item...

  // Swipe the item to dismiss it.
  await tester.drag(find.byType(Dismissible), const Offset(500, 0));

  // Build the widget until the dismiss animation ends.
  await tester.pumpAndSettle();

  // Ensure that the item is no longer on screen.
  expect(find.text('hi'), findsNothing);
});

完整範例

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('Add and remove a todo', (tester) async {
    // Build the widget.
    await tester.pumpWidget(const TodoList());

    // Enter 'hi' into the TextField.
    await tester.enterText(find.byType(TextField), 'hi');

    // Tap the add button.
    await tester.tap(find.byType(FloatingActionButton));

    // Rebuild the widget with the new item.
    await tester.pump();

    // Expect to find the item on screen.
    expect(find.text('hi'), findsOneWidget);

    // Swipe the item to dismiss it.
    await tester.drag(find.byType(Dismissible), const Offset(500, 0));

    // Build the widget until the dismiss animation ends.
    await tester.pumpAndSettle();

    // Ensure that the item is no longer on screen.
    expect(find.text('hi'), findsNothing);
  });
}

class TodoList extends StatefulWidget {
  const TodoList({super.key});

  @override
  State<TodoList> createState() => _TodoListState();
}

class _TodoListState extends State<TodoList> {
  static const _appTitle = 'Todo List';
  final todos = <String>[];
  final controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _appTitle,
      home: Scaffold(
        appBar: AppBar(
          title: const Text(_appTitle),
        ),
        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: (direction) => todos.removeAt(index),
                    background: Container(color: Colors.red),
                    child: ListTile(title: Text(todo)),
                  );
                },
              ),
            ),
          ],
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            setState(() {
              todos.add(controller.text);
              controller.clear();
            });
          },
          child: const Icon(Icons.add),
        ),
      ),
    );
  }
}