ทดสอบตรรกะและวิดเจ็ตด้วย fake ที่เขียนด้วยมือ นาฬิกาจำลอง และปฏิกิริยาการใช้ pump ที่ถูกต้อง
เปิดบทเรียนนี้ใน Kodokonพีระมิดการทดสอบของ Flutter: ฐานกว้าง ๆ ของ ยูนิตเทสต์ ที่เป็น Dart ล้วน, ชั้นหนึ่งของ widget test และการทดสอบแบบ integration อีกเล็กน้อย ยูนิตเทสต์ไม่ควร import material.dart เด็ดขาด หากตรรกะของคุณต้องใช้มัน ปัญหาอยู่ที่สถาปัตยกรรม ไม่ใช่เรื่องเทคนิค ในการแยกโค้ดออกจากสิ่งที่มันพึ่งพา จงเลือกใช้ fake ที่เขียนด้วยมือ แทน mock ที่ถูกสร้างอัตโนมัติ: fake คือตัวการทำงานของสัญญาที่ถูกทำให้เรียบง่ายแต่ทำงานได้จริง (repository ในหน่วยความจำ, นาฬิกาที่ควบคุมได้) มันอยู่รอดผ่านการ refactor และอ่านได้เหมือนโค้ดปกติ ส่วน mock ซึ่งตรวจสอบการปฏิสัมพันธ์ ("เมท็อดนี้ถูกเรียกสองครั้ง") จะผูกเทสต์เข้ากับรายละเอียดของการทำงาน จงสงวนมันไว้สำหรับโปรโตคอลที่การปฏิสัมพันธ์ คือ พฤติกรรมนั้นเอง
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 สร้างอ็อบเจกต์ขึ้นใหม่ก่อนการทดสอบ แต่ละ ครั้ง: ไม่มีสเตตร่วมกัน จึงไม่มีเทสต์ที่ขึ้นอยู่กับลำดับการทำงาน นั่นคือเงื่อนไขของความน่าเชื่อถือของมัน matcher ที่ประกอบกันได้ (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);
});
}widget test ไม่เรนเดอร์อะไรออกหน้าจอเลย: มันทำงานใน binding เฉพาะทาง (AutomatedTestWidgetsFlutterBinding) ที่ซึ่ง เวลาถูกจำลอง และแต่ละเฟรมถูกผลิตขึ้นเมื่อร้องขอ pumpWidget ติดตั้ง tree ขึ้น ส่วน pump() เดินนาฬิกาจำลองไปข้างหน้าและผลิตเฟรม หนึ่ง เฟรม และ pump(const Duration(milliseconds: 150)) จะกระโดดข้ามเวลา เหมาะอย่างยิ่งสำหรับหยุดสเตตกลางคันของแอนิเมชันไว้เพื่อตรวจสอบ Finder (find.text, find.byType, find.byKey) จะสืบค้น tree ของ Element จริง ไม่ใช่ภาพหน้าจอ เพราะไม่มีอะไรเป็นอะซิงโครนัสอย่างแท้จริง ทั้งนาฬิกาและเครือข่าย เทสต์เหล่านี้จึง มีผลลัพธ์แน่นอน (deterministic): ความล้มเหลวสามารถทำซ้ำได้เสมอ
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