手書きの偽物、シミュレートされた時計、そして正しいpumpの反射神経で、ロジックとウィジェットをテストしましょう。
このレッスンを Kodokon で開くFlutterのテストピラミッドはこうです。純粋なDartの単体テストという広い土台、ウィジェットテストの層、そしていくつかの結合テストです。単体テストはmaterial.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();
}
}setUpは各テストの前にオブジェクトを作り直します。共有された状態がないので、実行順序に依存するテストもありません。それが信頼性の条件です。合成できるマッチャー(isA<Exception>()、throwsA、isNull)は、真偽値よりずっと役立つ、正確な失敗メッセージを生み出します。下の二つ目のテストに注目してください。失敗を検証し、それからすぐそのあとに回復を検証します。エラーの経路もほかと同じコードです。テストされていないなら、壊れているとみなしましょう。
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))は時間を飛ばします。アニメーションの中間状態を凍らせて調べるのに理想的です。Finder(find.text、find.byType、find.byKey)は、スクリーンショットではなく、Elementの本物のツリーに問い合わせます。時計もネットワークも、何も本当には非同期でないので、これらのテストは決定論的です。失敗はいつでも再現できます。
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);
});
}flutter test
flutter test test/score_controller_test.dart
flutter test --coverage
flutter test --update-goldens