Kodokon kodokon.com

テスト:Dartの単体テストとウィジェットテスト

手書きの偽物、シミュレートされた時計、そして正しいpumpの反射神経で、ロジックとウィジェットをテストしましょう。

11 分 · 3 問

このレッスンを Kodokon で開く

Flutterのテストピラミッドはこうです。純粋なDartの単体テストという広い土台、ウィジェットテストの層、そしていくつかの結合テストです。単体テストはmaterial.dartを決してインポートすべきではありません。もしあなたのロジックがそれを必要とするなら、問題は技術的なものではなく設計的なものです。コードを依存から隔離するには、生成されたモックよりも手書きの偽物を選びましょう。偽物とは、契約を単純化しつつも本当に機能する実装(メモリ内リポジトリ、制御できる時計)です。リファクタリングを生き延び、普通のコードのように読めます。相互作用を検証するモック(「このメソッドは二回呼ばれた」)は、テストを実装の詳細に結合させます。相互作用こそが振る舞いであるようなプロトコルのためにとっておきましょう。

DART
import 'package:flutter/foundation.dart';

abstract interface class ScoreRepository {
  Future<int> fetchBest();
}

class ScoreController extends ChangeNotifier {
  ScoreController(this._repository);

  final ScoreRepository _repository;

  int? best;
  Object? error;

  Future<void> load() async {
    try {
      best = await _repository.fetchBest();
      error = null;
    } on Exception catch (e) {
      error = e;
    }
    notifyListeners();
  }
}
テスト対象のコード。material.dartのインポートはない。

setUpテストの前にオブジェクトを作り直します。共有された状態がないので、実行順序に依存するテストもありません。それが信頼性の条件です。合成できるマッチャー(isA<Exception>()throwsAisNull)は、真偽値よりずっと役立つ、正確な失敗メッセージを生み出します。下の二つ目のテストに注目してください。失敗を検証し、それからすぐそのあとに回復を検証します。エラーの経路もほかと同じコードです。テストされていないなら、壊れているとみなしましょう。

DART
import 'package:flutter_test/flutter_test.dart';

class FakeScoreRepository implements ScoreRepository {
  int calls = 0;
  bool failNextCall = false;

  @override
  Future<int> fetchBest() async {
    calls++;
    if (failNextCall) {
      failNextCall = false;
      throw Exception('network unavailable');
    }
    return 42;
  }
}

void main() {
  late FakeScoreRepository repository;
  late ScoreController controller;

  setUp(() {
    repository = FakeScoreRepository();
    controller = ScoreController(repository);
  });

  test('exposes the best score', () async {
    await controller.load();

    expect(controller.best, 42);
    expect(controller.error, isNull);
    expect(repository.calls, 1);
  });

  test('captures an error then recovers', () async {
    repository.failNextCall = true;

    await controller.load();
    expect(controller.error, isA<Exception>());

    await controller.load();
    expect(controller.best, 42);
    expect(controller.error, isNull);
  });

  test('notifies its listeners', () async {
    var notifications = 0;
    controller.addListener(() => notifications++);

    await controller.load();

    expect(notifications, 1);
  });
}
十五行の偽物が、モックのライブラリまるごとを置き換える。

ウィジェットテストは画面には何も描画しません。時間がシミュレートされ、各フレームが要求に応じて生成される、専用のバインディング(AutomatedTestWidgetsFlutterBinding)の中で動きます。pumpWidgetはツリーをマウントします。pump()は偽の時計を進め、一つのフレームを生成します。pump(const Duration(milliseconds: 150))は時間を飛ばします。アニメーションの中間状態を凍らせて調べるのに理想的です。Finderfind.textfind.byTypefind.byKey)は、スクリーンショットではなく、Elementの本物のツリーに問い合わせます。時計もネットワークも、何も本当には非同期でないので、これらのテストは決定論的です。失敗はいつでも再現できます。

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

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

  @override
  State<BadgeScreen> createState() => _BadgeScreenState();
}

class _BadgeScreenState extends State<BadgeScreen> {
  bool _visible = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          if (_visible) const Text('New'),
          IconButton(
            icon: const Icon(Icons.close),
            onPressed: () =>
                setState(() => _visible = false),
          ),
        ],
      ),
    );
  }
}

void main() {
  testWidgets('hides the badge after the tap',
      (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: BadgeScreen()),
    );
    expect(find.text('New'), findsOneWidget);

    await tester.tap(find.byIcon(Icons.close));
    await tester.pump();

    expect(find.text('New'), findsNothing);
  });
}
tapはフレームを起こさない。続くpumpが必要になる。
BASH
flutter test
flutter test test/score_controller_test.dart
flutter test --coverage
flutter test --update-goldens
ファイルを指定し、カバレッジを計測し、goldenを再生成する。

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. repeat()で始まったアニメーションに対して、pumpAndSettleはなぜ失敗するのですか?
    • スケジュールされたフレームが残っているかぎりフレームをpumpします。無限のアニメーションは決して安定しないので、タイムアウトになる
    • repeat()はテストのバインディングでサポートされていない
    • pumpAndSettleは暗黙的アニメーションしか扱わない
  2. 偽物(fake)とモック(mock)の違いは何ですか?
    • 偽物は単純化されつつも機能する実装です。モックは主に相互作用を検証するのに使われる
    • モックは手書きで、偽物はツールによって生成される
    • ない。二つの言葉は同義語
    • 偽物はDartのインターフェースを実装できない
  3. ウィジェットテストで、await tester.pump()は正確には何をしますか?
    • シミュレートされた時計を進め、一つの新しいフレームを生成する
    • 実行中のすべてのアニメーションが終わるのを待つ
    • テストを最初からやり直す
    • 実際のネットワークリクエストが終わるのを待つ