Halo Developer! 👋

Di artikel #35 kemarin kita sudah bahas Unit Testing — testing fungsi dan logika bisnis tanpa UI. Sekarang kita naik level: Widget Testing. Di sini kita bakal ngetes tampilan widget — apakah teksnya muncul, tombolnya bisa diklik, input-nya bisa diketik.

Bayangkan unit testing itu ngetes mesin mobil, sedangkan widget testing itu test drive — kamu nggak perlu jalan di jalan raya (device nyata), tapi kamu sudah bisa rasakan mobilnya dari dalam garasi. 🚗


Apa Itu Widget Testing?

Widget test (juga disebut headless test) menjalankan widget dalam environment simulasi tanpa emulator atau device fisik. Flutter punya widget tester bawaan dari package flutter_test — sudah termasuk di project Flutter baru, nggak perlu install tambahan.

Yang diuji dalam widget testing:

  • Rendering — widget muncul dengan benar?
  • Interaksi — tap, swipe, ketik berfungsi?
  • State — perubahan state mengubah UI sesuai harapan?
  • Routing — navigasi ke page yang tepat?

Karena tanpa emulator, widget test jauh lebih cepat dari integration test tapi lebih lengkap dari unit test.


Setup: Package flutter_test

Kalau kamu buat project Flutter baru, package ini sudah ada:

# pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter

Kalau belum ada, tambahkan lalu jalankan:

flutter pub get

Semua file test harus berada di folder test/ dan diakhiri _test.dart.


Widget Test Pertama

Misalnya kita punya widget sederhana — tombol counter:

// lib/counter_widget.dart
import 'package:flutter/material.dart';

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          'Count: $_count',
          key: const Key('counter-text'),
          style: const TextStyle(fontSize: 24),
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          key: const Key('increment-button'),
          onPressed: () {
            setState(() {
              _count++;
            });
          },
          child: const Text('Tambah'),
        ),
        const SizedBox(height: 8),
        OutlinedButton(
          key: const Key('reset-button'),
          onPressed: () {
            setState(() {
              _count = 0;
            });
          },
          child: const Text('Reset'),
        ),
      ],
    );
  }
}

Perhatikan kita kasih Key pada setiap widget penting — ini sangat membantu saat testing karena bisa langsung target widget tertentu.

Sekarang tulis widget test-nya:

// test/counter_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/counter_widget.dart';

void main() {
  testWidgets('counter dimulai dari 0', (WidgetTester tester) async {
    await tester.pumpWidget(
      const MaterialApp(
        home: Scaffold(
          body: CounterWidget(),
        ),
      ),
    );

    // Cek teks awal
    expect(find.text('Count: 0'), findsOneWidget);
    expect(find.text('Count: 1'), findsNothing);
  });

  testWidgets('tekan tombolTambah counter naik', (WidgetTester tester) async {
    await tester.pumpWidget(
      const MaterialApp(
        home: Scaffold(
          body: CounterWidget(),
        ),
      ),
    );

    // Tap tombolTambah
    await tester.tap(find.byKey(const Key('increment-button')));
    await tester.pump(); // rebuild widget

    // Cek teks berubah
    expect(find.text('Count: 0'), findsNothing);
    expect(find.text('Count: 1'), findsOneWidget);
  });

  testWidgets('tekan Reset kembalikan ke 0', (WidgetTester tester) async {
    await tester.pumpWidget(
      const MaterialApp(
        home: Scaffold(
          body: CounterWidget(),
        ),
      ),
    );

    // Tap tambah 3x
    await tester.tap(find.byKey(const Key('increment-button')));
    await tester.pump();
    await tester.tap(find.byKey(const Key('increment-button')));
    await tester.pump();
    await tester.tap(find.byKey(const Key('increment-button')));
    await tester.pump();

    expect(find.text('Count: 3'), findsOneWidget);

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

    expect(find.text('Count: 0'), findsOneWidget);
    expect(find.text('Count: 3'), findsNothing);
  });
}

Jalankan test:

flutter test test/counter_widget_test.dart

Output:

00:03 +3: counter dimulai dari 0
00:03 +4: tekan tombolTambah counter naik
00:03 +5: tekan Reset kembalikan ke 0

00:03 +5: All tests passed! 🎉

Memahami Alur Widget Test

Setiap widget test mengikuti pola 3 langkah:

1. pumpWidget() — Render widget ke dalam test environment. Kamu harus bungkus widget dengan MaterialApp (atau CupertinoApp) karena banyak widget butuh context dari Material/Cupertino.

2. Interaksi — Simulasikan aksi user: tap(), enterText(), drag(), longPress(), dsb.

3. pump() atau pumpAndSettle() — Setelah interaksi, widget perlu rebuild. pump() rebuild sekali. pumpAndSettle() menunggu sampai semua animasi selesai.

// pump() — rebuild sekali
await tester.tap(find.byKey(const Key('increment-button')));
await tester.pump();

// pumpAndSettle() — tunggu semua selesai
await tester.tap(find.byKey(const Key('increment-button')));
await tester.pumpAndSettle();

Finder: Mencari Widget di Tree

find adalah cara kamu lokasi widget tertentu dalam test. Beberapa cara paling umum:

// Berdasarkan Key
find.byKey(const Key('increment-button'))

// Berdasarkan teks
find.text('Tambah')

// Berdasarkan tipe widget
find.byType(ElevatedButton)

// Berdasarkan icon
find.byIcon(Icons.add)

// Single widget
find.byWidgetPredicate(
  (widget) => widget is Text && widget.data == 'Hello',
)

Matcher untuk hasil pencarian:

findsOneWidget    // tepat 1 widget ditemukan
findsNothing      // tidak ada widget ditemukan
findsWidgets      // minimal 1 widget
findsNWidgets(3)  // tepat 3 widget

Test Interaksi Form

Widget test juga bisa simulasi input text dan validasi:

// test/login_form_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/login_form.dart';

void main() {
  testWidgets('form validasi — kosongkan field tampilkan error', (WidgetTester tester) async {
    await tester.pumpWidget(
      const MaterialApp(
        home: Scaffold(
          body: LoginForm(),
        ),
      ),
    );

    // Coba submit tanpa isi apapun
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump();

    // Harus muncul error message
    expect(find.text('Email wajib diisi'), findsOneWidget);
    expect(find.text('Password wajib diisi'), findsOneWidget);
  });

  testWidgets('isi form lalu submit — tampilkan sukses', (WidgetTester tester) async {
    await tester.pumpWidget(
      const MaterialApp(
        home: Scaffold(
          body: LoginForm(),
        ),
      ),
    );

    // Ketik email
    await tester.enterText(
      find.byKey(const Key('email-field')),
      'budi@mail.com',
    );

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

    // Submit
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump();

    // Cek berhasil — misal muncul teks selamat datang
    expect(find.text('Selamat datang, Budi!'), findsOneWidget);
    expect(find.text('Email wajib diisi'), findsNothing);
  });
}

Perhatikan enterText() — langsung set text ke field tanpa perlu simulasi ketik huruf per huruf. Ini testing, bukan usability test. 😄


Test Async Widget (FutureBuilder)

Kalau widget pakai FutureBuilder atau AsyncSnapshot, kamu perlu handle async di test:

testWidgets('tampilkan data setelah future selesai', (WidgetTester tester) async {
  await tester.pumpWidget(
    const MaterialApp(
      home: Scaffold(
        body: UserProfile(userId: 1),
      ),
    ),
  );

  // Pertama kali — masih loading
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  // Tunggu future selesai
  await tester.pumpAndSettle();

  // Data muncul
  expect(find.byType(CircularProgressIndicator), findsNothing);
  expect(find.text('Budi Santoso'), findsOneWidget);
});

pumpAndSettle() otomatis menunggu sampai semua futures resolve dan UI stabil.


Test dengan Mock (Menggabungkan Unit + Widget Test)

Seringkali widget bergantung ke service/repository. Kita bisa inject mock untuk memisahkan dependency:

// test/product_list_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:myapp/models/product.dart';
import 'package:myapp/repositories/product_repository.dart';
import 'package:myapp/screens/product_list_screen.dart';

class MockProductRepository extends Mock implements ProductRepository {}

void main() {
  late MockProductRepository mockRepo;

  setUp(() {
    mockRepo = MockProductRepository();
  });

  testWidgets('tampilkan daftar produk dari repository', (WidgetTester tester) async {
    // Atur mock kembalikan 2 produk
    when(() => mockRepo.getProducts()).thenAnswer(
      (_) async => [
        Product(id: 1, name: 'Laptop', price: 10000000),
        Product(id: 2, name: 'Mouse', price: 150000),
      ],
    );

    await tester.pumpWidget(
      MaterialApp(
        home: ProductListScreen(repository: mockRepo),
      ),
    );

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

    // Tunggu data load
    await tester.pumpAndSettle();

    // Produk muncul
    expect(find.text('Laptop'), findsOneWidget);
    expect(find.text('Mouse'), findsOneWidget);
    expect(find.text('Rp 10.000.000'), findsOneWidget);

    // Verifikasi repository dipanggil tepat 1 kali
    verify(() => mockRepo.getProducts()).called(1);
  });
}

Kombinasi mock + widget testing memberikan kendali penuh atas data yang masuk ke widget tanpa perlu koneksi internet atau database sungguhan.


Perbedaan Widget Test vs Integration Test

Aspek Widget Test Integration Test
Environment Headless (tanpa device) Device/emulator nyata
Kecepatan ⚡ Sangat cepat (~detik) 🐢 Lambat (~menit)
Scope Satu widget/screen Seluruh flow app
Package flutter_test integration_test
Mocking Perlu mock external deps Data real dari API/DB
Cocok untuk Testing UI & interaksi End-to-end user flow

Best Practice Widget Testing

1. Bungkus widget dengan MaterialApp — Hampir semua widget butuh context Material. Tanpa ini, test sering error aneh.

2. Pakai Key untuk target pentingfind.byKey() lebih stabil daripada find.byType() kalau ada beberapa widget sejenis di satu screen.

3. Jangan test implementation — Test apa yang tampil, bukan bagaimana widget dibangun internalnya. Contoh buruk: expect(widget.toString(), contains('StatefulWidget')).

4. Pisahkan test per behavior — Satu test = satu asumsi. Jangan gabungkan test render + test interaksi + test navigasi dalam satu testWidgets().

5. Gunakan pumpAndSettle() untuk animasi — Kalau widget punya animasi atau async, pump() biasa bisa gagal karena widget belum stabil.


Ringkasan

Konsep Penjelasan
Package flutter_test (bawaan Flutter)
Fungsi test testWidgets('deskripsi', (tester) { ... })
Render tester.pumpWidget(MaterialApp(...))
Interaksi tester.tap(), tester.enterText(), tester.drag()
Rebuild tester.pump() atau tester.pumpAndSettle()
Cari widget find.text(), find.byKey(), find.byType()
Jalankan flutter test

Widget testing adalah jembatan sempurna antara unit testing (logika murni) dan integration testing (device nyata). Dengan widget test, kamu bisa yakin UI berfungsi dengan benar sebelum deploy ke device. 💪


Coba sendiri! Buat widget test untuk minimal 3 widget di projekmu — pastikan text muncul, tombol bisa diklik, dan state berubah sesuai harapan. Share ke sosial media dan tag @ahsai001 — tunjukkan bahwa testing itu penting dan seru! 🚀