Skip to content

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.4

bloc_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 CounterCubit

onChange versus onTransition

Perbedaannya sering membingungkan:

onChangeonTransition
Berlaku untukCubit dan BlocHanya Bloc
BerisiState lama & baruState lama, baru, dan event
Kapan dipanggilSetiap emitSetiap 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, diabaikan

Aktifkan 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/html

Struktur 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 bersama

Prioritas pengujian

Kalau waktumu terbatas, urutan yang memberi nilai terbesar:

  1. Cubit dan Bloc — cepat ditulis, cepat dijalankan, menangkap bug logika.
  2. Repository — menangkap bug penguraian dan cache.
  3. 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 memakai verify untuk membuktikan urutan interaksi — misalnya bahwa validasi terjadi sebelum permintaan jaringan.
  • Ada satu pengujian yang harapannya adalah daftar kosong, karena state yang di-emit identik dengan state sebelumnya.
  • BlocObserver terpasang, 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.
  • verifyNever dipakai 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 MockCubit dan urutan state ditentukan lewat whenListen.
  • Ketiga tahap diperiksa dengan pumpAndSettle di 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 debounce memakai wait dan 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 emit setelahnya.
  • onError pada BlocObserver mencatat 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_test menyediakan blocTest dengan build, act, dan expect; mocktail untuk objek palsu tanpa pembuatan kode.
  • expect memeriksa urutan yang persis, dan state awal tidak termasuk.
  • Ingat bahwa emit dengan nilai yang sama diabaikan — pengujiannya expect: () => [].
  • Equatable wajib agar perbandingan state di pengujian bekerja.
  • Uji bahwa copyWith mempertahankan properti lain — bug klasik yang sulit terlihat.
  • Gunakan verify dan verifyNever untuk memeriksa interaksi, bukan hanya state — misalnya membuktikan validasi terjadi sebelum permintaan jaringan.
  • Pakai wait untuk menguji BLoC yang memakai debounce atau throttle.
  • MockCubit + whenListen untuk menguji widget dengan urutan state tertentu.
  • BlocObserver mencatat seluruh perubahan di semua BLoC secara global — jauh lebih baik daripada menaburkan print.
  • onChange untuk Cubit dan Bloc; onTransition hanya Bloc dan menyertakan event penyebabnya.
  • Jangan biarkan exception lolos dari BLoC — stream-nya berhenti dan BLoC mati diam-diam.
  • Periksa isClosed sebelum emit setelah await — padanan mounted pada 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.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.