Testing
Test สี่แบบ พีระมิดเดียว
หัวข้อที่มีชื่อว่า “Test สี่แบบ พีระมิดเดียว”Flutter ให้เครื่องมือ test สี่แบบ และมันแลกความเร็วกับความสมจริง ใช้แบบเร็วเยอะ ๆ กับแบบช้าน้อย ๆ
flowchart TB integ["Integration tests (whole app, real device) — few, slow"] --> widget["Widget tests (one widget tree, no device) — many, fast"] widget --> unit["Unit tests (pure Dart logic) — most, fastest"]
- Unit test ยืนยัน Dart บริสุทธิ์ — ฟังก์ชัน, class, logic ของ Cubit ไม่มี UI ไม่มีอุปกรณ์ เร็วระดับมิลลิวินาทีแต่ละอัน
- Widget test pump widget เข้า test harness แล้วโต้ตอบกับมัน (tap, ใส่ข้อความ, expect) — ไม่มีอุปกรณ์จริง ยังเร็วอยู่ นี่คือจุดหวานเฉพาะของ Flutter
- Integration test รันแอปทั้งตัวบนอุปกรณ์จริงหรือ emulator ขับมันแบบ end to end — ช้า แต่สมจริงที่สุด
- Golden test จับ pixel ที่ render ของ widget แล้วเทียบกับ reference image ที่บันทึกไว้ จับ visual regression ได้
Unit test
หัวข้อที่มีชื่อว่า “Unit test”import 'package:test/test.dart';
test('cart total sums item prices', () { final cart = Cart()..add(Item(price: 10))..add(Item(price: 5)); expect(cart.total, 15);});Dart ธรรมดา expect ธรรมดา logic ส่วนใหญ่ของคุณ — ราคา, การ validate, การ parse, state transition — ควร test ได้ง่ายแบบนี้ ซึ่งเป็นเหตุผลที่ดีที่จะเก็บ logic ไว้นอก widget
Widget test
หัวข้อที่มีชื่อว่า “Widget test”Widget test ใช้ WidgetTester เพื่อสร้าง widget, หา element, ทำอะไรบางอย่าง และ assert — ทั้งหมดในหน่วยความจำ
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';
testWidgets('tapping + increments the counter', (WidgetTester tester) async { await tester.pumpWidget(const MyApp()); // build the widget tree
expect(find.text('0'), findsOneWidget); // initial state
await tester.tap(find.byIcon(Icons.add)); // interact await tester.pump(); // rebuild after setState
expect(find.text('1'), findsOneWidget); // assert the new state});การเรียกหลัก: pumpWidget สร้าง tree, find หา widget, tap/enterText โต้ตอบ และ pump เดินหน้าเฟรมเดียวเป๊ะ ๆ เพื่อให้ rebuild เกิดขึ้น
Finder กับ matcher
หัวข้อที่มีชื่อว่า “Finder กับ matcher”find.text/find.byType/find.byKey/find.byIcon/find.widgetWithText คือ finder ที่ใช้บ่อย แต่ละตัวคืน Finder ที่คุณส่งให้ expect พร้อม matcher เรื่อง cardinality: findsOneWidget, findsNothing, findsWidgets (หนึ่งตัวขึ้นไป), findsNWidgets(n) รวม finder กับ assertion จริงบน widget ที่มันเจอผ่าน tester.widget<T>(finder) เมื่อคุณต้องเช็ค property ไม่ใช่แค่การมีอยู่
pump เทียบกับ pumpAndSettle พิสูจน์ด้วย animation จริง
หัวข้อที่มีชื่อว่า “pump เทียบกับ pumpAndSettle พิสูจน์ด้วย animation จริง”pump([duration]) เดินหน้าเฟรมเดียวเป๊ะ ๆ (อาจบวก duration คงที่ก่อน) — สำหรับ animation หลายเฟรม การเรียก pump() ครั้งเดียวพาคุณไปแค่ค่าของ animation ณ ขณะนั้นเท่านั้น pumpAndSettle() pump ซ้ำ ๆ จนไม่มีเฟรมถูก schedule แล้ว ซึ่งคือสิ่งที่คุณต้องการเมื่อ animation/transition ต้องจบสมบูรณ์ก่อนที่คุณจะ assert
import 'package:flutter/material.dart';
class FadeInText extends StatefulWidget { const FadeInText({super.key}); @override State<FadeInText> createState() => _FadeInTextState();}
class _FadeInTextState extends State<FadeInText> with SingleTickerProviderStateMixin { late final AnimationController controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), )..forward();
@override void dispose() { controller.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return FadeTransition( key: const Key('fade'), // disambiguates from MaterialApp's own internal FadeTransitions opacity: controller, child: const Text('loaded'), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/tooling-and-production__testing/pump_vs_settle_demo.dart';
void main() { testWidgets('a single pump() only advances one frame of a running animation', (tester) async { await tester.pumpWidget(const MaterialApp(home: FadeInText())); final opacityAfterOnePump = tester.widget<FadeTransition>(find.byKey(const Key('fade'))).opacity.value;
await tester.pump(const Duration(milliseconds: 150)); // halfway through 300ms final opacityMidway = tester.widget<FadeTransition>(find.byKey(const Key('fade'))).opacity.value;
expect(opacityAfterOnePump, lessThan(opacityMidway)); // genuinely progressed expect(opacityMidway, lessThan(1.0)); // NOT finished yet after a single pump });
testWidgets('pumpAndSettle() waits until the animation genuinely finishes', (tester) async { await tester.pumpWidget(const MaterialApp(home: FadeInText())); await tester.pumpAndSettle();
final finalOpacity = tester.widget<FadeTransition>(find.byKey(const Key('fade'))).opacity.value; expect(finalOpacity, 1.0); // fully settled });}00:00 +0: a single pump() only advances one frame of a running animation00:00 +1: pumpAndSettle() waits until the animation genuinely finishes
00:00 +2: All tests passed!test แรกพิสูจน์ว่า pump() เดินหน้าแค่สิ่งที่คุณบอกจริง ๆ (ยังอยู่กลาง fade ที่ 150ms ของ animation 300ms); test ที่สองพิสูจน์ว่า pumpAndSettle() ไม่หยุดจนกว่าเฟรมที่ animation schedule เองจะหมด หยิบ pumpAndSettle() มาใช้หลัง interaction ใดก็ตามที่เริ่ม animation/transition/Future ที่คุณต้องการให้ resolve สมบูรณ์ (route push, AnimatedSwitcher, snackbar) — แต่อย่าใช้กับ widget ที่มี animation ไม่รู้จบ (เช่น spinner ที่ใช้ ..repeat()) ซึ่งจะทำให้ pumpAndSettle() timeout รอเฟรมที่ไม่มีวันหยุด
Mock repository ด้วย mocktail พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “Mock repository ด้วย mocktail พิสูจน์แล้ว”widget จริงพึ่งพา collaborator จริง — repository, API client — และโค้ดที่ AI generate มักสร้างสิ่งเหล่านั้นตรง ๆ ข้างใน widget แทนที่จะรับมันเป็น parameter ซึ่งทำให้ widget test ไม่ได้จริง ๆ โดยไม่ต้องยิง network mocktail (เวอร์ชันปัจจุบัน 1.0.5 ตาม pubspec.lock ที่ resolve แล้วของคอร์สนี้เอง) ให้คุณแทนที่ด้วย implementation ปลอม และ assert ได้ตรง ๆ ว่ามันถูกเรียกยังไง
import 'package:flutter/material.dart';
abstract class UserRepository { Future<String> fetchUserName(String id);}
class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key, required this.repository, required this.userId}); final UserRepository repository; final String userId;
@override State<ProfileScreen> createState() => _ProfileScreenState();}
class _ProfileScreenState extends State<ProfileScreen> { late final Future<String> _nameFuture;
@override void initState() { super.initState(); _nameFuture = widget.repository.fetchUserName(widget.userId); // injected, not constructed inline }
@override Widget build(BuildContext context) { return FutureBuilder<String>( future: _nameFuture, builder: (context, snapshot) { if (!snapshot.hasData) return const CircularProgressIndicator(); return Text(snapshot.data!); }, ); }}
// A second collaborator whose method takes a custom (non-primitive) type --// this is what registerFallbackValue exists for, proven below.class AnalyticsEvent { AnalyticsEvent(this.name); final String name;}
abstract class Analytics { Future<void> logEvent(AnalyticsEvent event);}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:mocktail/mocktail.dart';import 'package:probe/lessons/tooling-and-production__testing/mocktail_demo.dart';
class MockUserRepository extends Mock implements UserRepository {}
class MockAnalytics extends Mock implements Analytics {}
// A Fake stands in for AnalyticsEvent ONLY as a fallback value for any() --// mocktail never calls its members, so an empty Fake is enough.class FakeAnalyticsEvent extends Fake implements AnalyticsEvent {}
void main() { setUpAll(() { registerFallbackValue(FakeAnalyticsEvent()); });
testWidgets('ProfileScreen shows the name the mocked repository returns', (tester) async { final mockRepo = MockUserRepository(); when(() => mockRepo.fetchUserName('42')) .thenAnswer((_) async => 'Ada Lovelace');
await tester.pumpWidget(MaterialApp( home: ProfileScreen(repository: mockRepo, userId: '42'), )); await tester.pump(); // let the FutureBuilder's snapshot settle
expect(find.text('Ada Lovelace'), findsOneWidget); verify(() => mockRepo.fetchUserName('42')).called(1); });
test('registerFallbackValue is what makes any() work for a custom type', () async { final analytics = MockAnalytics(); // Without the setUpAll registration above, this line throws -- proven // in prose below with the real, unedited error text. when(() => analytics.logEvent(any())).thenAnswer((_) async {});
await analytics.logEvent(AnalyticsEvent('screen_view'));
verify(() => analytics.logEvent(any())).called(1); });}00:00 +0: ProfileScreen shows the name the mocked repository returns00:00 +1: registerFallbackValue is what makes any() work for a custom type
00:00 +2: All tests passed!verify(() => mockRepo.fetchUserName('42')).called(1) คือครึ่งหลังของบทพิสูจน์ที่ tutorial ส่วนใหญ่ข้ามไป: มันไม่ได้แค่เช็คว่า widget render ข้อความที่ถูก (ซึ่ง string hardcode ก็ทำแบบนั้นได้เหมือนกัน) — มันยืนยันอย่างอิสระว่า widget เรียก collaborator จริง ๆ ด้วย argument ที่คาดหวังเป๊ะ เป็นจำนวนครั้งที่ถูกต้องเป๊ะ
registerFallbackValue: error จริงที่มันป้องกัน
หัวข้อที่มีชื่อว่า “registerFallbackValue: error จริงที่มันป้องกัน”การเรียก any() สำหรับ argument type ที่ไม่ใช่ primitive (AnalyticsEvent ด้านบน) โดยไม่ได้ลงทะเบียน fallback ก่อนจะ throw ทันที — จับไว้ที่นี่ จริงและไม่แก้ไข:
StateError: Bad state: A test tried to use `any` or `captureAny` on a parameter of type `AnalyticsEvent`, butregisterFallbackValue was not previously called to register a fallback value for `AnalyticsEvent`.
To fix, do:
void main() { setUpAll(() { registerFallbackValue(/* create a dummy instance of `AnalyticsEvent` */); });}
This instance of `AnalyticsEvent` will only be passed around, but never be interacted with.Therefore, if `AnalyticsEvent` is a function, it does not have to return a valid object andcould throw unconditionally.If you cannot easily create an instance of `AnalyticsEvent`, consider defining a `Fake`:
class MyTypeFake extends Fake implements MyType {}
void main() { setUpAll(() { registerFallbackValue(MyTypeFake()); });}ข้อความ error ของ mocktail เองคือ documentation ในที่นี้: มันมีอยู่เพราะ sound null safety ของ Dart หมายความว่า any() ต้องคืน instance AnalyticsEvent ที่ไม่ใช่ null จริง ๆ ข้างในบางตัว และ mocktail ไม่มีทางสร้างมันขึ้นมาเองได้ — Fake (ที่ member ของมันไม่เคยถูกเรียกจริง มีแค่ identity ของมันในฐานะ “AnalyticsEvent” ที่สำคัญ) คือคำตอบมาตรฐานเมื่อ class จริงสร้างได้ไม่ง่าย
testWidgets variant พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “testWidgets variant พิสูจน์แล้ว”variant: รัน body ของ testWidgets เดียวกัน ครั้งละหนึ่งค่าใน TestVariant (ตัวที่มีมาให้รวม TargetPlatformVariant; ValueVariant<T> คือตัวทั่วไปสำหรับชุดค่าของคุณเอง):
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';
final localeVariant = ValueVariant<String>({'en', 'th', 'ja'});
void main() { testWidgets( 'a Text widget renders under every locale variant', (tester) async { final locale = localeVariant.currentValue!; await tester.pumpWidget(MaterialApp( locale: Locale(locale), home: const Text('hello'), )); expect(find.text('hello'), findsOneWidget); }, variant: localeVariant, );}00:00 +0: a Text widget renders under every locale variant (variant: en)00:00 +1: a Text widget renders under every locale variant (variant: th)00:00 +2: a Text widget renders under every locale variant (variant: ja)
00:00 +3: All tests passed!การเรียก testWidgets เดียว สาม test run ที่รายงานแยกกันจริง — variant.currentValue ข้างใน body คือวิธีที่คุณอ่านว่า run ปัจจุบันใช้ค่าไหน นี่คือกลไกเบื้องหลัง TargetPlatformVariant.all() pattern ทั่วไปสำหรับการรัน widget test เดียวข้ามทุก TargetPlatform โดยไม่ต้องเขียน loop เอง (หรือแย่กว่านั้น test body สี่อันที่เกือบเหมือนกัน)
Golden test
หัวข้อที่มีชื่อว่า “Golden test”testWidgets('button matches golden', (tester) async { await tester.pumpWidget(const MyButton()); await expectLater( find.byType(MyButton), matchesGoldenFile('goldens/my_button.png'), );});Golden (snapshot) test ทรงพลังสำหรับ design system แต่เปราะบางข้าม platform และเวอร์ชัน font — pin มันอย่างระมัดระวังและ regenerate อย่างตั้งใจด้วย --update-goldens รันจริง ใน scratch environment ของคอร์สนี้เอง (ไม่เคยใน shared probe ของคอร์สนี้ ตาม shared brief — matchesGoldenFile ต้องการ reference .png ที่ checked-in ซึ่ง fence collector ของ harness นี้ไม่มีกลไกจะพามันไปได้ เพราะมันเก็บแค่ fence dart/yaml):
$ flutter test --update-goldens test/golden_experiment_test.dart00:00 +0: a simple button matches its golden file00:00 +1: All tests passed!
$ flutter test test/golden_experiment_test.dart # second run, comparing against the file just written00:00 +0: a simple button matches its golden file00:00 +1: All tests passed!การรันครั้งแรก (--update-goldens) เขียน test/goldens/simple_button.png; การรันครั้งที่สองที่ไม่แก้ไขอะไรเทียบ pixel ที่ render ใหม่กับไฟล์นั้นเป๊ะ ๆ และผ่านเพราะไม่มีอะไรเปลี่ยน harness ของคอร์สนี้รัน pattern นี้ไม่ได้จริง ๆ (ไม่มี binary asset support ใน fence collector ของมัน) — ยืนยันอย่างตรงไปตรงมาใน scratch แทนที่จะอ้างโดยไม่ได้รัน
integration_test: test ทั้งแอปบนอุปกรณ์จริง (docs — ไม่มีอุปกรณ์ที่นี่)
หัวข้อที่มีชื่อว่า “integration_test: test ทั้งแอปบนอุปกรณ์จริง (docs — ไม่มีอุปกรณ์ที่นี่)”package integration_test (มากับ Flutter SDK ไม่ใช่ dependency แยกจาก pub.dev ที่ต้องเพิ่มเอง) รันแอปที่ compile แล้วจริงของคุณบนอุปกรณ์จริงหรือ emulator ขับมันแบบเดียวกับที่ widget test ขับ tree ในหน่วยความจำ — IntegrationTestWidgetsFlutterBinding.ensureInitialized() แทน test binding ปกติ แล้วใช้ API tester.tap/pump/expect เดียวกันกับแอปที่กำลังรันจริง คู่มือ integration testing ของ docs.flutter.dev คือแหล่งที่มาของ section นี้ — environment ของคอร์สนี้ไม่มีอุปกรณ์หรือ emulator ให้รันจริง ดังนั้นนี่คือการอ้างอิง ไม่ใช่การสาธิต trade-off เทียบกับ widget test คือพีระมิดตอนต้นบทนี้เป๊ะ ๆ: ความสมจริงเต็มรูปแบบ (platform channel จริง, การ render จริง, timing จริง) แลกกับความเร็วและต้นทุน setup ระดับอุปกรณ์จริง — หยิบมันมาใช้สำหรับ flow end-to-end จริง ๆ ไม่กี่อัน (onboarding, checkout) ที่คุ้มกับต้นทุน ไม่ใช่แทนที่ widget test
Test coverage: flutter test --coverage กับ lcov output จริง
หัวข้อที่มีชื่อว่า “Test coverage: flutter test --coverage กับ lcov output จริง”int add(int a, int b) => a + b;
int classify(int value) { if (value < 0) return -1; if (value == 0) return 0; return 1;}
int neverCalled(int value) => value * 100; // deliberately left uncoveredimport 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/tooling-and-production__testing/coverage_demo.dart';
void main() { test('add sums two ints', () { expect(add(2, 3), 5); });
test('classify handles negative, zero, and positive', () { expect(classify(-5), -1); expect(classify(0), 0); expect(classify(5), 1); });}00:00 +0: add sums two ints00:00 +1: classify handles negative, zero, and positive
00:00 +2: All tests passed!การรัน flutter test --coverage กับสองไฟล์นี้เป๊ะ (harness ของคอร์สนี้ไม่รันด้วย --coverage ดังนั้นสิ่งนี้ถูกจับใน scratch) สร้าง coverage/lcov.info จริงและไม่แก้ไขสำหรับ coverage_demo.dart:
SF:lib/coverage_demo.dartDA:1,2DA:3,1DA:4,2DA:5,1DA:9,0LF:5LH:4end_of_recordDA:<line>,<hitCount> แต่ละอันคือหนึ่งบรรทัดที่ execute ได้และจำนวนครั้งที่ test เรียกมัน; DA:9,0 คือบรรทัดของ neverCalled — ศูนย์ครั้งจริง ๆ เพราะไม่มี test เรียกมันเลย LF:5/LH:5 สรุปไฟล์ (5 บรรทัดที่ execute ได้พบ 4 บรรทัดถูก hit) — coverage ระดับบรรทัดจริงที่คำนวณได้ 80% สำหรับไฟล์เดียวนี้ ไม่ใช่การประมาณ genhtml (ส่วนหนึ่งของ toolchain lcov — ไม่ได้ติดตั้งบน Mac เครื่องนี้ และคอร์สนี้ brew install มันไม่ได้ตาม shared brief) แปลง lcov.info เป็น HTML report ที่ดูใน browser ได้; docs ของมัน คือการอ้างอิงสำหรับขั้นตอนนั้น ไม่ได้ยืนยันซ้ำที่นี่
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”โค้ด widget ที่ AI generate มักสร้าง dependency ของตัวเองตรงข้างใน ซึ่งทำให้ test แบบที่บทนี้สาธิตเป็นไปไม่ได้เลยโดยไม่ต้องยิง network จริง
// AI-generated: "show the user's order history"class OrderHistoryScreen extends StatefulWidget { const OrderHistoryScreen({super.key}); @override State<OrderHistoryScreen> createState() => _OrderHistoryScreenState();}
class _OrderHistoryScreenState extends State<OrderHistoryScreen> { final _api = OrdersApi(); // constructed inline -- cannot be mocked late final Future<List<Order>> _orders = _api.fetchOrders();
@override Widget build(BuildContext context) { return FutureBuilder<List<Order>>( future: _orders, builder: (context, snapshot) => snapshot.hasData ? ListView(children: snapshot.data!.map((o) => Text(o.id)).toList()) : const CircularProgressIndicator(), ); }}ก่อนจะเชื่อโค้ด widget ที่ generate มาแบบนี้ ให้เช็ค:
- collaborator (
OrdersApi()ด้านบน) ที่ถูกสร้างตรงข้างในState/StatelessWidgetแทนที่จะรับเป็น constructor parameter — พิสูจน์แล้วด้านบนว่าProfileScreenที่รับUserRepository repositoryคือสิ่งที่ทำให้MockUserRepositoryแทนที่ได้ใน test เลย await tester.pump()ครั้งเดียวหลัง interaction ที่เริ่ม animation หลายเฟรมจริง ๆ แล้ว assertion ที่สมมติว่ามันจบแล้ว — พิสูจน์แล้วด้านบนว่าpump()ตัวเดียวเดินหน้าแค่เฟรมเดียว วิธีแก้คือpumpAndSettle()เมื่อคุณต้องการ end state จริง ๆany()ที่ใช้กับ argument type แบบ custom โดยไม่มีregisterFallbackValue/Fakeคู่กัน — พิสูจน์แล้วด้านบนว่ามัน throw ทันที ไม่ผ่านแบบเงียบ ๆ- widget test ที่มี assertion เดียวคือมีข้อความบางอย่างปรากฏ โดยไม่มี
verify()บน mock — พิสูจน์แล้วด้านบนว่าverify()คือสิ่งที่ยืนยันจริง ๆ ว่า collaborator ถูกเรียกถูกต้อง ไม่ใช่แค่ UI บังเอิญ render อะไรที่ดูสมเหตุสมผล
🐛 หา bug ในโค้ดนี้
class CheckoutButton extends StatelessWidget {const CheckoutButton({super.key, required this.paymentService});final PaymentService paymentService;
@overrideWidget build(BuildContext context) { return ElevatedButton( onPressed: () => paymentService.charge(amount: 42.0), child: const Text('Pay'), );}}
// In the test file:class MockPaymentService extends Mock implements PaymentService {}
void main() {testWidgets('tapping Pay charges the payment service', (tester) async { final mockService = MockPaymentService(); await tester.pumpWidget(MaterialApp( home: CheckoutButton(paymentService: mockService), ));
await tester.tap(find.text('Pay')); await tester.pump();
expect(find.text('Pay'), findsOneWidget);});}เฉลย
test ไม่เคยเรียก verify() บน mockService เลย — มันแค่ assert ว่า label ของปุ่มยังเป็น “Pay” หลังการ tap ซึ่งจะผ่านแม้ onPressed จะขาดการเชื่อมโยงกับ paymentService.charge(...) โดยสิ้นเชิง (regression จริงที่พลาดง่าย: มีคนรีแฟคเตอร์ onPressed แล้วบังเอิญทำการเรียก charge หายไป และ test นี้ยังผ่านต่อ) พิสูจน์แล้วด้านบนด้วย ProfileScreen/mockRepo วิธีแก้คือการเรียก verify() จริง: verify(() => mockService.charge(amount: 42.0)).called(1); — นั่นคือบรรทัดเดียวที่ยืนยันจริง ๆ ว่าปุ่มทำสิ่งที่มันอ้างว่าทำ ไม่ใช่แค่ยังเขียนว่า “Pay”
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกเทคนิคในบทนี้มีอยู่เพื่อตอบคำถามเดียวที่การเช็ค “UI ดูถูกไหม” ธรรมดาตอบไม่ได้: collaborator ที่ถูกต้องถูกเรียกไหม ด้วย argument ที่ถูกต้อง จำนวนครั้งที่ถูกต้อง — ไม่ขึ้นกับว่าอะไร render ออกมา? verify() ของ mocktail พิสูจน์สิ่งนั้นตรง ๆ pumpAndSettle() เทียบกับ pump() เดียวคือคำถามเดียวกันที่ใช้กับเวลา: “state ที่ฉันกำลัง assert คือ state สุดท้าย จริงไหม หรือแค่เฟรมไหนก็ตามที่บังเอิญเป็นปัจจุบัน?” และ error ของ registerFallbackValue เมื่ออ่านอย่างละเอียด คือ type system ที่ถามคำถามเดียวกันอีกชั้นหนึ่ง: “คุณบอกฉันว่า any() match — match อะไร กันแน่ เพราะฉันต้องมี object จริงส่งกลับไปด้วย?”
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| Unit test | เร็วที่สุด ง่ายที่สุด ควรมีเยอะที่สุด | ไม่พิสูจน์อะไรเลยเกี่ยวกับที่ widget จริง ๆ render/ทำงาน |
Widget test + mocktail | พิสูจน์แล้วด้านบน: พฤติกรรมจริง ยืนยันการเรียก collaborator จริง ยังเร็วอยู่ (ไม่มีอุปกรณ์) | ต้องออกแบบ widget ให้รับ dependency แบบ inject ไม่ใช่สร้างตรงข้างใน |
pumpAndSettle() | พิสูจน์แล้วด้านบน: รอ final state จริง | timeout กับ animation ที่ไม่รู้จบใด ๆ — FadeInText ที่พิสูจน์แล้วด้านบนจะทำให้มันค้างถ้าใช้ ..repeat() |
| Golden test | จับ pixel regression จริง | พิสูจน์แล้วด้านบน: รันผ่าน harness ของคอร์สนี้เองไม่ได้; เปราะบางข้าม platform/font โดยทั่วไป |
| Integration test | สมจริงที่สุด — อุปกรณ์จริง แอปจริง | ช้า ต้องการ hardware/emulator จริง — environment ของคอร์สนี้ไม่มี อ้างอิงจาก docs เท่านั้น |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ถ้าข้อความบนหน้าจอถูกต้อง แสดงว่า widget ทำสิ่งที่ถูกต้อง” พิสูจน์แล้วด้านบนว่าไม่จริง จากตัวอย่าง
SpotTheBug: test ที่ assert แค่ข้อความที่ render ผ่านแม้ action ของปุ่มจะขาดการเชื่อมโยงกับ service ที่มันอ้างว่าเรียกโดยสิ้นเชิง — มีแค่verify()เท่านั้นที่ปิดช่องว่างนั้น - “
tester.pump()ครั้งเดียวพอแล้วหลัง interaction ใดก็ตาม” พิสูจน์แล้วด้านบนว่าไม่จริง: สำหรับ widget ที่กำลัง animatepump()ครั้งเดียวเดินหน้าแค่เฟรมเดียวจริง ๆ — พิสูจน์ด้วยค่า opacity จริงที่ยังต่ำกว่า1.0ที่จุดครึ่งทาง - “
any()ใน mocktail ใช้ได้กับทุก type โดยไม่ต้อง setup” พิสูจน์แล้วด้านบนว่าไม่จริง ด้วยStateErrorจริงที่ไม่แก้ไข — custom type (ที่ไม่ใช่ primitive) ต้องการregisterFallbackValueก่อน ไม่งั้นany()throw ทันที - “harness ของคอร์สนี้รัน golden test ได้เหมือนอย่างอื่น” พิสูจน์แล้วด้านบนว่าไม่จริง: มันทำไม่ได้จริง ๆ (ไม่มี binary asset support ใน fence collector) — ยืนยันอย่างตรงไปตรงมาใน scratch แทนที่จะอ้างลอย ๆ