Unit Test #

Unit test adalah pondasi dari seluruh test suite. Ia menguji satu unit logika secara terisolasi — tanpa UI, tanpa jaringan nyata, tanpa database asli — sehingga berjalan dalam milidetik dan memberikan feedback yang cepat dan akurat. Jika unit test gagal, kamu tahu persis di mana bug-nya.

Setup #

Package flutter_test sudah tersedia secara default. Untuk mocking dependency, gunakan mocktail yang tidak membutuhkan code generation:

# pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.4

Anatomi Unit Test #

// test/features/produk/produk_repository_test.dart
import 'package:flutter_test/flutter_test.dart';

void main() {
  // group: mengelompokkan test yang berkaitan
  group('ProdukRepository', () {

    // Variabel yang digunakan di beberapa test
    late ProdukRepository repository;
    late MockProdukRemoteDataSource mockRemote;
    late MockProdukLocalDataSource mockLocal;

    // setUp: dijalankan sebelum SETIAP test dalam group
    setUp(() {
      mockRemote = MockProdukRemoteDataSource();
      mockLocal = MockProdukLocalDataSource();
      repository = ProdukRepositoryImpl(
        remote: mockRemote,
        local: mockLocal,
      );
    });

    // tearDown: dijalankan setelah SETIAP test (opsional)
    tearDown(() {
      // bersihkan resource jika perlu
    });

    // test: satu skenario yang diuji
    test('getProduk mengembalikan data dari cache jika tersedia', () async {
      // Arrange -- siapkan kondisi
      final cachedProduk = [
        Produk(id: '1', nama: 'Produk A', harga: 10000),
      ];
      when(() => mockLocal.getProduk()).thenAnswer((_) async => cachedProduk);

      // Act -- jalankan kode yang ditest
      final result = await repository.getProduk();

      // Assert -- verifikasi hasilnya
      expect(result, equals(cachedProduk));
      // Pastikan remote TIDAK dipanggil jika cache tersedia
      verifyNever(() => mockRemote.getProduk());
    });

    test('getProduk fetch dari remote jika cache kosong', () async {
      // Arrange
      final remoteProduk = [Produk(id: '2', nama: 'Produk B', harga: 20000)];
      when(() => mockLocal.getProduk()).thenAnswer((_) async => null);
      when(() => mockRemote.getProduk()).thenAnswer((_) async => remoteProduk);
      when(() => mockLocal.saveProduk(any())).thenAnswer((_) async {});

      // Act
      final result = await repository.getProduk();

      // Assert
      expect(result, equals(remoteProduk));
      verify(() => mockRemote.getProduk()).called(1);
      verify(() => mockLocal.saveProduk(remoteProduk)).called(1);
    });

    test('getProduk throw NetworkException jika remote gagal dan cache kosong', () async {
      // Arrange
      when(() => mockLocal.getProduk()).thenAnswer((_) async => null);
      when(() => mockRemote.getProduk()).thenThrow(const NetworkException());

      // Assert menggunakan throwsA
      await expectLater(
        repository.getProduk(),
        throwsA(isA<NetworkException>()),
      );
    });
  });
}

Mocking dengan Mocktail #

Mocktail memungkinkan pembuatan mock dari abstract class atau interface tanpa code generation:

// test/helpers/mocks.dart

// Definisikan mock class -- satu baris per interface
class MockProdukRemoteDataSource extends Mock implements ProdukRemoteDataSource {}
class MockProdukLocalDataSource extends Mock implements ProdukLocalDataSource {}
class MockProdukRepository extends Mock implements ProdukRepository {}
class MockAuthRepository extends Mock implements AuthRepository {}

// Untuk mock class yang punya method dengan tipe generik,
// daftarkan fallback value di main() atau setUpAll()
void main() {
  setUpAll(() {
    registerFallbackValue(Produk(id: '', nama: '', harga: 0));
    registerFallbackValue(<Produk>[]);
  });
  // ...
}

Stubbing — Tentukan Apa yang Dikembalikan Mock #

final mock = MockProdukRepository();

// thenReturn -- return nilai sinkron
when(() => mock.getProdukById('1'))
    .thenReturn(Produk(id: '1', nama: 'A', harga: 1000));

// thenAnswer -- return nilai async atau Future
when(() => mock.getProduk())
    .thenAnswer((_) async => [Produk(id: '1', nama: 'A', harga: 1000)]);

// thenThrow -- lempar exception
when(() => mock.getProduk())
    .thenThrow(const NetworkException('Tidak ada internet'));

// Berdasarkan argumen yang diberikan
when(() => mock.getProdukById('1'))
    .thenAnswer((_) async => Produk(id: '1', nama: 'Mahal', harga: 999999));
when(() => mock.getProdukById('2'))
    .thenAnswer((_) async => Produk(id: '2', nama: 'Murah', harga: 1000));

// any() -- matcher untuk argumen apapun
when(() => mock.saveProduk(any()))
    .thenAnswer((_) async {});

Verifikasi — Pastikan Method Dipanggil #

// Verifikasi dipanggil tepat sekali
verify(() => mock.getProduk()).called(1);

// Verifikasi dipanggil dengan argumen tertentu
verify(() => mock.getProdukById('123')).called(1);

// Verifikasi TIDAK dipanggil
verifyNever(() => mock.hapusProduk(any()));

// Verifikasi urutan pemanggilan
verifyInOrder([
  () => mock.validateInput(any()),
  () => mock.saveProduk(any()),
  () => mock.invalidateCache(),
]);

Test Fungsi Pure #

Fungsi pure (input → output, tanpa side effect) adalah yang paling mudah ditest:

// lib/features/keranjang/domain/keranjang_calculator.dart
class KeranjangCalculator {
  static double hitungTotal(List<ItemKeranjang> items) {
    return items.fold(0, (sum, item) => sum + (item.harga * item.jumlah));
  }

  static double hitungDiskon(double total, String? kodePromo) {
    return switch (kodePromo) {
      'DISKON10' => total * 0.10,
      'DISKON20' => total * 0.20,
      _ => 0,
    };
  }

  static bool isMinimumOrder(double total) => total >= 50000;
}

// test/features/keranjang/domain/keranjang_calculator_test.dart
void main() {
  group('KeranjangCalculator', () {
    group('hitungTotal', () {
      test('menghitung total dengan benar untuk satu item', () {
        final items = [ItemKeranjang(harga: 10000, jumlah: 3)];
        expect(KeranjangCalculator.hitungTotal(items), equals(30000));
      });

      test('menghitung total dengan benar untuk banyak item', () {
        final items = [
          ItemKeranjang(harga: 10000, jumlah: 2),
          ItemKeranjang(harga: 5000, jumlah: 4),
        ];
        expect(KeranjangCalculator.hitungTotal(items), equals(40000));
      });

      test('mengembalikan 0 untuk keranjang kosong', () {
        expect(KeranjangCalculator.hitungTotal([]), equals(0));
      });
    });

    group('hitungDiskon', () {
      test('DISKON10 memberikan diskon 10%', () {
        expect(KeranjangCalculator.hitungDiskon(100000, 'DISKON10'), equals(10000));
      });

      test('kode tidak valid memberikan diskon 0', () {
        expect(KeranjangCalculator.hitungDiskon(100000, 'INVALID'), equals(0));
        expect(KeranjangCalculator.hitungDiskon(100000, null), equals(0));
      });
    });
  });
}

Test AsyncNotifier Riverpod #

Test notifier menggunakan ProviderContainer — tidak butuh widget tree:

// test/features/produk/produk_notifier_test.dart
void main() {
  group('ProdukNotifier', () {
    late MockProdukRepository mockRepo;

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

    // Helper: buat container dengan override
    ProviderContainer makeContainer() {
      return ProviderContainer(
        overrides: [
          produkRepositoryProvider.overrideWithValue(mockRepo),
        ],
      );
    }

    test('build() memuat produk saat pertama kali', () async {
      // Arrange
      final produkList = [Produk(id: '1', nama: 'A', harga: 1000)];
      when(() => mockRepo.getProduk()).thenAnswer((_) async => produkList);

      final container = makeContainer();

      // Act -- tunggu future selesai
      final result = await container.read(produkProvider.future);

      // Assert
      expect(result, equals(produkList));
    });

    test('hapus() memanggil repository dan refresh state', () async {
      // Arrange
      final produkList = [
        Produk(id: '1', nama: 'A', harga: 1000),
        Produk(id: '2', nama: 'B', harga: 2000),
      ];
      when(() => mockRepo.getProduk()).thenAnswer((_) async => produkList);
      when(() => mockRepo.hapusProduk('1')).thenAnswer((_) async {});

      final container = makeContainer();
      await container.read(produkProvider.future);  // tunggu build selesai

      // Act
      await container.read(produkProvider.notifier).hapus('1');

      // Assert
      verify(() => mockRepo.hapusProduk('1')).called(1);
      verify(() => mockRepo.getProduk()).called(2);  // dipanggil 2x: build + setelah hapus
    });

    test('build() mengeset AsyncError jika repository throw', () async {
      // Arrange
      when(() => mockRepo.getProduk()).thenThrow(const NetworkException());

      final container = makeContainer();

      // Assert -- gunakan listener untuk menangkap state
      final listener = Listener<AsyncValue<List<Produk>>>();
      container.listen(produkProvider, listener, fireImmediately: true);

      await Future.delayed(Duration.zero);  // tunggu async selesai

      verify(() => listener(
        any(that: isA<AsyncLoading>()),
        any(that: isA<AsyncError>()),
      ));
    });
  });
}

// Helper class untuk listen state changes
class Listener<T> extends Mock {
  void call(T? previous, T next);
}

Test Bloc dan Cubit #

// Tambahkan bloc_test ke dev_dependencies
// bloc_test: ^9.1.7

void main() {
  group('ProdukCubit', () {
    late MockProdukRepository mockRepo;

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

    // blocTest: helper yang mengemulasi siklus bloc
    blocTest<ProdukCubit, ProdukState>(
      'emit [loading, loaded] saat getProduk berhasil',
      build: () => ProdukCubit(mockRepo),
      setUp: () {
        when(() => mockRepo.getProduk())
            .thenAnswer((_) async => [Produk(id: '1', nama: 'A', harga: 1000)]);
      },
      act: (cubit) => cubit.loadProduk(),
      expect: () => [
        const ProdukState.loading(),
        ProdukState.loaded([Produk(id: '1', nama: 'A', harga: 1000)]),
      ],
    );

    blocTest<ProdukCubit, ProdukState>(
      'emit [loading, error] saat getProduk gagal',
      build: () => ProdukCubit(mockRepo),
      setUp: () {
        when(() => mockRepo.getProduk())
            .thenThrow(const NetworkException('Tidak ada internet'));
      },
      act: (cubit) => cubit.loadProduk(),
      expect: () => [
        const ProdukState.loading(),
        const ProdukState.error('Tidak ada internet'),
      ],
      verify: (cubit) {
        verify(() => mockRepo.getProduk()).called(1);
      },
    );
  });
}

Matchers yang Berguna #

// Kesetaraan
expect(nilai, equals(42));
expect(daftar, equals([1, 2, 3]));
expect(objek, same(objekYangSama));           // identitas, bukan nilai

// Tipe
expect(error, isA<NetworkException>());
expect(nilai, isNull);
expect(nilai, isNotNull);
expect(nilai, isTrue);
expect(nilai, isFalse);

// Angka
expect(nilai, greaterThan(10));
expect(nilai, lessThanOrEqualTo(100));
expect(nilai, inInclusiveRange(1, 10));
expect(nilai, closeTo(3.14, 0.001));          // untuk double, toleransi 0.001

// String
expect(teks, contains('flutter'));
expect(teks, startsWith('Hello'));
expect(teks, endsWith('!'));
expect(teks, matches(RegExp(r'^\d{4}$')));    // 4 digit angka

// Collection
expect(daftar, hasLength(3));
expect(daftar, contains('item'));
expect(daftar, containsAll(['a', 'b']));
expect(daftar, isEmpty);
expect(daftar, isNotEmpty);
expect(map, containsPair('key', 'value'));

// Exception / Future
expect(() => fungsiYangThrow(), throwsA(isA<ArgumentError>()));
await expectLater(futureYangGagal, throwsA(isA<NetworkException>()));
await expectLater(future, completion(equals('hasil')));

// Stream
await expectLater(stream, emits(42));
await expectLater(stream, emitsInOrder([1, 2, 3]));
await expectLater(stream, emitsDone);
await expectLater(stream, neverEmits(isNegative));

Ringkasan #

  • Unit test menguji satu unit logika secara terisolasi — tanpa UI, tanpa jaringan nyata, tanpa database asli. Berjalan dalam milidetik.
  • Struktur AAA: Arrange (siapkan kondisi) → Act (jalankan kode) → Assert (verifikasi hasil). Ikuti pola ini konsisten di setiap test.
  • Gunakan group() untuk mengelompokkan test yang berkaitan, dan setUp() untuk inisialisasi yang diulang sebelum setiap test.
  • mocktail adalah pilihan utama untuk mocking — tidak butuh code generation, API-nya intuitif. Gunakan when() untuk stub dan verify() untuk verifikasi.
  • Test AsyncNotifier Riverpod menggunakan ProviderContainer dengan overrideWithValue — tidak perlu widget tree. Gunakan container.read(provider.future) untuk menunggu async selesai.
  • Test Bloc/Cubit menggunakan bloc_test dengan blocTest() — deklarasikan act (method yang dipanggil) dan expect (sequence state yang diharapkan).
  • Kuasai matchers yang tersedia — isA<T>(), throwsA(), emitsInOrder(), dan completion() adalah yang paling sering digunakan dalam test async.

← Sebelumnya: Overview   Berikutnya: Widget Test →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact