Widget Test #
Jika unit test bertugas mengamankan logika di balik layar, widget test (sering kali disebut pengujian komponen) adalah jembatan yang menghubungkan logika tersebut dengan apa yang benar-benar dilihat dan disentuh oleh pengguna di layar. Di dalam Flutter, widget test tidak memerlukan peluncuran emulator Android atau simulator iOS yang memakan banyak waktu dan memori. Pustaka pengujian Flutter memiliki mesin emulasi antarmuka grafis internal yang sangat ringan, memungkinkan kita untuk merender widget, mensimulasikan interaksi pengguna, dan memverifikasi perubahan state UI secara instan dalam hitungan detik.
Widget test memberikan keseimbangan (sweet spot) yang ideal di dalam piramida pengujian kita: ia memberikan tingkat keyakinan yang tinggi bahwa tampilan UI merender state dengan benar, namun tetap berjalan dengan sangat cepat dan terisolasi tanpa memerlukan dependensi sistem operasi native. Di dalam panduan komprehensif ini, kita akan membahas mendalam cara kerja WidgetTester, menavigasi widget tree menggunakan Finder, melakukan asersi dengan Matcher, mensimulasikan berbagai tindakan pengguna, merancang helper pumpApp untuk efisiensi kode, hingga menerapkan pengujian visual menggunakan Golden Test.
Konsep Dasar & Emulasi UI #
Untuk memahami widget test, kita harus menyadari bahwa Flutter tidak menggunakan komponen UI native dari sistem operasi (seperti Button bawaan Android atau UIView bawaan iOS). Flutter menggambar seluruh elemen antarmukanya sendiri piksel-demi-piksel menggunakan mesin grafisnya (Impeller atau Skia). Karakteristik ini memberikan keuntungan besar untuk pengujian: pustaka pengujian dapat menggantikan mesin grafis fisik dengan mesin emulasi grafis berbasis memori RAM (virtual screen).
Saat kita menjalankan widget test:
- Widget tree akan dibangun secara penuh di dalam memori.
- Seluruh proses tata letak (layout) dan pengukuran ukuran widget (constraints) dihitung secara akurat sesuai spesifikasi Flutter.
- Pustaka pengujian akan mensimulasikan resolusi layar default (biasanya $800 \times 600$ piksel, namun dapat kita kustomisasi sesuai kebutuhan pengujian).
- Kita dapat melakukan inspeksi mendalam terhadap struktur widget tree untuk memastikan properti dekorasi, warna, margin, dan posisi widget terpasang dengan benar.
WidgetTester: Siklus Hidup & Trigger Frame #
Objek WidgetTester adalah instrumen utama kita untuk berinteraksi dengan widget yang sedang diuji. Melalui WidgetTester, kita memicu pembangunan ulang frame UI (rebuild) ketika terjadi perubahan state. Hal ini karena dalam lingkungan pengujian, Flutter tidak melakukan rendering otomatis secara terus-menerus pada kecepatan 60 FPS untuk menghemat memori. Kita harus memicu rendering frame baru secara manual menggunakan metode pump.
Berikut adalah tiga metode kontrol frame utama pada WidgetTester yang wajib kita pahami:
1. tester.pumpWidget(Widget widget)
#
Metode ini digunakan untuk merender widget pertama kali ke dalam lingkungan pengujian. Panggilan ini akan memicu daur hidup pembuatan elemen tree secara berantai.
await tester.pumpWidget(const MaterialApp(home: Text('Halo Dunia')));
2. tester.pump([Duration? duration])
#
Memicu pembangunan ulang (rebuild) satu frame pada widget tree. Jika kita menyertakan durasi (misal: Duration(milliseconds: 100)), Flutter akan memajukan waktu pengujian secara sinkron sebesar durasi tersebut untuk memproses animasi transisi.
// Panggil setelah interaksi seperti tap yang mengubah state via setState
await tester.tap(find.byType(ElevatedButton));
await tester.pump(); // Memicu pembangunan frame baru untuk merender state terbaru
3. tester.pumpAndSettle()
#
Memicu pembangunan frame secara berulang-ulang sampai tidak ada lagi frame yang dijadwalkan di antrean. Sederhananya, metode ini menunggu hingga seluruh animasi, transisi halaman, dan proses asinkron mikro selesai sepenuhnya.
await tester.tap(find.text('Login'));
await tester.pumpAndSettle(); // Menunggu transisi halaman login ke beranda selesai sepenuhnya
[!WARNING] Bahaya Pengecualian Animasi Tanpa Akhir: Metode
pumpAndSettle()memiliki batas waktu default (timeout biasanya 10 menit) dan akan melempar error jika mendeteksi adanya animasi yang terus berjalan tanpa henti. Contoh paling umum adalah widgetCircularProgressIndicatoryang berputar terus-menerus di layar. Jika kita memanggilpumpAndSettle()saat indikator putar tersebut aktif di layar, tes kita dijamin akan crash karena timeout. Untuk skenario di mana loading indicator aktif, gunakantester.pump(Duration(milliseconds: n))secara terkendali.
Arsitektur Alur Rendering Widget Tester #
Untuk memperjelas bagaimana siklus hidup rendering dan pemrosesan frame dikendalikan secara manual oleh kode tes kita, perhatikan flowchart di bawah ini:
graph TD
Start["tester.pumpWidget(Widget)"] -->|1. Render Pertama| Frame1["Frame Pertama di-Render"]
Frame1 -->|2. Aksi Pengguna (tester.tap/enterText)| Act["Tindakan Simulasi (Interaksi)"]
Act -->|3. Perlu Render Ulang (setState / Trigger)| Frame2{"Apakah Butuh Animasi?"}
Frame2 -->|Ya (Animasi Aktif)| PumpSettle["tester.pumpAndSettle()"]
Frame2 -->|Tidak (Satu Frame)| Pump["tester.pump()"]
PumpSettle -->|4. Tunggu Animasi Selesai| Settle["Semua Frame Selesai Dirender"]
Pump -->|4. Jalankan Satu Frame| Settle
Settle -->|5. Verifikasi UI| Expect["expect(finder, matcher)"]Dengan memahami diagram di atas, kita menyadari pentingnya menaruh pemanggilan pump() atau pumpAndSettle() di antara baris kode interaksi (Act) dan baris kode verifikasi (Assert). Tanpa pemicu frame ini, tampilan UI di memori tes akan tetap berada pada state lama.
Finder: Menavigasi dan Menemukan Komponen #
Sebelum kita dapat memverifikasi isi suatu elemen atau mensimulasikan ketukan tombol, kita harus menemukan widget tersebut di dalam widget tree menggunakan objek Finder. Kelas find bawaan Flutter menyediakan berbagai metode pencarian yang sangat fleksibel:
1. Metode Pencarian Standar #
find.text(String text): Mencari widget yang menampilkan teks persis (exact match). Sangat baik untuk memverifikasi teks statis.find.textContaining(String substring): Mencari widget yang teksnya mengandung potongan kata tertentu.find.byType(Type type): Mencari berdasarkan kelas widget (contoh:find.byType(CircularProgressIndicator)).find.byIcon(IconData icon): Mencari berdasarkan metadata ikon (contoh:find.byIcon(Icons.shopping_cart)).find.byKey(Key key): Mencari berdasarkan identitas unik Key yang kita sematkan pada widget di kode produksi. Ini adalah metode pencarian paling aman untuk menghindari kesalahan pencarian ganda.
2. Metode Pencarian Navigasi Relasional #
Terkadang, suatu elemen UI yang sama (misalnya tombol “Hapus”) muncul di beberapa tempat sekaligus di dalam satu layar. Kita dapat menyaring pencarian menggunakan relasi ancestor (induk) dan descendant (anak):
// Mencari tombol ElevatedButton yang berada di dalam widget Card produk tertentu
final finderTombolSpesifik = find.descendant(
of: find.byKey(const ValueKey('produk-card-123')),
matching: find.byType(ElevatedButton),
);
3. Penyaringan Berdasarkan Predikat Kustom #
Jika metode bawaan di atas belum mencukupi, kita dapat memfilter widget secara imperatif menggunakan fungsi predikat kustom:
final finderPredikat = find.byWidgetPredicate(
(Widget widget) => widget is Container && widget.decoration != null,
);
Matcher: Asersi Tampilan & Properti Widget #
Setelah kita berhasil menunjuk widget menggunakan Finder, langkah berikutnya adalah memverifikasi kondisinya menggunakan Matcher di dalam fungsi expect().
1. Memverifikasi Jumlah Keberadaan Widget #
findsOneWidget: Memastikan widget ditemukan tepat satu di layar.findsNothing: Memastikan tidak ada satu pun widget yang cocok (sangat berguna untuk menguji status tertutup/hilang).findsWidgets: Memastikan ditemukan minimal satu atau lebih widget.findsNWidgets(int n): Memastikan jumlah widget yang ditemukan tepat sebanyak $n$ buah.
2. Memverifikasi Parameter Internal Widget #
Terkadang kita tidak hanya ingin memastikan widget itu ada, tetapi juga ingin memeriksa apakah warna dekorasinya benar, ukuran font-nya sesuai, atau apakah status tombolnya aktif/nonaktif. Kita dapat menarik instance objek widget sesungguhnya dari WidgetTester menggunakan metode widget():
// 1. Temukan widget tombol di layar
final finderTombol = find.byKey(const Key('submit-button'));
// 2. Ambil instansiasi objek ElevatedButton dari framework
final ElevatedButton tombolObj = tester.widget<ElevatedButton>(finderTombol);
// 3. Verifikasi properti internalnya
// Jika onPressed bernilai null, berarti tombol dalam status dinonaktifkan (disabled)
expect(tombolObj.onPressed, isNotNull);
Simulasi Interaksi Pengguna di UI #
WidgetTester menyediakan kemampuan penuh untuk mensimulasikan tindakan fisik pengguna di layar perangkat. Metode interaksi ini bersifat asinkron dan wajib diikuti oleh pemanggilan frame trigger (pump) agar efek interaksi diproses oleh framework:
testWidgets('Simulasi interaksi form input', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: UserFormScreen()));
// 1. Ketik teks pada kolom input
// tester.enterText secara otomatis menempatkan fokus dan mengetikkan string
await tester.enterText(find.byType(TextField), '[email protected]');
await tester.pump(); // Trigger frame untuk memperbarui tampilan teks di layar
// 2. Ketuk tombol Kirim
await tester.tap(find.text('Kirim Data'));
// 3. Proses animasi loading atau pengiriman data hingga selesai
await tester.pumpAndSettle();
// 4. Lakukan asersi
expect(find.text('Data Berhasil Terkirim'), findsOneWidget);
});
Beberapa metode simulasi interaksi penting lainnya meliputi:
tester.drag(Finder finder, Offset offset): Menyeret widget (misal: menyeret slider ke koordinat tertentu).tester.longPress(Finder finder): Menahan ketukan pada widget untuk memicu menu pop-up.tester.scrollUntilVisible(Finder finder, double scrollDelta, {Finder? scrollable}): Menggulir area scrollable secara otomatis hingga widget target yang kita cari muncul di layar. Sangat penting untuk menguji daftar list yang panjang.
Helper Extension: Pola Bersih pumpApp #
Dalam aplikasi Flutter yang nyata, sebuah widget UI mandiri jarang sekali dapat berdiri sendiri tanpa memerlukan dependensi luar. Widget kita biasanya membutuhkan wrapper MaterialApp untuk mendeteksi arah teks (localization), wrapper Theme untuk styling warna, ProviderScope jika menggunakan Riverpod untuk state management, atau konfigurasi rute navigasi.
Menulis seluruh wrapper ini secara berulang-ulang di setiap berkas tes widget (boilerplate code) akan membuat kode pengujian kita kotor dan sulit dipelihara. Solusi terbaik adalah membuat sebuah Extension helper bernama pumpApp pada kelas WidgetTester.
Berikut adalah implementasi helper pumpApp yang bersih:
// test/helpers/pump_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
extension PumpApp on WidgetTester {
// Helper pembungkus terpusat untuk Widget Test
Future<void> pumpApp(
Widget widget, {
List<Override> overrides = const [],
}) async {
return await pumpWidget(
// 1. Bungkus dengan ProviderScope Riverpod untuk mengelola dependency override
ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
// Kita bisa menambahkan localizationsDelegates di sini jika aplikasi multi-bahasa
home: Scaffold(
body: widget,
),
),
),
);
}
}
Sekarang, perhatikan betapa bersih dan fokusnya kode tes kita jika menggunakan helper extension di atas:
// test/features/profile/presentation/widgets/profile_card_test.dart
import 'package:flutter_test/flutter_test.dart';
import '../../../helpers/pump_app.dart'; // Impor helper extension kita
void main() {
testWidgets('ProfileCard harus merender nama pengguna', (tester) async {
// Cukup panggil pumpApp secara ringkas
await tester.pumpApp(
const ProfileCard(userId: 'USER-1'),
overrides: [
// Kita bisa meng-override provider di sini jika diperlukan
],
);
expect(find.text('Budi Hartono'), findsOneWidget);
});
}
Pengujian Multi-State (Loading, Error, Data) #
Sebuah komponen UI yang matang harus mampu menangani transisi status data dengan baik. Kita harus menguji minimal tiga kondisi visual pada halaman kita:
- State Loading: Menampilkan loading indicator saat data sedang diambil dari server.
- State Data (Success): Menampilkan daftar komponen data secara rapi saat operasi sukses.
- State Error: Menampilkan pesan kesalahan dan tombol coba lagi (retry) ketika terjadi kegagalan sistem.
Berikut adalah contoh implementasi pengujian multi-state pada halaman ProductListScreen dengan memanfaatkan provider overrides Riverpod:
// test/features/products/presentation/screens/product_list_screen_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:flutter_app/features/products/domain/repositories/product_repository.dart';
import 'package:flutter_app/features/products/presentation/screens/product_list_screen.dart';
import 'package:flutter_app/features/products/data/models/product_model.dart';
import '../../../helpers/pump_app.dart';
class MockProductRepository extends Mock implements ProductRepository {}
void main() {
group('Pengujian Multi-State ProductListScreen', () {
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
});
testWidgets('Harus menampilkan loading indicator saat status pemuatan aktif', (tester) async {
// Arrange: Buat repositori mengembalikan Future yang tertunda lama (delayed)
when(() => mockRepository.getProductsList()).thenAnswer(
(_) async => Future.delayed(const Duration(seconds: 5), () => <ProductModel>[]),
);
// Act: Render halaman
await tester.pumpApp(
const ProductListScreen(),
overrides: [
productRepositoryProvider.overrideWithValue(mockRepository),
],
);
// Assert: Pemuatan awal harus memicu loading indicator
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.byType(ListView), findsNothing);
});
testWidgets('Harus menampilkan daftar kartu produk ketika data sukses diambil', (tester) async {
// Arrange
final listProduk = [
ProductModel(id: '1', name: 'Mouse Wireless', price: 150000.0),
ProductModel(id: '2', name: 'Keyboard USB', price: 200000.0),
];
when(() => mockRepository.getProductsList()).thenAnswer((_) async => listProduk);
// Act
await tester.pumpApp(
const ProductListScreen(),
overrides: [
productRepositoryProvider.overrideWithValue(mockRepository),
],
);
// Tunggu hingga proses asinkron selesai merender data di layar
await tester.pumpAndSettle();
// Assert
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Mouse Wireless'), findsOneWidget);
expect(find.text('Keyboard USB'), findsOneWidget);
expect(find.byType(ListTile), findsNWidgets(2));
});
testWidgets('Harus menampilkan pesan kesalahan dan tombol retry saat terjadi error jaringan', (tester) async {
// Arrange
when(() => mockRepository.getProductsList()).thenThrow(Exception('Koneksi internet terputus'));
// Act
await tester.pumpApp(
const ProductListScreen(),
overrides: [
productRepositoryProvider.overrideWithValue(mockRepository),
],
);
await tester.pumpAndSettle();
// Assert
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Exception: Koneksi internet terputus'), findsOneWidget);
expect(find.text('Coba Lagi'), findsOneWidget); // Verifikasi keberadaan tombol retry
});
});
}
Golden Test: Verifikasi Visual Snapshot #
Bahkan jika semua asersi teks kita lulus, ada kemungkinan tata letak visual aplikasi kita berantakan secara estetika (misalnya: teks tumpang tindih, gambar terpotong, atau tombol meluap keluar batas layar). Untuk menguji integritas estetika visual antarmuka, kita menggunakan Golden Test.
Golden Test merender widget kita ke dalam bentuk gambar biner .png referensi (yang disebut sebagai berkas Golden File), kemudian pada pengujian berikutnya, ia akan mengambil cuplikan rendering terbaru dan membandingkannya piksel-demi-piksel dengan gambar referensi tersebut.
Berikut adalah cara menulis Golden Test untuk komponen ProductCard:
testWidgets('ProductCard Golden Test', (WidgetTester tester) async {
// 1. Render widget dengan setelan tema yang presisi
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.light(),
home: Center(
child: ProductCard(
product: ProductModel(id: '1', name: 'Buku Flutter', price: 99000.0),
),
),
),
);
// Pastikan rendering frame stabil
await tester.pumpAndSettle();
// 2. Bandingkan visual widget dengan berkas golden referensi
// Berkas gambar referensi akan disimpan di folder test/goldens/
await expectLater(
find.byType(ProductCard),
matchesGoldenFile('goldens/product_card_golden.png'),
);
});
Panduan CLI Golden Test: #
Untuk membuat berkas gambar referensi pertama kali (atau memperbaruinya jika kita sengaja mengubah desain UI):
flutter test --update-goldens
Untuk menjalankan pengujian visual secara rutin (membandingkan rendering dengan file png yang sudah ada):
flutter test
[!IMPORTANT] Keterbatasan Lintas Platform Golden Test: Satu hal yang wajib kita ketahui tentang Golden Test bawaan Flutter adalah ketergantungannya pada pustaka rendering font tingkat sistem operasi. Hasil rendering teks di komputer macOS akan sedikit berbeda di tingkat piksel mikro dengan hasil rendering di Linux (CI Server) atau Windows karena perbedaan teknik anti-aliasing font. Hal ini sering membuat golden test lulus di lokal namun gagal di server CI/CD.
Untuk mengatasinya, kita disarankan menggunakan paket penunjang khusus seperti
alchemistataugolden_toolkityang secara otomatis menutupi perbedaan font lintas platform, atau membatasi eksekusi golden test hanya pada sistem operasi Docker yang konsisten di CI server kita.
Ringkasan #
- Virtual Screen: Widget test merender widget ke dalam memori RAM virtual menggunakan mesin emulasi grafis Flutter, berjalan cepat tanpa butuh perangkat fisik nyata.
- Manajemen Frame: Selalu gunakan
tester.pumpWidget()untuk inisiasi render pertama,tester.pump()untuk memproses satu frame perubahan state, dantester.pumpAndSettle()untuk menunggu animasi selesai.- Bahaya Loop Animasi: Hindari memanggil
pumpAndSettle()saat terdapat animasi tak terbatas seperti putaran loading indicator di layar karena akan memicu timeout crash.- Abstraksi helper: Buatlah extension helper
pumpApp()padaWidgetTesteruntuk memangkas boilerplate code wrapperMaterialAppdanProviderScopedi setiap file tes.- Penyaringan State: Pastikan untuk menguji seluruh kemungkinan state antarmuka: loading state, data/success state, dan error state.
- Integritas Visual: Manfaatkan Golden Test untuk mengamankan tata letak visual UI dari kerusakan overflow atau perubahan piksel yang tidak disengaja.