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 lib —
test/src/domain/x_test.dartuntuklib/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 dariisA<Exception>().setUpuntuk inisialisasi per test,setUpAlluntuk setup mahal yang dibagi seluruh group (koneksi database, file besar).- Stream matchers (
emitsInOrder,emitsThrough,emitsDone) memungkinkan test Stream yang ekspresif tanpa perlutoList()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 analyzedandart formatuntuk kualitas kode yang konsisten.