Widget Test #

Widget test menguji bagaimana widget ditampilkan dan bagaimana ia merespons interaksi pengguna — tanpa membutuhkan device fisik. Ia berjalan di lingkungan Flutter yang di-emulasi, jauh lebih cepat dari integration test tapi tetap bisa mensimulasikan tap, input teks, scroll, dan animasi. Widget test adalah lapisan tengah yang ideal untuk memverifikasi bahwa UI benar-benar merender state yang tepat.

Setup #

# pubspec.yaml -- flutter_test sudah ada secara default
dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.4
  # patrol_finders: ^2.0.0  # opsional, finder yang lebih ringkas

Anatomi Widget Test #

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

void main() {
  testWidgets('ProdukCard menampilkan nama dan harga', (tester) async {
    // Arrange -- render widget
    await tester.pumpWidget(
      MaterialApp(  // selalu bungkus dengan MaterialApp atau CupertinoApp
        home: ProdukCard(
          produk: Produk(id: '1', nama: 'Flutter Book', harga: 150000),
        ),
      ),
    );

    // Act + Assert -- temukan dan verifikasi elemen
    expect(find.text('Flutter Book'), findsOneWidget);
    expect(find.text('Rp 150.000'), findsOneWidget);
    expect(find.byIcon(Icons.shopping_cart), findsOneWidget);
  });
}

WidgetTester — Berinteraksi dengan Widget #

WidgetTester adalah objek utama dalam widget test. Ia menyediakan semua alat untuk merender, mencari, dan berinteraksi dengan widget:

testWidgets('interaksi dasar', (tester) async {
  await tester.pumpWidget(MaterialApp(home: LoginScreen()));

  // RENDER DAN TIMING
  // pumpWidget -- render widget pertama kali
  await tester.pumpWidget(MaterialApp(home: MyWidget()));
  // pump -- trigger satu frame (setelah setState, setelah tap)
  await tester.pump();
  // pump dengan durasi -- skip animasi
  await tester.pump(const Duration(milliseconds: 500));
  // pumpAndSettle -- pump berulang sampai tidak ada frame tersisa (animasi selesai)
  await tester.pumpAndSettle();

  // INTERAKSI
  // tap
  await tester.tap(find.text('Login'));
  await tester.pump();

  // tap berdasarkan key
  await tester.tap(find.byKey(const Key('submit-button')));

  // input teks
  await tester.enterText(find.byType(TextField), '[email protected]');
  await tester.pump();

  // long press
  await tester.longPress(find.byType(ListTile));

  // drag -- misalnya untuk dismiss atau pull-to-refresh
  await tester.drag(find.byType(RefreshIndicator), const Offset(0, 300));
  await tester.pumpAndSettle();

  // scroll
  await tester.scrollUntilVisible(
    find.text('Item ke-50'),
    500,  // scroll delta
    scrollable: find.byType(Scrollable),
  );

  // fling -- scroll dengan momentum
  await tester.fling(find.byType(ListView), const Offset(0, -500), 800);
  await tester.pumpAndSettle();
});

Finder — Temukan Widget di Tree #

// Berdasarkan teks
find.text('Submit')                     // teks exact
find.textContaining('Halo')            // teks mengandung substring

// Berdasarkan tipe widget
find.byType(ElevatedButton)
find.byType(TextField)
find.byType(CircularProgressIndicator)

// Berdasarkan Key
find.byKey(const Key('login-button'))
find.byKey(const ValueKey('produk-123'))

// Berdasarkan Icon
find.byIcon(Icons.add)
find.byIcon(Icons.delete)

// Berdasarkan widget instance
final widget = MyCustomWidget();
find.byWidget(widget)

// Berdasarkan predicate (kondisi kustom)
find.byWidgetPredicate(
  (widget) => widget is Text && widget.data?.startsWith('Rp') == true,
)

// Ancestor dan Descendant -- navigasi dalam tree
find.descendant(
  of: find.byType(Card),
  matching: find.byType(ElevatedButton),
)
find.ancestor(
  of: find.text('Flutter Book'),
  matching: find.byType(ListTile),
)

// Refinement -- jika finder menemukan banyak
find.byType(Text).first
find.byType(Text).last
find.byType(Text).at(2)   // indeks ke-2

Matcher untuk Widget #

// Jumlah widget yang ditemukan
expect(find.text('Submit'), findsOneWidget);      // tepat 1
expect(find.text('Item'), findsWidgets);           // 1 atau lebih
expect(find.text('Hidden'), findsNothing);         // 0
expect(find.byType(ListTile), findsNWidgets(5));   // tepat 5
expect(find.byType(Text), findsAtLeastNWidgets(3)); // minimal 3

// Properti widget
final text = tester.widget<Text>(find.text('Halo'));
expect(text.style?.color, equals(Colors.red));
expect(text.style?.fontSize, equals(16));

final button = tester.widget<ElevatedButton>(find.byType(ElevatedButton));
expect(button.onPressed, isNull);         // button disabled
expect(button.onPressed, isNotNull);      // button enabled

// Semantics (aksesibilitas)
expect(
  tester.getSemantics(find.byType(ElevatedButton)),
  matchesSemantics(label: 'Submit', isButton: true),
);

Test dengan State — Loading, Error, Data #

void main() {
  group('ProdukListScreen', () {
    late MockProdukNotifier mockNotifier;

    setUp(() {
      mockNotifier = MockProdukNotifier();
    });

    testWidgets('menampilkan loading indicator saat state loading', (tester) async {
      // Override provider dengan mock yang mengembalikan loading state
      when(() => mockNotifier.build()).thenAnswer(
        (_) async => Future.delayed(const Duration(seconds: 10), () => <Produk>[]),
      );

      await tester.pumpWidget(
        ProviderScope(
          overrides: [
            produkProvider.overrideWith(() => mockNotifier),
          ],
          child: const MaterialApp(home: ProdukListScreen()),
        ),
      );

      // Saat pertama render, state masih loading
      expect(find.byType(CircularProgressIndicator), findsOneWidget);
      expect(find.byType(ProdukCard), findsNothing);
    });

    testWidgets('menampilkan daftar produk saat state data', (tester) async {
      final produkList = [
        Produk(id: '1', nama: 'Flutter Book', harga: 150000),
        Produk(id: '2', nama: 'Dart Guide', harga: 120000),
      ];

      await tester.pumpWidget(
        ProviderScope(
          overrides: [
            // Override langsung dengan nilai yang sudah selesai
            produkProvider.overrideWith(
              (ref) async => produkList,
            ),
          ],
          child: const MaterialApp(home: ProdukListScreen()),
        ),
      );

      await tester.pumpAndSettle();  // tunggu async selesai

      expect(find.byType(CircularProgressIndicator), findsNothing);
      expect(find.text('Flutter Book'), findsOneWidget);
      expect(find.text('Dart Guide'), findsOneWidget);
      expect(find.byType(ProdukCard), findsNWidgets(2));
    });

    testWidgets('menampilkan error message saat state error', (tester) async {
      await tester.pumpWidget(
        ProviderScope(
          overrides: [
            produkProvider.overrideWith(
              (ref) async => throw const NetworkException('Tidak ada internet'),
            ),
          ],
          child: const MaterialApp(home: ProdukListScreen()),
        ),
      );

      await tester.pumpAndSettle();

      expect(find.text('Tidak ada internet'), findsOneWidget);
      expect(find.text('Coba Lagi'), findsOneWidget);  // tombol retry
    });
  });
}

Helper pumpApp — Hindari Duplikasi #

Setiap widget test membutuhkan wrapper MaterialApp, ProviderScope, dan mungkin ThemeData, router, dll. Buat helper untuk menghindari duplikasi:

// test/helpers/pump_app.dart
extension PumpApp on WidgetTester {
  Future<void> pumpApp(
    Widget widget, {
    List<Override> overrides = const [],
  }) {
    return pumpWidget(
      ProviderScope(
        overrides: overrides,
        child: MaterialApp(
          theme: AppTheme.light,
          darkTheme: AppTheme.dark,
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: widget,
        ),
      ),
    );
  }
}

// Penggunaan di test -- jauh lebih ringkas
testWidgets('test dengan helper', (tester) async {
  await tester.pumpApp(
    const ProdukListScreen(),
    overrides: [
      produkProvider.overrideWith((ref) async => []),
    ],
  );
  await tester.pumpAndSettle();
  expect(find.text('Belum ada produk'), findsOneWidget);
});

Simulasi Alur Interaksi #

testWidgets('alur login berhasil navigasi ke home', (tester) async {
  final mockAuthRepo = MockAuthRepository();
  when(() => mockAuthRepo.login(email: any(named: 'email'), password: any(named: 'password')))
      .thenAnswer((_) async => AuthResult(user: User(id: '1', nama: 'Budi')));

  await tester.pumpApp(
    const LoginScreen(),
    overrides: [authRepositoryProvider.overrideWithValue(mockAuthRepo)],
  );

  // Input email
  await tester.enterText(
    find.byKey(const Key('email-field')),
    '[email protected]',
  );

  // Input password
  await tester.enterText(
    find.byKey(const Key('password-field')),
    'password123',
  );

  // Tap tombol login
  await tester.tap(find.byKey(const Key('login-button')));
  await tester.pump();

  // Loading indicator muncul
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  // Tunggu proses login selesai
  await tester.pumpAndSettle();

  // Navigasi ke HomeScreen
  expect(find.byType(HomeScreen), findsOneWidget);
  expect(find.byType(LoginScreen), findsNothing);
});

Golden Test — Snapshot Visual #

Golden test menyimpan screenshot widget sebagai file referensi dan membandingkannya di run berikutnya — berguna untuk mendeteksi perubahan visual yang tidak disengaja:

testWidgets('ProdukCard golden test', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      theme: AppTheme.light,
      home: Scaffold(
        body: ProdukCard(
          produk: Produk(id: '1', nama: 'Flutter Book', harga: 150000),
        ),
      ),
    ),
  );

  await tester.pumpAndSettle();

  // Pertama kali: jalankan flutter test --update-goldens untuk membuat file .png
  // Selanjutnya: membandingkan render dengan file yang sudah ada
  await expectLater(
    find.byType(ProdukCard),
    matchesGoldenFile('goldens/produk_card.png'),
  );
});

// Struktur folder golden files
// test/
//   goldens/
//     produk_card.png          ← generated, commit ke git
//     login_screen_empty.png
//     login_screen_error.png
# Buat/update golden files
flutter test --update-goldens

# Jalankan golden test (compare dengan existing)
flutter test

# Skip golden test di CI jika tidak perlu
flutter test --exclude-tags golden
Catatan: Golden test sangat sensitif terhadap perbedaan rendering antar platform (macOS vs Linux vs Windows). Jika dijalankan di CI, pastikan menggunakan platform yang konsisten, atau gunakan package alchemist yang mengonversi teks menjadi kotak hitam untuk konsistensi lintas platform.

Ringkasan #

  • Widget test berjalan di lingkungan Flutter yang di-emulasi — tidak butuh device fisik, jauh lebih cepat dari integration test.
  • Selalu bungkus widget yang ditest dengan MaterialApp (atau CupertinoApp) dan ProviderScope jika menggunakan Riverpod.
  • pumpWidget() untuk render awal, pump() untuk satu frame setelah interaksi, pumpAndSettle() untuk menunggu sampai semua animasi dan async selesai.
  • Finder untuk menemukan widget: find.text(), find.byType(), find.byKey(), find.byIcon(). Gunakan find.descendant() untuk navigasi dalam tree.
  • Matcher: findsOneWidget, findsNothing, findsNWidgets(n), findsAtLeastNWidgets(n) untuk memverifikasi jumlah widget yang ditemukan.
  • Test semua state: loading, error, dan data — jangan hanya test happy path.
  • Buat pumpApp() extension sebagai helper untuk menghindari duplikasi setup MaterialApp dan ProviderScope di setiap test.
  • Golden test menyimpan snapshot visual sebagai file .png — berguna untuk mendeteksi perubahan UI yang tidak disengaja. Gunakan --update-goldens untuk membuat/memperbarui file referensi.

← Sebelumnya: Unit Test   Berikutnya: Integration Test →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact