Kodokon kodokon.com

การทดสอบ: ยูนิตเทสต์ของ Dart และ widget test

ทดสอบตรรกะและวิดเจ็ตด้วย fake ที่เขียนด้วยมือ นาฬิกาจำลอง และปฏิกิริยาการใช้ pump ที่ถูกต้อง

11 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

พีระมิดการทดสอบของ Flutter: ฐานกว้าง ๆ ของ ยูนิตเทสต์ ที่เป็น Dart ล้วน, ชั้นหนึ่งของ widget test และการทดสอบแบบ integration อีกเล็กน้อย ยูนิตเทสต์ไม่ควร import material.dart เด็ดขาด หากตรรกะของคุณต้องใช้มัน ปัญหาอยู่ที่สถาปัตยกรรม ไม่ใช่เรื่องเทคนิค ในการแยกโค้ดออกจากสิ่งที่มันพึ่งพา จงเลือกใช้ fake ที่เขียนด้วยมือ แทน mock ที่ถูกสร้างอัตโนมัติ: fake คือตัวการทำงานของสัญญาที่ถูกทำให้เรียบง่ายแต่ทำงานได้จริง (repository ในหน่วยความจำ, นาฬิกาที่ควบคุมได้) มันอยู่รอดผ่านการ refactor และอ่านได้เหมือนโค้ดปกติ ส่วน mock ซึ่งตรวจสอบการปฏิสัมพันธ์ ("เมท็อดนี้ถูกเรียกสองครั้ง") จะผูกเทสต์เข้ากับรายละเอียดของการทำงาน จงสงวนมันไว้สำหรับโปรโตคอลที่การปฏิสัมพันธ์ คือ พฤติกรรมนั้นเอง

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();
  }
}
โค้ดที่กำลังถูกทดสอบ: ไม่มีการ import material.dart

setUp สร้างอ็อบเจกต์ขึ้นใหม่ก่อนการทดสอบ แต่ละ ครั้ง: ไม่มีสเตตร่วมกัน จึงไม่มีเทสต์ที่ขึ้นอยู่กับลำดับการทำงาน นั่นคือเงื่อนไขของความน่าเชื่อถือของมัน matcher ที่ประกอบกันได้ (isA<Exception>(), throwsA, isNull) ให้ข้อความแจ้งความล้มเหลวที่แม่นยำ ซึ่งมีประโยชน์กว่าค่าบูลีนมาก สังเกตเทสต์ตัวที่สองด้านล่าง: มันตรวจสอบความล้มเหลว แล้ว ตามด้วยการฟื้นตัวทันที เส้นทางของข้อผิดพลาดก็เป็นโค้ดเหมือนกับส่วนอื่น ๆ หากมันไม่ถูกทดสอบ ก็จงถือว่ามันพังแล้ว

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);
  });
}
fake ยาวสิบห้าบรรทัดแทนที่ไลบรารี mock ทั้งก้อนได้

widget test ไม่เรนเดอร์อะไรออกหน้าจอเลย: มันทำงานใน binding เฉพาะทาง (AutomatedTestWidgetsFlutterBinding) ที่ซึ่ง เวลาถูกจำลอง และแต่ละเฟรมถูกผลิตขึ้นเมื่อร้องขอ pumpWidget ติดตั้ง tree ขึ้น ส่วน pump() เดินนาฬิกาจำลองไปข้างหน้าและผลิตเฟรม หนึ่ง เฟรม และ pump(const Duration(milliseconds: 150)) จะกระโดดข้ามเวลา เหมาะอย่างยิ่งสำหรับหยุดสเตตกลางคันของแอนิเมชันไว้เพื่อตรวจสอบ Finder (find.text, find.byType, find.byKey) จะสืบค้น tree ของ Element จริง ไม่ใช่ภาพหน้าจอ เพราะไม่มีอะไรเป็นอะซิงโครนัสอย่างแท้จริง ทั้งนาฬิกาและเครือข่าย เทสต์เหล่านี้จึง มีผลลัพธ์แน่นอน (deterministic): ความล้มเหลวสามารถทำซ้ำได้เสมอ

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
เจาะจงไฟล์หนึ่ง วัด coverage สร้าง golden ขึ้นใหม่

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. ทำไม pumpAndSettle จึงล้มเหลวเมื่อเจอกับแอนิเมชันที่เริ่มด้วย repeat()?
    • มันสูบเฟรมตราบเท่าที่ยังมีเฟรมถูกกำหนดไว้: แอนิเมชันไม่รู้จบไม่มีวันนิ่ง จึงเกิดการหมดเวลา
    • test binding ไม่รองรับ repeat()
    • pumpAndSettle จัดการได้เฉพาะแอนิเมชันแบบ implicit เท่านั้น
  2. อะไรคือความแตกต่างระหว่าง fake กับ mock?
    • fake คือตัวการทำงานที่ถูกทำให้เรียบง่ายแต่ทำงานได้ ส่วน mock มีไว้เพื่อตรวจสอบการปฏิสัมพันธ์เป็นหลัก
    • mock เขียนด้วยมือ ส่วน fake ถูกสร้างโดยเครื่องมือ
    • ไม่มีเลย: ทั้งสองคำเป็นคำพ้องความหมายกัน
    • fake ไม่สามารถ implement อินเทอร์เฟซของ Dart ได้
  3. ใน widget test นั้น await tester.pump() ทำอะไรกันแน่?
    • มันเดินนาฬิกาจำลองไปข้างหน้าและผลิตเฟรมใหม่เพียงเฟรมเดียว
    • มันรอให้แอนิเมชันที่กำลังทำงานอยู่ทั้งหมดเสร็จสิ้น
    • มันเริ่มเทสต์ใหม่ตั้งแต่ต้น
    • มันรอให้คำขอเครือข่ายจริงเสร็จสิ้น