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, dansetUp()untuk inisialisasi yang diulang sebelum setiap test.mocktailadalah pilihan utama untuk mocking — tidak butuh code generation, API-nya intuitif. Gunakanwhen()untuk stub danverify()untuk verifikasi.- Test
AsyncNotifierRiverpod menggunakanProviderContainerdenganoverrideWithValue— tidak perlu widget tree. Gunakancontainer.read(provider.future)untuk menunggu async selesai.- Test Bloc/Cubit menggunakan
bloc_testdenganblocTest()— deklarasikanact(method yang dipanggil) danexpect(sequence state yang diharapkan).- Kuasai matchers yang tersedia —
isA<T>(),throwsA(),emitsInOrder(), dancompletion()adalah yang paling sering digunakan dalam test async.