Best Practice #

Menulis test yang berjalan adalah satu hal. Menulis test yang bermakna, mudah dibaca, cepat dijalankan, dan tidak sering gagal tanpa alasan adalah seni tersendiri. Kumpulan best practice ini dibangun dari pola-pola yang membuat test suite menjadi aset yang membantu tim, bukan beban yang memperlambat.

1. Test Perilaku, Bukan Implementasi #

Test yang baik memverifikasi apa yang dilakukan kode, bukan bagaimana ia melakukannya. Test yang bergantung pada detail implementasi akan rusak setiap kali kamu refactor meskipun perilakunya tidak berubah.

// ANTI-PATTERN: test implementasi internal
test('_cachedProduk diisi setelah fetch', () async {
  await repository.getProduk();
  // Mengakses field private -- mengikat test ke detail implementasi!
  expect(repository._cachedProduk, isNotEmpty);
});

// BENAR: test perilaku yang terlihat dari luar
test('panggilan kedua tidak hit network jika cache valid', () async {
  when(() => mockRemote.getProduk()).thenAnswer((_) async => produkList);
  when(() => mockLocal.getCachedProduk()).thenAnswer((_) async => produkList);

  await repository.getProduk();  // panggilan pertama
  await repository.getProduk();  // panggilan kedua

  // Remote hanya dipanggil sekali -- cache digunakan di panggilan kedua
  verify(() => mockRemote.getProduk()).called(1);
});

2. Satu Assertion per Test (Idealnya) #

Satu test, satu hal yang diverifikasi. Jika test gagal, kamu langsung tahu apa yang salah tanpa harus membaca seluruh test.

// ANTI-PATTERN: banyak assertion yang tidak berkaitan
test('login', () async {
  final result = await authRepo.login('[email protected]', 'pass');
  expect(result.user.nama, 'Budi');            // assertion 1
  expect(result.accessToken, isNotEmpty);       // assertion 2
  expect(result.expiresIn, greaterThan(0));     // assertion 3
  expect(result.user.email, '[email protected]');  // assertion 4
  // Jika assertion 2 gagal, assertion 3 dan 4 tidak dijalankan
});

// BENAR: pisahkan menjadi test yang fokus
group('login berhasil', () {
  late AuthResult result;

  setUp(() async {
    result = await authRepo.login('[email protected]', 'pass');
  });

  test('mengembalikan user yang benar', () {
    expect(result.user.nama, 'Budi');
    expect(result.user.email, '[email protected]');
  });

  test('mengembalikan token yang valid', () {
    expect(result.accessToken, isNotEmpty);
    expect(result.expiresIn, greaterThan(0));
  });
});

3. Nama Test yang Deskriptif #

Nama test adalah dokumentasi. Ketika test gagal di CI, nama yang bagus langsung memberi tahu apa yang salah tanpa harus membuka file.

// ANTI-PATTERN: nama yang tidak informatif
test('test 1', () { ... });
test('getProduk', () { ... });
test('error', () { ... });

// BENAR: nama yang mendeskripsikan skenario dan ekspektasi
test('getProduk mengembalikan list kosong jika database kosong', () { ... });
test('getProduk throw NetworkException jika tidak ada koneksi', () { ... });
test('tambahProduk memanggil remote dan invalidate cache', () { ... });
test('hapusProduk dengan ID yang tidak ada throw NotFoundException', () { ... });

// Format yang baik: "[method/fitur] [kondisi] [ekspektasi]"
// atau: "[skenario] [hasil yang diharapkan]"

4. Test Harus Deterministik dan Tidak Flaky #

Test yang kadang lulus kadang gagal (flaky) lebih buruk dari tidak ada test — ia membuat tim tidak percaya pada hasil test.

// ANTI-PATTERN: bergantung pada waktu nyata
test('token expired setelah 1 jam', () async {
  final token = await authService.generateToken();
  await Future.delayed(const Duration(hours: 1));  // JANGAN! Sangat lambat
  expect(authService.isTokenExpired(token), isTrue);
});

// BENAR: mock waktu atau inject clock
class FakeClock implements Clock {
  DateTime _now;
  FakeClock(this._now);
  @override DateTime now() => _now;
  void advance(Duration duration) => _now = _now.add(duration);
}

test('token expired setelah 1 jam', () {
  final clock = FakeClock(DateTime(2024, 1, 1, 10, 0, 0));
  final authService = AuthService(clock: clock);
  final token = authService.generateToken();

  clock.advance(const Duration(hours: 1, minutes: 1));
  expect(authService.isTokenExpired(token), isTrue);
});

// ANTI-PATTERN: bergantung pada urutan test
// Test A menyimpan data ke storage yang dibaca Test B
// Jika Test B jalan duluan, ia gagal

// BENAR: setiap test bersih dari state test lain
setUp(() {
  // Reset semua mock dan state
  reset(mockRepo);
  clearInteractions(mockRepo);
});

5. Jaga Test Tetap Cepat #

Test yang lambat tidak akan dijalankan secara rutin. Target: seluruh unit test suite selesai dalam < 30 detik.

// ANTI-PATTERN: delay nyata dalam unit test
test('retry setelah 5 detik', () async {
  await Future.delayed(const Duration(seconds: 5));  // JANGAN!
  // ...
});

// BENAR: gunakan FakeAsync untuk kontrol waktu tanpa menunggu
import 'package:fake_async/fake_async.dart';

test('retry setelah 5 detik (dengan FakeAsync)', () {
  fakeAsync((async) {
    var retryCalled = false;

    RetryService().retry(
      onRetry: () => retryCalled = true,
      delay: const Duration(seconds: 5),
    );

    // Majukan waktu secara sinkron tanpa menunggu
    async.elapse(const Duration(seconds: 6));

    expect(retryCalled, isTrue);
  });
});

6. Isolasi Test dengan setUp dan tearDown #

Setiap test harus mulai dengan state yang bersih dan tidak meninggalkan state yang mempengaruhi test lain:

void main() {
  group('ProdukNotifier', () {
    late ProviderContainer container;
    late MockProdukRepository mockRepo;

    setUp(() {
      // Buat instance baru untuk SETIAP test
      mockRepo = MockProdukRepository();
      container = ProviderContainer(
        overrides: [produkRepositoryProvider.overrideWithValue(mockRepo)],
      );
    });

    tearDown(() {
      // Selalu dispose container untuk mencegah memory leak
      container.dispose();
    });

    // Setiap test punya container dan mock yang bersih
    test('test 1', () async {
      when(() => mockRepo.getProduk()).thenAnswer((_) async => []);
      final result = await container.read(produkProvider.future);
      expect(result, isEmpty);
    });

    test('test 2', () async {
      // Tidak terpengaruh oleh test 1
      when(() => mockRepo.getProduk()).thenAnswer((_) async => produkList);
      final result = await container.read(produkProvider.future);
      expect(result, equals(produkList));
    });
  });
}

7. Coverage yang Bermakna — Bukan Angka #

Coverage 100% tidak menjamin kode bebas bug. Fokus pada coverage yang bermakna:

Yang harus punya coverage tinggi (>80%):
  ✓ Logika bisnis (kalkulasi, validasi, transformasi)
  ✓ State transitions di notifier/bloc
  ✓ Error handling di repository
  ✓ Parsing dan serialisasi data

Yang tidak perlu obsesi coverage tinggi:
  ✗ Getter/setter sederhana
  ✗ Generated code (*.g.dart, *.freezed.dart)
  ✗ main.dart dan bootstrap code
  ✗ Model class tanpa logika (hanya data)

Cara exclude dari coverage report:
// Tambahkan komentar untuk exclude dari coverage
// coverage:ignore-file  -- exclude seluruh file
// coverage:ignore-line  -- exclude satu baris
// coverage:ignore-start / coverage:ignore-end  -- exclude blok

// lib/core/generated/router.gr.dart
// coverage:ignore-file  -- generated code tidak perlu ditest
# Konfigurasi coverage di flutter_test_coverage.yaml
# atau gunakan lcov --remove untuk exclude pattern

8. Apa yang Tidak Perlu Ditest #

// TIDAK PERLU DITEST:

// 1. Getter/setter trivial tanpa logika
class Produk {
  final String nama;
  String get displayNama => nama;  // tidak perlu ditest
}

// 2. Code yang hanya meneruskan ke library lain
class Logger {
  void log(String message) {
    debugPrint(message);  // tidak perlu test debugPrint
  }
}

// 3. Constructor tanpa logika
class AppConfig {
  final String baseUrl;
  const AppConfig({required this.baseUrl});  // tidak ada yang perlu ditest
}

// 4. Code third-party yang sudah ditest oleh maintainernya
// Jangan test behavior Riverpod atau Dio -- sudah ada test suite mereka sendiri

// PERLU DITEST:
// ✓ Validasi input yang kompleks
// ✓ Transformasi data (DTO → domain entity)
// ✓ State machine (semua transisi state)
// ✓ Edge case (nilai nol, list kosong, null)
// ✓ Error path yang mudah dilupakan

Anti-Pattern yang Harus Dihindari #

// ✗ Mock terlalu dalam -- mock semuanya termasuk yang tidak perlu
class TestTerlaloBanyakMock {
  void test() {
    final mockA = MockA();
    final mockB = MockB();
    final mockC = MockC();
    final mockD = MockD();
    // 10 mock hanya untuk test satu method sederhana
    // Sinyal: unit yang ditest terlalu besar dan kompleks
  }
}
// ✓ Jika butuh banyak mock, pertimbangkan refactor unit yang ditest

// ✗ Test yang bergantung satu sama lain
test('setup data', () async {
  await db.insert(produk);  // disimpan untuk test berikutnya
});
test('baca data', () async {
  final result = await db.getAll();  // berharap data dari test sebelumnya ada
  expect(result, isNotEmpty);        // bisa gagal jika dijalankan sendiri!
});
// ✓ Setiap test mandiri -- siapkan datanya sendiri di setUp atau dalam test

// ✗ Assertion yang tidak pernah gagal
test('hapus produk', () async {
  await repository.hapusProduk('123');
  // Tidak ada assertion! Test ini selalu lulus meski method throw exception
});
// ✓ Selalu verifikasi efek samping
test('hapus produk', () async {
  await repository.hapusProduk('123');
  verify(() => mockRemote.hapusProduk('123')).called(1);
  verify(() => mockLocal.clearCache()).called(1);
});

// ✗ Sleep di test
test('loading selesai', () async {
  await Future.delayed(const Duration(seconds: 2));  // JANGAN
  expect(find.byType(CircularProgressIndicator), findsNothing);
});
// ✓ Gunakan pumpAndSettle atau waitUntilGone
await tester.pumpAndSettle();
await $(#loadingIndicator).waitUntilGone();

Checklist Review Test #

KUALITAS TEST:
  □ Nama test deskriptif: "[skenario] [hasil yang diharapkan]"
  □ Setiap test punya minimal satu assertion yang bermakna
  □ Test menguji perilaku, bukan detail implementasi
  □ Tidak ada delay/sleep nyata -- gunakan FakeAsync atau mock waktu

ISOLASI:
  □ setUp() membuat instance baru untuk setiap test
  □ tearDown() membersihkan resource (dispose container, close db)
  □ Test tidak bergantung pada urutan eksekusi
  □ Tidak ada shared mutable state antar test

MOCK:
  □ Hanya mock dependency eksternal (network, database, storage)
  □ Tidak mock unit yang sedang ditest itu sendiri
  □ Stub hanya behavior yang relevan dengan test
  □ Verifikasi interaksi yang penting (verify, verifyNever)

WIDGET TEST:
  □ Semua state ditest: loading, error, dan data
  □ pumpWidget dibungkus MaterialApp (dan ProviderScope jika pakai Riverpod)
  □ pumpAndSettle() setelah interaksi yang trigger animasi/async
  □ Gunakan pumpApp() helper untuk menghindari duplikasi

INTEGRATION TEST:
  □ Hanya alur kritis yang ditest dengan integration test
  □ Page Object Model untuk memisahkan interaksi dari assertion
  □ Tidak ada assertion berdasarkan teks yang sering berubah
  □ Dijalankan di CI pada device/emulator yang konsisten

CI/CD:
  □ Unit dan widget test dijalankan di setiap PR/push
  □ Integration test dijalankan sebelum release atau nightly
  □ Hasil test di-report dan mudah dilihat
  □ Test yang flaky segera diidentifikasi dan diperbaiki

Ringkasan #

  • Test perilaku, bukan implementasi — test yang bergantung pada detail internal akan rusak saat refactor meski logika bisnis tidak berubah.
  • Satu assertion per test (idealnya) — memudahkan identifikasi apa yang gagal. Gunakan group() dan setUp() untuk berbagi setup tanpa duplikasi.
  • Nama test deskriptif: format "[skenario] [hasil yang diharapkan]" membuat laporan test CI mudah dibaca tanpa harus membuka kode.
  • Test harus deterministik — tidak boleh bergantung pada waktu nyata, urutan eksekusi, atau state yang ditinggalkan test lain. Gunakan FakeAsync untuk kontrol waktu.
  • Jaga test tetap cepat — unit test suite harus selesai < 30 detik. Test lambat tidak akan dijalankan secara konsisten.
  • Coverage bermakna — 100% coverage tidak berarti bebas bug. Fokus coverage pada logika bisnis, state transitions, dan error handling. Exclude generated code.
  • Hindari test yang tidak pernah gagal (assertion trivial), mock berlebihan (sinyal unit terlalu besar), dan Future.delayed di test (gunakan pumpAndSettle atau FakeAsync).
  • Integration test hanya untuk alur kritis — terlalu banyak integration test memperlambat CI dan lebih rentan flaky.

← Sebelumnya: Integration Test   Berikutnya: Performance Profiling →

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