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(atauCupertinoApp) danProviderScopejika menggunakan Riverpod.pumpWidget()untuk render awal,pump()untuk satu frame setelah interaksi,pumpAndSettle()untuk menunggu sampai semua animasi dan async selesai.Finderuntuk menemukan widget:find.text(),find.byType(),find.byKey(),find.byIcon(). Gunakanfind.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-goldensuntuk membuat/memperbarui file referensi.