Tampilan
Bab 26 — Testing & Debugging BLoC
Ini bab penutup Bagian III, dan sekaligus alasan paling kuat untuk memilih BLoC di proyek besar: logika bisnisnya bisa diuji tanpa membangun satu widget pun.
Karena Cubit dan Bloc adalah kelas Dart murni yang tidak mengenal Flutter — kalau kamu mengikuti aturan di Bab 22 — pengujiannya cepat, sederhana, dan tidak rapuh.
Kenapa menguji BLoC lebih mudah
Bandingkan dua pendekatan menguji aturan "penghitung tidak boleh negatif":
dart
// Dengan setState — butuh widget test yang lambat
testWidgets('tidak boleh negatif', (tester) async {
await tester.pumpWidget(const MaterialApp(home: HalamanPenghitung()));
await tester.tap(find.byIcon(Icons.remove));
await tester.pump();
expect(find.text('0'), findsOneWidget);
});
// Dengan BLoC — pengujian unit murni, milidetik
test('tidak boleh negatif', () {
final cubit = CounterCubit();
cubit.decrement();
expect(cubit.state, 0);
});Yang pertama membangun seluruh pohon widget, merender, dan mencari teks. Yang kedua hanya memanggil satu metode.
Menyiapkan
yaml
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
bloc_test: ^9.1.7
mocktail: ^1.0.4bloc_test menyediakan helper khusus untuk menguji BLoC. mocktail untuk membuat objek palsu — ia lebih disukai daripada mockito karena tidak butuh pembuatan kode.
Pengujian dasar
dart
// test/logic/counter_cubit_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:aplikasi_saya/logic/cubit/counter_cubit.dart';
void main() {
group('CounterCubit', () {
late CounterCubit cubit;
setUp(() {
cubit = CounterCubit();
});
tearDown(() {
cubit.close(); // ⚠️ selalu tutup, kalau tidak stream bocor
});
test('state awal adalah 0', () {
expect(cubit.state, 0);
});
blocTest<CounterCubit, int>(
'mengeluarkan 1 ketika increment dipanggil',
build: () => CounterCubit(),
act: (cubit) => cubit.increment(),
expect: () => [1],
);
blocTest<CounterCubit, int>(
'mengeluarkan 1, 2, 3 ketika increment dipanggil tiga kali',
build: () => CounterCubit(),
act: (cubit) => cubit
..increment()
..increment()
..increment(),
expect: () => [1, 2, 3],
);
blocTest<CounterCubit, int>(
'mengeluarkan -1 ketika decrement dipanggil',
build: () => CounterCubit(),
act: (cubit) => cubit.decrement(),
expect: () => [-1],
);
blocTest<CounterCubit, int>(
'tidak mengeluarkan apa pun ketika reset dipanggil pada state 0',
build: () => CounterCubit(),
act: (cubit) => cubit.reset(),
// emit(0) diabaikan karena state sudah 0
expect: () => <int>[],
);
});
}Pengujian terakhir itu menangkap perilaku yang dibahas di Bab 20: emit mengabaikan state yang sama dengan sebelumnya. Kalau kamu tidak menyadarinya, pengujianmu akan gagal dengan pesan yang membingungkan.
Anatomi blocTest
dart
blocTest<TipeBloc, TipeState>(
'deskripsi pengujian',
// Menyiapkan objek palsu sebelum bloc dibuat
setUp: () { },
// Membuat instance yang akan diuji
build: () => CounterCubit(),
// State awal kustom, kalau perlu
seed: () => 10,
// Aksi yang dijalankan
act: (cubit) => cubit.increment(),
// Tunggu sebelum memeriksa (untuk debounce, throttle)
wait: const Duration(milliseconds: 300),
// Lewati beberapa state pertama
skip: 1,
// State yang diharapkan, BERURUTAN
expect: () => [11],
// Pemeriksaan tambahan setelah selesai
verify: (cubit) {
verify(() => repository.ambilData()).called(1);
},
// Error yang diharapkan
errors: () => [isA<Exception>()],
);Yang paling sering kamu pakai adalah build, act, dan expect.
expect memeriksa urutan, bukan sekadar isi
expect: () => [1, 2, 3] berarti BLoC harus mengeluarkan persis tiga state dalam urutan itu. Kalau ia mengeluarkan [1, 2, 3, 3] atau [3, 2, 1], pengujian gagal.
Dan state awal tidak termasuk. CounterCubit yang dimulai dari 0 lalu di-increment sekali menghasilkan expect: () => [1], bukan [0, 1].
Menguji state kustom
Di sinilah Equatable dari Bab 20 menjadi wajib. Tanpanya, perbandingan state di pengujian akan selalu gagal.
dart
// test/logic/settings_cubit_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
void main() {
group('SettingsCubit', () {
blocTest<SettingsCubit, SettingsState>(
'mengaktifkan notifikasi aplikasi',
build: () => SettingsCubit(),
act: (cubit) => cubit.setNotifikasiAplikasi(true),
expect: () => [
const SettingsState(notifikasiAplikasi: true),
],
);
blocTest<SettingsCubit, SettingsState>(
'mempertahankan nilai lain saat mengubah satu sakelar',
build: () => SettingsCubit(),
seed: () => const SettingsState(modeGelap: true, bahasa: 'en'),
act: (cubit) => cubit.setNotifikasiEmail(true),
expect: () => [
const SettingsState(
modeGelap: true, // tidak berubah
bahasa: 'en', // tidak berubah
notifikasiEmail: true, // berubah
),
],
);
});
}Pengujian kedua itu berharga: ia membuktikan bahwa copyWith benar-benar menyalin seluruh properti. Bug klasik pada copyWith adalah lupa menyertakan satu properti, dan pengujian seperti ini menangkapnya.
Kalau kamu tidak ingin membandingkan seluruh objek, pakai matcher:
dart
blocTest<SettingsCubit, SettingsState>(
'mengubah ukuran font',
build: () => SettingsCubit(),
act: (cubit) => cubit.setUkuranFont(18),
expect: () => [
isA<SettingsState>().having((s) => s.ukuranFont, 'ukuranFont', 18.0),
],
);Menguji dengan repository palsu
Sebagian besar BLoC yang berguna bergantung pada repository. Untuk mengujinya, ganti repository asli dengan yang palsu.
dart
// test/logic/weather_cubit_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:aplikasi_saya/data/models/weather.dart';
import 'package:aplikasi_saya/data/repositories/weather_repository.dart';
import 'package:aplikasi_saya/data/data_providers/weather_api.dart';
import 'package:aplikasi_saya/logic/cubit/weather_cubit.dart';
import 'package:aplikasi_saya/logic/cubit/weather_state.dart';
class MockWeatherRepository extends Mock implements WeatherRepository {}
void main() {
late MockWeatherRepository repository;
const cuacaContoh = Weather(
kota: 'Bandung',
suhu: 24.5,
terasaSeperti: 25.0,
deskripsi: 'berawan',
ikon: '02d',
kelembapan: 78,
kecepatanAngin: 3.2,
);
setUp(() {
repository = MockWeatherRepository();
});
group('WeatherCubit', () {
test('state awal adalah WeatherInitial', () {
final cubit = WeatherCubit(repository);
expect(cubit.state, const WeatherInitial());
cubit.close();
});
blocTest<WeatherCubit, WeatherState>(
'mengeluarkan Loading lalu Loaded saat berhasil',
setUp: () {
when(() => repository.ambilCuaca(any()))
.thenAnswer((_) async => cuacaContoh);
},
build: () => WeatherCubit(repository),
act: (cubit) => cubit.ambilCuaca('Bandung'),
expect: () => [
const WeatherLoading(),
const WeatherLoaded(cuacaContoh),
],
verify: (_) {
verify(() => repository.ambilCuaca('Bandung')).called(1);
},
);
blocTest<WeatherCubit, WeatherState>(
'mengeluarkan Loading lalu Error saat repository gagal',
setUp: () {
when(() => repository.ambilCuaca(any()))
.thenThrow(KesalahanApi('Kota tidak ditemukan.'));
},
build: () => WeatherCubit(repository),
act: (cubit) => cubit.ambilCuaca('KotaPalsu'),
expect: () => [
const WeatherLoading(),
const WeatherError('Kota tidak ditemukan.'),
],
);
blocTest<WeatherCubit, WeatherState>(
'menolak nama kota kosong tanpa memanggil repository',
build: () => WeatherCubit(repository),
act: (cubit) => cubit.ambilCuaca(' '),
expect: () => [
const WeatherError('Masukkan nama kota terlebih dahulu.'),
],
verify: (_) {
// Repository tidak boleh tersentuh sama sekali
verifyNever(() => repository.ambilCuaca(any()));
},
);
});
}Pengujian ketiga itu contoh bagus dari nilai verify. Ia tidak hanya memeriksa state yang dihasilkan, tetapi juga membuktikan bahwa validasi terjadi sebelum permintaan jaringan — sesuatu yang tidak terlihat dari state saja.
registerFallbackValue
Kalau argumen any() bertipe kustom, mocktail butuh nilai cadangan yang didaftarkan lebih dulu:
dart
class FakeTodo extends Fake implements Todo {}
setUpAll(() {
registerFallbackValue(FakeTodo());
});
// Sekarang ini berfungsi
when(() => repository.simpan(any())).thenAnswer((_) async {});Menguji Bloc berbasis event
Sama saja, hanya act yang menambahkan event alih-alih memanggil metode.
dart
blocTest<TodoBloc, TodoState>(
'memuat daftar tugas',
setUp: () {
when(() => repository.ambilSemua())
.thenAnswer((_) async => [Todo(judul: 'Belajar BLoC')]);
},
build: () => TodoBloc(repository),
act: (bloc) => bloc.add(MuatTodo()),
expect: () => [
const TodoLoading(),
isA<TodoLoaded>().having((s) => s.daftar.length, 'jumlah', 1),
],
);Untuk event yang memakai transformer seperti debounce, gunakan wait:
dart
blocTest<SearchBloc, SearchState>(
'hanya mencari sekali meskipun diketik tiga kali',
setUp: () {
when(() => repository.cari(any())).thenAnswer((_) async => []);
},
build: () => SearchBloc(repository),
act: (bloc) => bloc
..add(QueryChanged('f'))
..add(QueryChanged('fl'))
..add(QueryChanged('flu')),
wait: const Duration(milliseconds: 400), // lewati jendela debounce
verify: (_) {
// Hanya kueri terakhir yang benar-benar dicari
verify(() => repository.cari('flu')).called(1);
verifyNever(() => repository.cari('f'));
},
);Menguji repository
Lapisan data juga perlu diuji, dan pemisahan di Bab 22 membuatnya mudah.
dart
// test/data/weather_repository_test.dart
class MockWeatherApi extends Mock implements WeatherApi {}
void main() {
late MockWeatherApi api;
late WeatherRepository repository;
const jsonMentah = {
'name': 'Bandung',
'main': {'temp': 24.5, 'feels_like': 25.0, 'humidity': 78},
'weather': [
{'description': 'berawan', 'icon': '02d'}
],
'wind': {'speed': 3.2},
};
setUp(() {
api = MockWeatherApi();
repository = WeatherRepository(api);
});
test('mengubah JSON mentah menjadi objek Weather', () async {
when(() => api.ambilCuacaMentah(any()))
.thenAnswer((_) async => jsonMentah);
final hasil = await repository.ambilCuaca('Bandung');
expect(hasil.kota, 'Bandung');
expect(hasil.suhu, 24.5);
expect(hasil.deskripsi, 'berawan');
});
test('memakai cache pada permintaan kedua', () async {
when(() => api.ambilCuacaMentah(any()))
.thenAnswer((_) async => jsonMentah);
await repository.ambilCuaca('Bandung');
await repository.ambilCuaca('Bandung');
// API hanya dipanggil sekali — yang kedua dari cache
verify(() => api.ambilCuacaMentah('Bandung')).called(1);
});
test('mengabaikan perbedaan huruf besar-kecil pada nama kota', () async {
when(() => api.ambilCuacaMentah(any()))
.thenAnswer((_) async => jsonMentah);
await repository.ambilCuaca('Bandung');
await repository.ambilCuaca('bandung');
verify(() => api.ambilCuacaMentah(any())).called(1);
});
}Pengujian kedua dan ketiga menguji perilaku cache — logika yang mudah rusak saat seseorang merefaktor dan sulit terlihat dari UI.
Menguji widget dengan BLoC
Ketika kamu memang perlu menguji lapisan UI, sediakan BLoC palsu.
dart
// test/presentation/weather_screen_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:bloc_test/bloc_test.dart';
class MockWeatherCubit extends MockCubit<WeatherState>
implements WeatherCubit {}
void main() {
late MockWeatherCubit cubit;
setUp(() {
cubit = MockWeatherCubit();
});
Widget bungkus(Widget anak) {
return MaterialApp(
home: BlocProvider<WeatherCubit>.value(
value: cubit,
child: anak,
),
);
}
testWidgets('menampilkan spinner saat Loading', (tester) async {
when(() => cubit.state).thenReturn(const WeatherLoading());
await tester.pumpWidget(bungkus(const WeatherScreen()));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('menampilkan pesan saat Error', (tester) async {
when(() => cubit.state)
.thenReturn(const WeatherError('Kota tidak ditemukan.'));
await tester.pumpWidget(bungkus(const WeatherScreen()));
await tester.pump();
expect(find.text('Kota tidak ditemukan.'), findsOneWidget);
expect(find.text('Coba Lagi'), findsOneWidget);
});
testWidgets('memanggil ambilCuaca saat tombol cari ditekan',
(tester) async {
when(() => cubit.state).thenReturn(const WeatherInitial());
when(() => cubit.ambilCuaca(any())).thenAnswer((_) async {});
await tester.pumpWidget(bungkus(const WeatherScreen()));
await tester.enterText(find.byType(TextField), 'Bandung');
await tester.tap(find.byIcon(Icons.search));
await tester.pump();
verify(() => cubit.ambilCuaca('Bandung')).called(1);
});
}MockCubit dari bloc_test sudah menyediakan stream palsu, sehingga BlocBuilder bekerja normal.
Untuk menguji urutan state di UI, pakai whenListen:
dart
testWidgets('berpindah dari loading ke data', (tester) async {
whenListen(
cubit,
Stream.fromIterable([
const WeatherLoading(),
const WeatherLoaded(cuacaContoh),
]),
initialState: const WeatherInitial(),
);
await tester.pumpWidget(bungkus(const WeatherScreen()));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pump();
expect(find.text('Bandung'), findsOneWidget);
});BlocObserver: melihat semua yang terjadi
Sekarang beralih ke debugging. BlocObserver adalah pengamat global yang mencatat setiap perubahan di seluruh BLoC aplikasimu — tanpa kamu menaburkan print di mana-mana.
dart
// lib/logic/simple_bloc_observer.dart
import 'dart:developer' as developer;
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
class SimpleBlocObserver extends BlocObserver {
const SimpleBlocObserver();
@override
void onCreate(BlocBase bloc) {
super.onCreate(bloc);
if (kDebugMode) developer.log('🟢 DIBUAT ${bloc.runtimeType}');
}
/// Setiap state baru yang dikeluarkan — berlaku untuk Cubit maupun Bloc.
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
if (kDebugMode) {
developer.log(
'🔄 ${bloc.runtimeType}\n'
' dari : ${change.currentState}\n'
' ke : ${change.nextState}',
);
}
}
/// Hanya untuk Bloc — event yang masuk.
@override
void onEvent(Bloc bloc, Object? event) {
super.onEvent(bloc, event);
if (kDebugMode) developer.log('📥 EVENT ${bloc.runtimeType} → $event');
}
/// Hanya untuk Bloc — transisi lengkap (event + state lama + state baru).
@override
void onTransition(Bloc bloc, Transition transition) {
super.onTransition(bloc, transition);
if (kDebugMode) {
developer.log(
'➡️ ${bloc.runtimeType}\n'
' event : ${transition.event}\n'
' dari : ${transition.currentState}\n'
' ke : ${transition.nextState}',
);
}
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
developer.log(
'❌ ERROR di ${bloc.runtimeType}',
error: error,
stackTrace: stackTrace,
);
super.onError(bloc, error, stackTrace);
// Di produksi: kirim ke Crashlytics atau Sentry
}
@override
void onClose(BlocBase bloc) {
super.onClose(bloc);
if (kDebugMode) developer.log('🔴 DITUTUP ${bloc.runtimeType}');
}
}Mengaktifkannya:
dart
// lib/main.dart
void main() {
Bloc.observer = const SimpleBlocObserver();
runApp(const AplikasiSaya());
}Keluarannya di konsol:
🟢 DIBUAT CounterCubit
📥 EVENT CounterBloc → IncrementCounter()
➡️ CounterBloc
event : IncrementCounter()
dari : 0
ke : 1
🔄 CounterCubit
dari : 0
ke : 1
🔴 DITUTUP CounterCubitonChange versus onTransition
Perbedaannya sering membingungkan:
onChange | onTransition | |
|---|---|---|
| Berlaku untuk | Cubit dan Bloc | Hanya Bloc |
| Berisi | State lama & baru | State lama, baru, dan event |
| Kapan dipanggil | Setiap emit | Setiap event yang menghasilkan state |
Untuk Bloc, onTransition lebih berguna karena ia menjawab pertanyaan "apa yang menyebabkan perubahan ini" — informasi yang tidak ada di onChange.
Inilah keunggulan Bloc atas Cubit yang disebut di Bab 20: jejak sebab-akibat yang bisa dilacak.
Menangani error di dalam BLoC
BLoC punya mekanisme error sendiri.
dart
class WeatherCubit extends Cubit<WeatherState> {
WeatherCubit(this._repository) : super(const WeatherInitial());
final WeatherRepository _repository;
Future<void> ambilCuaca(String kota) async {
emit(const WeatherLoading());
try {
final cuaca = await _repository.ambilCuaca(kota);
emit(WeatherLoaded(cuaca));
} on KesalahanApi catch (e, jejak) {
// Laporkan ke observer untuk pencatatan
addError(e, jejak);
// Dan tetap keluarkan state yang bisa ditangani UI
emit(WeatherError(e.pesan));
}
}
@override
void onError(Object error, StackTrace stackTrace) {
developer.log('WeatherCubit gagal', error: error, stackTrace: stackTrace);
super.onError(error, stackTrace);
}
}Jangan biarkan exception lolos dari BLoC
Exception yang tidak ditangkap di dalam Cubit akan menghentikan stream-nya, dan BLoC itu berhenti berfungsi permanen — tanpa crash yang jelas. UI hanya membeku pada state terakhir.
Selalu bungkus operasi yang bisa gagal dengan try/catch, dan selalu keluarkan state error agar UI bisa menampilkan sesuatu.
Kesalahan umum saat debugging
"UI tidak diperbarui padahal state berubah."
Hampir selalu masalah Equatable. Periksa tiga hal:
dart
// 1. Apakah state extends Equatable?
class MyState extends Equatable { }
// 2. Apakah SEMUA properti ada di props?
@override
List<Object?> get props => [a, b, c]; // jangan ada yang terlewat
// 3. Apakah kamu membuat objek BARU?
emit(state.copyWith(nilai: baru)); // ✅
state.daftar.add(item); emit(state); // ❌ objek sama, diabaikanAktifkan BlocObserver dan lihat apakah onChange benar-benar terpanggil. Kalau tidak, emit sedang diabaikan.
"Could not find the correct Provider."
Masalah context. Lihat Bab 23 — biasanya karena memakai context yang berada di atas BlocProvider, atau lupa BlocProvider.value saat navigasi.
"Cannot emit after close."
Operasi asinkron selesai setelah BLoC ditutup:
dart
Future<void> muat() async {
final data = await _repo.ambil();
if (isClosed) return; // ← padanan `mounted` pada widget
emit(Loaded(data));
}"Bad state: Stream has already been listened to."
StreamController tanpa .broadcast() yang didengarkan dua kali. Lihat Bab 24.
Menjalankan pengujian
bash
# Semua pengujian
flutter test
# Satu berkas
flutter test test/logic/counter_cubit_test.dart
# Dengan laporan cakupan
flutter test --coverage
# Ubah menjadi HTML (butuh lcov)
genhtml coverage/lcov.info -o coverage/htmlStruktur folder pengujian yang mencerminkan struktur kode:
test/
├── data/
│ ├── models/
│ │ └── weather_test.dart
│ └── repositories/
│ └── weather_repository_test.dart
├── logic/
│ ├── counter_cubit_test.dart
│ ├── weather_cubit_test.dart
│ └── settings_cubit_test.dart
├── presentation/
│ └── weather_screen_test.dart
└── helpers/
└── mocks.dart ← definisi mock dipakai bersamaPrioritas pengujian
Kalau waktumu terbatas, urutan yang memberi nilai terbesar:
- Cubit dan Bloc — cepat ditulis, cepat dijalankan, menangkap bug logika.
- Repository — menangkap bug penguraian dan cache.
- Widget — paling lambat dan paling rapuh; tulis hanya untuk alur kritis.
Mengejar cakupan 100% jarang sepadan. Mengujinya di tempat yang logikanya rumit jauh lebih berharga.
Latihan Mandiri
Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.
Variasi 1: Rangkaian Pengujian Pertama — ⭐⭐ · 45–60 menit
Tantangan: Ambil BLoC mana pun yang sudah kamu tulis, lalu tulis rangkaian pengujian untuknya: satu untuk jalur berhasil, satu untuk jalur gagal memakai repository palsu, dan satu yang membuktikan copyWith tidak diam-diam menghapus properti lain. Lalu pasang BlocObserver dan amati satu sesi pemakaian penuh.
Kriteria selesai:
- Pengujian berjalan tanpa membangun satu widget pun.
- Repository palsu dibuat dengan
mocktail, dan setidaknya satu pengujian memakaiverifyuntuk membuktikan urutan interaksi — misalnya bahwa validasi terjadi sebelum permintaan jaringan. - Ada satu pengujian yang harapannya adalah daftar kosong, karena state yang di-
emitidentik dengan state sebelumnya. BlocObserverterpasang, dan kamu menemukan setidaknya satu perubahan state yang terjadi lebih sering daripada yang kamu kira.
Petunjuk: Dua hal yang paling sering membuat pengujian BLoC gagal padahal kodenya benar: expect memeriksa urutan yang persis dan tidak menyertakan state awal, dan perbandingan state bergantung sepenuhnya pada Equatable. Kalau pengujianmu melaporkan dua state yang terlihat identik sebagai berbeda, periksa props. Untuk pengujian copyWith, polanya sederhana tetapi sangat berguna: buat state dengan semua properti terisi, panggil copyWith yang hanya mengubah satu, lalu periksa seluruh properti lainnya masih sama. Bug di sini biasanya berupa satu baris yang lupa ditulis, dan gejalanya di aplikasi berupa nilai yang tiba-tiba kembali ke bawaan tanpa sebab yang jelas.
Variasi 2: Menguji Repository — ⭐⭐⭐ · 45–60 menit
Tantangan: Tulis pengujian untuk sebuah repository yang menggabungkan dua sumber: API dan cache lokal. Buktikan lewat pengujian bahwa cache dipakai ketika API gagal, bahwa cache diperbarui ketika API berhasil, dan bahwa API tidak dipanggil sama sekali ketika cache masih segar.
Kriteria selesai:
- Kedua sumber dipalsukan, dan repository diuji tanpa jaringan maupun penyimpanan sungguhan.
verifyNeverdipakai untuk membuktikan API tidak dipanggil pada kasus cache segar.- Ada pengujian untuk kasus kedua sumber gagal, dan hasilnya adalah kesalahan yang bisa dimengerti, bukan exception mentah.
- Pengujian berjalan dalam hitungan milidetik, bukan detik.
Petunjuk: verifyNever sering diabaikan padahal ia menguji hal yang tidak bisa dibuktikan dengan memeriksa hasil: bahwa sesuatu tidak terjadi. Sebuah repository yang mengembalikan data cache yang benar tetapi diam-diam tetap memanggil API akan lolos semua pengujian berbasis hasil, sambil membakar kuota pengguna. Untuk kriteria terakhir, kalau pengujianmu memakan detik, kemungkinan ada Future.delayed sungguhan di jalur yang diuji — palsukan juga sumber waktunya.
Variasi 3: Menguji Widget dengan BLoC Palsu — ⭐⭐⭐ · 45–60 menit
Tantangan: Tulis pengujian widget untuk satu layar yang bergantung pada BLoC, dengan urutan state yang kamu tentukan sendiri: memuat, lalu gagal, lalu berhasil. Buktikan bahwa layar menampilkan yang benar di tiap tahap, dan bahwa snackbar kesalahan muncul tepat sekali.
Kriteria selesai:
- BLoC dipalsukan dengan
MockCubitdan urutan state ditentukan lewatwhenListen. - Ketiga tahap diperiksa dengan
pumpAndSettledi antaranya. - Ada pengujian yang membuktikan snackbar hanya muncul sekali meski widget dibangun ulang beberapa kali untuk state yang sama.
- Tidak ada jaringan, penyimpanan, maupun timer sungguhan yang terlibat.
Petunjuk: Kriteria ketiga adalah pengujian yang menangkap bug klasik "snackbar berlipat" yang dibahas di Bab 21 — dan menariknya, ia hanya bisa ditangkap kalau kamu sengaja mengirim state yang sama dua kali lewat whenListen. Di aplikasi sungguhan, situasi itu terjadi sendiri secara acak, yang membuatnya sulit ditemukan tanpa pengujian.
Variasi 4: Waktu, Debounce, dan Error — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Uji sebuah BLoC pencarian yang memakai debounce, lalu uji juga apa yang terjadi ketika handler-nya melempar exception. Buktikan bahwa BLoC tidak mati diam-diam, dan bahwa emit setelah close tidak pernah terjadi.
Kriteria selesai:
- Pengujian
debouncememakaiwaitdan membuktikan hanya satu permintaan terkirim untuk beberapa ketikan beruntun. - Exception yang dilempar di dalam handler ditangkap, dan BLoC tetap merespons event berikutnya.
- Ada pengujian yang menutup BLoC di tengah operasi asinkron dan membuktikan tidak ada
emitsetelahnya. onErrorpadaBlocObservermencatat kegagalan itu.
Petunjuk: Kriteria kedua menguji hal yang paling berbahaya di BLoC: exception yang lolos akan menghentikan stream-nya, dan BLoC itu mati diam-diam — tidak ada error di layar, tombol-tombol hanya berhenti bekerja. Menangkapnya di dalam handler dan mengubahnya menjadi state kegagalan adalah pertahanan utamanya. Untuk kriteria ketiga, isClosed adalah padanan mounted pada widget, dan memeriksanya setelah setiap await adalah kebiasaan yang layak dibentuk sejak awal.
Ikhtisar
- BLoC bisa diuji tanpa membangun widget — inilah keunggulan terbesarnya untuk proyek besar.
bloc_testmenyediakanblocTestdenganbuild,act, danexpect;mocktailuntuk objek palsu tanpa pembuatan kode.expectmemeriksa urutan yang persis, dan state awal tidak termasuk.- Ingat bahwa
emitdengan nilai yang sama diabaikan — pengujiannyaexpect: () => []. Equatablewajib agar perbandingan state di pengujian bekerja.- Uji bahwa
copyWithmempertahankan properti lain — bug klasik yang sulit terlihat. - Gunakan
verifydanverifyNeveruntuk memeriksa interaksi, bukan hanya state — misalnya membuktikan validasi terjadi sebelum permintaan jaringan. - Pakai
waituntuk menguji BLoC yang memakaidebounceatauthrottle. MockCubit+whenListenuntuk menguji widget dengan urutan state tertentu.BlocObservermencatat seluruh perubahan di semua BLoC secara global — jauh lebih baik daripada menaburkanprint.onChangeuntuk Cubit dan Bloc;onTransitionhanya Bloc dan menyertakan event penyebabnya.- Jangan biarkan exception lolos dari BLoC — stream-nya berhenti dan BLoC mati diam-diam.
- Periksa
isClosedsebelumemitsetelahawait— padananmountedpada widget. - Prioritas pengujian: Cubit/Bloc dulu, lalu repository, widget terakhir.
Bagian III selesai. Kamu sekarang menguasai tiga solusi state management utama di ekosistem Flutter.
Berikutnya: Bab 27 — Setup Firebase, tempat aplikasimu mendapat backend sungguhan.
Transkrip asli
Disintesis dari 2_flutter_bloc-zero-to-hero/5_bloc-testing.md dan 11_debugging-bloc.md (BlocObserver, onChange, onTransition, onError). Lihat PDF BLoC Zero to Hero.