Unit Test

Unit Test #

Unit test adalah investasi — bukan beban. Test yang ditulis dengan baik memungkinkan refactoring tanpa rasa takut, mendokumentasikan perilaku yang diharapkan, dan menangkap regresi sebelum sampai ke pengguna. Dart menyediakan ekosistem testing yang lengkap: package test untuk framework testing, mockito untuk mocking, dan tooling bawaan untuk mengukur code coverage. Artikel ini membahas cara menulis test yang benar-benar berguna — bukan sekadar test yang lulus, tapi test yang gagal dengan jelas saat ada yang salah dan memberi confidence saat semua hijau.

Filosofi Testing — FIRST #

Test yang baik mengikuti prinsip FIRST:

Fast     — setiap test berjalan dalam milidetik, bukan detik
Isolated — satu test tidak bergantung pada state dari test lain
Repeatable — hasil sama di manapun dijalankan (lokal, CI, berbeda OS)
Self-validating — lulus atau gagal otomatis, tidak butuh interpretasi manual
Timely   — ditulis sebelum atau bersamaan dengan kode produksi

Setup dan Struktur Proyek #

dart pub add dev:test dev:mockito dev:build_runner
# pubspec.yaml
dev_dependencies:
  test: ^1.25.0
  mockito: ^5.4.0
  build_runner: ^2.4.0

Struktur direktori test yang disarankan mencerminkan struktur lib:

lib/
  ├── src/
  │   ├── domain/
  │   │   ├── entitas/produk.dart
  │   │   └── layanan/hitung_diskon.dart
  │   └── data/
  │       └── repository/produk_repository.dart
test/
  ├── src/
  │   ├── domain/
  │   │   ├── entitas/produk_test.dart
  │   │   └── layanan/hitung_diskon_test.dart
  │   └── data/
  │       └── repository/produk_repository_test.dart
  └── helpers/
      └── test_fixtures.dart   ← data dummy bersama

Anatomi Test #

import 'package:test/test.dart';
import 'package:aplikasi_toko/src/domain/layanan/hitung_diskon.dart';

void main() {
  // group — mengelompokkan test yang terkait
  group('HitungDiskon', () {
    late HitungDiskon service;

    // setUp — dijalankan sebelum SETIAP test dalam group ini
    setUp(() {
      service = HitungDiskon();
    });

    // tearDown — dijalankan setelah SETIAP test dalam group ini
    tearDown(() {
      // bersihkan resource jika perlu
    });

    // setUpAll — dijalankan SEKALI sebelum semua test dalam group
    setUpAll(() async {
      // inisialisasi mahal: buka koneksi DB, load file besar
    });

    // tearDownAll — dijalankan SEKALI setelah semua test dalam group
    tearDownAll(() async {
      // tutup koneksi, hapus file temp
    });

    // test individual — satu perilaku spesifik per test
    test('mengembalikan 0 jika harga 0', () {
      expect(service.hitung(harga: 0, persen: 10), equals(0));
    });

    test('menghitung diskon 10% dengan benar', () {
      expect(service.hitung(harga: 100000, persen: 10), equals(10000));
    });

    test('melempar ArgumentError jika persen negatif', () {
      expect(
        () => service.hitung(harga: 100000, persen: -5),
        throwsA(isA<ArgumentError>()),
      );
    });
  });
}

Matcher — Semua yang Perlu Diketahui #

Package test menyediakan puluhan matcher. Memilih matcher yang tepat menghasilkan pesan error yang jauh lebih informatif saat test gagal.

Equality dan Comparison #

expect(hasil, equals(42));           // sama persis (== operator)
expect(nilai, same(referensi));      // referensi objek yang sama (identical)
expect(angka, isNot(equals(0)));     // negasi dari matcher lain
expect(teks, isNull);               // null
expect(teks, isNotNull);            // tidak null
expect(daftar, isEmpty);            // kosong
expect(daftar, isNotEmpty);         // tidak kosong

// Komparasi numerik
expect(nilai, greaterThan(10));
expect(nilai, lessThan(100));
expect(nilai, greaterThanOrEqualTo(10));
expect(nilai, closeTo(3.14, 0.01)); // toleransi ±0.01

// Tipe
expect(objek, isA<String>());       // instance dari tipe tertentu
expect(objek, isA<List<int>>());    // dengan generics

String Matchers #

expect(teks, contains('Dart'));
expect(teks, startsWith('Halo'));
expect(teks, endsWith('!'));
expect(teks, matches(RegExp(r'^\d{5}$'))); // cocok regex
expect(teks, hasLength(10));
expect(teks, equalsIgnoringCase('DART')); // case-insensitive

Collection Matchers #

expect(list, contains(42));
expect(list, containsAll([1, 2, 3]));
expect(list, containsAllInOrder([1, 2, 3])); // urutan penting
expect(list, hasLength(5));
expect(list, everyElement(greaterThan(0)));  // semua elemen memenuhi kondisi
expect(list, anyElement(greaterThan(100)));  // minimal satu elemen memenuhi

// Map
expect(map, containsPair('kunci', 'nilai'));
expect(map, containsValue('nilai'));

Exception Matchers #

// Exception spesifik
expect(() => bagi(10, 0), throwsA(isA<ArgumentError>()));

// Dengan pesan spesifik
expect(
  () => bagi(10, 0),
  throwsA(
    isA<ArgumentError>()
        .having((e) => e.message, 'message', contains('nol')),
  ),
);

// Shortcut untuk exception umum
expect(() => null!.toString(), throwsA(isA<TypeError>()));
expect(() => list[100], throwsRangeError);
expect(() => throw Exception(), throwsException);

// Memastikan TIDAK throw
expect(() => fungsiAman(), returnsNormally);

Custom Matcher dengan predicate #

// Matcher kustom dengan predicate
final adalahBilangan Prima = predicate<int>(
  (n) {
    if (n < 2) return false;
    for (int i = 2; i <= n ~/ 2; i++) {
      if (n % i == 0) return false;
    }
    return true;
  },
  'adalah bilangan prima',
);

expect(7, adalahBilanganPrima);
expect(4, isNot(adalahBilanganPrima));

Test Asinkron #

import 'package:test/test.dart';

Future<String> ambilData(String id) async {
  await Future.delayed(Duration(milliseconds: 100));
  if (id.isEmpty) throw ArgumentError('ID tidak boleh kosong');
  return 'Data-$id';
}

void main() {
  group('ambilData', () {
    // Test async — tandai test dengan async
    test('mengembalikan data untuk ID valid', () async {
      final hasil = await ambilData('U001');
      expect(hasil, equals('Data-U001'));
    });

    // Test exception dari async function
    test('melempar ArgumentError untuk ID kosong', () async {
      expect(
        () async => await ambilData(''),
        throwsA(isA<ArgumentError>()),
      );
    });

    // Test dengan timeout — gagal jika tidak selesai dalam 2 detik
    test('selesai dalam batas waktu', () async {
      final hasil = await ambilData('U002')
          .timeout(Duration(seconds: 2));
      expect(hasil, isNotNull);
    }, timeout: Timeout(Duration(seconds: 5)));
  });
}

Stream Matchers #

import 'package:test/test.dart';

Stream<int> hitungMundur(int dari) async* {
  for (int i = dari; i >= 0; i--) {
    await Future.delayed(Duration(milliseconds: 10));
    yield i;
  }
}

void main() {
  group('hitungMundur', () {
    test('mengeluarkan nilai yang benar', () {
      expect(
        hitungMundur(3),
        emitsInOrder([3, 2, 1, 0, emitsDone]), // urutan event + selesai
      );
    });

    test('mengeluarkan semua elemen', () {
      expect(
        hitungMundur(2),
        emitsInAnyOrder([0, 1, 2]), // tidak peduli urutan
      );
    });

    test('mengandung nilai tertentu', () {
      expect(
        hitungMundur(5),
        emitsThrough(3), // stream pada akhirnya emit 3
      );
    });

    test('error stream ditangkap', () {
      final errorStream = Stream<int>.error(StateError('test error'));
      expect(
        errorStream,
        emitsError(isA<StateError>()),
      );
    });
  });
}

Tagging dan Filtering Test #

Tag memungkinkan menjalankan subset test tertentu — berguna untuk memisahkan test cepat dari test lambat, atau unit test dari integration test:

import 'package:test/test.dart';

void main() {
  // Tag satu test
  test('test cepat', () {
    expect(1 + 1, 2);
  }, tags: 'unit');

  test('test dengan database', () async {
    // test yang butuh koneksi database
  }, tags: ['integration', 'slow']);

  // Tag seluruh group
  group('API tests', () {
    test('GET /produk', () async { /* ... */ });
    test('POST /produk', () async { /* ... */ });
  }, tags: 'integration');
}
# Jalankan hanya test dengan tag 'unit'
dart test --tags unit

# Skip test dengan tag 'slow'
dart test --exclude-tags slow

# Jalankan test berdasarkan nama
dart test --name "menghitung diskon"

# Jalankan file test tertentu
dart test test/domain/hitung_diskon_test.dart

# Jalankan test secara paralel (lebih cepat)
dart test --concurrency 4

Test yang Baik — Struktur AAA #

Setiap test mengikuti pola Arrange-Act-Assert (AAA) — memisahkan setup, eksekusi, dan verifikasi secara jelas:

test('layanan menghitung total belanja dengan benar', () {
  // Arrange — siapkan data dan dependensi
  final keranjang = Keranjang();
  keranjang.tambah(Produk(id: 'P1', nama: 'Laptop', harga: 15_000_000), qty: 1);
  keranjang.tambah(Produk(id: 'P2', nama: 'Mouse', harga: 250_000), qty: 2);
  final layanan = LayananBelanja(diskon: 0.10);

  // Act — jalankan kode yang diuji
  final total = layanan.hitungTotal(keranjang);

  // Assert — verifikasi hasil
  expect(total, equals(13_950_000)); // (15_000_000 + 500_000) * 0.9
});
// ANTI-PATTERN: satu test yang menguji banyak hal
test('semua fungsi kalkulator', () {
  final calc = Kalkulator();
  expect(calc.tambah(2, 3), 5);           // ✗ jika ini gagal...
  expect(calc.kurangi(5, 3), 2);          // ...ini tidak pernah dijalankan
  expect(calc.kali(4, 5), 20);
  expect(calc.bagi(10, 2), 5.0);
  // Satu test, empat assertion — sulit tahu mana yang gagal
});

// BENAR: satu test, satu perilaku
test('tambah mengembalikan jumlah dua angka', () {
  expect(Kalkulator().tambah(2, 3), equals(5));
});

test('kurangi mengembalikan selisih dua angka', () {
  expect(Kalkulator().kurangi(5, 3), equals(2));
});

test('bagi melempar ArgumentError jika penyebut nol', () {
  expect(() => Kalkulator().bagi(10, 0), throwsA(isA<ArgumentError>()));
});

Test Data — Fixtures dan Builders #

Untuk data yang digunakan di banyak test, buat fixtures terpusat:

// test/helpers/fixtures.dart
import 'package:aplikasi_toko/src/domain/entitas/produk.dart';
import 'package:aplikasi_toko/src/domain/entitas/pengguna.dart';

class Fixtures {
  static Produk produkLaptop() => Produk(
    id: 'P001',
    nama: 'Laptop Gaming',
    harga: 15_000_000,
    stok: 10,
  );

  static Produk produkMouse() => Produk(
    id: 'P002',
    nama: 'Gaming Mouse',
    harga: 250_000,
    stok: 50,
  );

  static Pengguna penggunaPremium() => Pengguna(
    id: 'U001',
    nama: 'Budi Santoso',
    email: '[email protected]',
    level: LevelPengguna.premium,
  );
}

// Penggunaan di test
test('pengguna premium mendapat diskon 20%', () {
  final pengguna = Fixtures.penggunaPremium();
  final produk = Fixtures.produkLaptop();
  final layanan = LayananDiskon();

  final diskon = layanan.hitungDiskon(pengguna, produk);

  expect(diskon, equals(3_000_000)); // 20% dari 15.000.000
});

Code Coverage #

Code coverage mengukur persentase baris kode yang dieksekusi oleh test. Target yang umum adalah 80%+ untuk logika bisnis, bukan untuk semua kode.

# Jalankan test dengan pengumpulan coverage
dart test --coverage coverage/

# Convert ke format LCOV (perlu package coverage)
dart pub global activate coverage
dart pub global run coverage:format_coverage \
  --packages=.dart_tool/package_config.json \
  --report-on=lib \
  --lcov \
  --in=coverage \
  --out=coverage/lcov.info

# Generate laporan HTML (perlu lcov terinstall di sistem)
genhtml coverage/lcov.info -o coverage/html

# Buka laporan
open coverage/html/index.html  # macOS
xdg-open coverage/html/index.html  # Linux
# analysis_options.yaml — konfigurasi linter untuk test
analyzer:
  exclude:
    - test/**   # test files tidak perlu beberapa lint rules

linter:
  rules:
    - test_types_in_equals  # pastikan matcher digunakan dengan benar
Code coverage 100% bukan tujuan. Coverage tinggi dengan test yang buruk lebih berbahaya dari coverage rendah dengan test yang baik — karena memberi false confidence. Prioritaskan coverage pada logika bisnis yang kompleks dan edge case, bukan pada getter sederhana atau boilerplate.

Menjalankan Test dalam CI #

# .github/workflows/test.yaml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dart-lang/setup-dart@v1
        with:
          sdk: stable

      - name: Install dependencies
        run: dart pub get

      - name: Verify formatting
        run: dart format --output=none --set-exit-if-changed .

      - name: Analyze code
        run: dart analyze --fatal-infos

      - name: Run tests
        run: dart test --coverage coverage/

      - name: Check coverage
        run: |
          dart pub global activate coverage
          dart pub global run coverage:format_coverage \
            --packages=.dart_tool/package_config.json \
            --report-on=lib \
            --lcov \
            --in=coverage \
            --out=coverage/lcov.info          

Anti-Pattern Unit Test #

Test yang Bergantung pada Urutan #

// ANTI-PATTERN: test ini bergantung pada state dari test sebelumnya
List<String> daftarGlobal = [];

test('tambah ke daftar', () {
  daftarGlobal.add('item'); // ✗ state ini bocor ke test berikutnya
  expect(daftarGlobal, hasLength(1));
});

test('daftar masih berisi item', () {
  expect(daftarGlobal, hasLength(1)); // ✗ bergantung pada test sebelumnya!
});

// BENAR: setiap test mandiri — inisialisasi sendiri
test('tambah ke daftar', () {
  final daftar = <String>[];  // state lokal
  daftar.add('item');
  expect(daftar, hasLength(1));
});

Test yang Tidak Menguji Perilaku Nyata #

// ANTI-PATTERN: test yang hanya memverifikasi implementation detail
test('service memanggil repository', () {
  final mockRepo = MockProdukRepository();
  final service = ProdukService(mockRepo);

  service.ambilProduk('P001');

  // ✗ hanya verifikasi method dipanggil, tidak verifikasi hasilnya
  verify(mockRepo.ambilById('P001')).called(1);
  // Test ini lulus meski service mengembalikan data yang salah!
});

// BENAR: verifikasi perilaku yang bermakna bagi pengguna
test('service mengembalikan produk yang benar', () async {
  final mockRepo = MockProdukRepository();
  final produkDiharapkan = Fixtures.produkLaptop();

  when(mockRepo.ambilById('P001'))
      .thenAnswer((_) async => produkDiharapkan);

  final service = ProdukService(mockRepo);
  final hasil = await service.ambilProduk('P001');

  // ✓ verifikasi HASIL yang relevan bagi pengguna
  expect(hasil?.nama, equals('Laptop Gaming'));
  expect(hasil?.harga, equals(15_000_000));
});

Ringkasan #

  • Struktur test mencerminkan struktur libtest/src/domain/x_test.dart untuk lib/src/domain/x.dart. Ini memudahkan navigasi dan memastikan semua modul punya test.
  • Satu test, satu perilaku — test yang menguji banyak hal sekaligus sulit di-debug saat gagal. Buat test terpisah untuk setiap skenario.
  • Pola AAA — Arrange (siapkan), Act (jalankan), Assert (verifikasi). Pisahkan ketiga bagian ini secara visual untuk keterbacaan.
  • Matcher yang spesifik menghasilkan pesan error yang lebih informatif — isA<ArgumentError>().having(...) jauh lebih berguna dari isA<Exception>().
  • setUp untuk inisialisasi per test, setUpAll untuk setup mahal yang dibagi seluruh group (koneksi database, file besar).
  • Stream matchers (emitsInOrder, emitsThrough, emitsDone) memungkinkan test Stream yang ekspresif tanpa perlu toList() terlebih dahulu.
  • Tagging (tags: 'unit') memungkinkan menjalankan subset test — pisahkan unit test (cepat) dari integration test (lambat) agar feedback loop tetap cepat.
  • Fixtures terpusat mencegah duplikasi data test — satu tempat untuk membuat test object yang konsisten dan mudah diperbarui.
  • Code coverage adalah alat, bukan tujuan — target 80%+ untuk logika bisnis, tapi jangan kejar 100% dengan menulis test trivial hanya untuk angka.
  • Test dalam CI memastikan tidak ada kode yang di-merge tanpa melewati test suite — tambahkan dart analyze dan dart format untuk kualitas kode yang konsisten.

← Sebelumnya: Web Server   Berikutnya: Mocking →

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