Best Practice #
Menulis tes otomatis yang dapat berjalan sukses di komputer lokal kita adalah satu hal. Namun, menulis kumpulan tes (test suite) yang bermakna, mudah dibaca oleh pengembang lain, dapat dieksekusi dengan cepat, bebas dari kegagalan acak (flakiness), dan tidak mudah rusak saat kita merapikan struktur kode adalah keahlian yang membedakan pengembang junior dengan arsitek perangkat lunak senior. Ketika tes otomatis dirancang dengan buruk, ia akan berubah menjadi beban yang memperlambat laju rilis fitur baru karena tim sibuk memperbaiki tes yang rusak akibat perubahan kosmetik visual.
Di dalam artikel penutup ini, kita akan merangkum praktik-praktik terbaik (best practices) pengujian di Flutter untuk skala produksi. Kita akan membahas pentingnya menguji perilaku (behavior) dibanding detail implementasi, pembagian asersi secara terfokus, teknik menstabilkan tes dari flakiness waktu menggunakan objek Clock tiruan, akselerasi tes asinkron dengan FakeAsync, strategi metrik cakupan (coverage) yang sehat, serta desain jalur integrasi kontinu (CI/CD pipeline).
1. Uji Perilaku (Behavior), Bukan Implementasi #
Kesalahan terbesar yang sering dilakukan saat mulai menulis tes adalah merancang tes yang mengikat diri pada detail implementasi internal suatu kelas. Detail implementasi merujuk pada bagaimana sebuah kelas melakukan pekerjaannya secara internal (misal: nama variabel privat, urutan pemanggilan fungsi privat, atau struktur penyimpanan data privat). Tes yang baik harus berfokus pada perilaku eksternal yang terlihat, yaitu apa hasil akhir yang diberikan untuk suatu input tertentu (Black Box Testing).
Jika kita menguji detail internal, ketika kita melakukan refactoring (merapikan kode tanpa mengubah hasil akhir), tes kita dijamin akan rusak. Hal ini memaksa kita membuang waktu untuk memperbarui kode tes yang sebenarnya tidak mengalami perubahan perilaku bisnis.
Berikut adalah ilustrasi perbedaan antara anti-pattern pengujian implementasi dengan pola pengujian perilaku yang benar:
// ANTI-PATTERN: Tes yang mengikat diri pada variabel privat internal
test('Harus mengisi daftar produk ke dalam cache internal setelah fetch', () async {
await repository.getProductsList();
// Mengakses field private _cachedProducts -- Jika nama variabel ini diubah saat refactor,
// tes akan langsung error meskipun aplikasi berjalan normal!
expect(repository._cachedProducts, isNotEmpty);
});
// BENAR: Tes yang memverifikasi perilaku dari luar
test('Panggilan kedua harus mengambil data dari cache lokal tanpa mengakses jaringan', () async {
// Susun stubbing
when(() => mockRemote.fetchProducts()).thenAnswer((_) async => listDummy);
when(() => mockLocal.getCachedProducts()).thenAnswer((_) async => listDummy);
// Jalankan panggilan pertama (data diambil dari remote dan dicache)
await repository.getProductsList();
// Jalankan panggilan kedua (data seharusnya diambil dari cache lokal)
await repository.getProductsList();
// Verifikasi perilaku: Pustaka remote hanya dipanggil tepat satu kali
verify(() => mockRemote.fetchProducts()).called(1);
verify(() => mockLocal.getCachedProducts()).called(2);
});
2. Satu Asersi per Tes (Fokus & Atomik) #
Setiap tes individu (test()) sebaiknya dirancang secara fokus untuk memverifikasi tepat satu skenario atau satu hasil asersi spesifik. Menjejalkan belasan asersi yang tidak berkaitan ke dalam satu fungsi tes akan menyulitkan proses debugging. Jika asersi baris kedua gagal, eksekusi tes akan langsung dihentikan, sehingga kita tidak pernah tahu apakah asersi di baris-baris berikutnya sebenarnya sukses atau gagal.
Untuk menjaga pengujian tetap bersih tanpa menulis ulang kode persiapan (Arrange) secara berulang-ulang, gunakan kombinasi group() dan setUp():
// ANTI-PATTERN: Banyak asersi dicampuradukkan dalam satu tes
test('Menguji hasil login', () async {
final result = await authRepository.login('[email protected]', 'pass123');
expect(result.user.name, equals('Budi'));
expect(result.accessToken, isNotEmpty);
expect(result.expiresIn, greaterThan(0));
expect(result.user.email, equals('[email protected]'));
});
// BENAR: Membagi ke dalam tes yang fokus & terperinci
group('Ketika login berhasil dilakukan', () {
late AuthResult result;
setUp(() async {
// Jalankan aksi satu kali sebelum masing-masing sub-test berjalan
result = await authRepository.login('[email protected]', 'pass123');
});
test('Harus mengembalikan objek profil pengguna yang sesuai', () {
expect(result.user.name, equals('Budi'));
expect(result.user.email, equals('[email protected]'));
});
test('Harus menyertakan token akses dengan masa berlaku yang valid', () {
expect(result.accessToken, isNotEmpty);
expect(result.expiresIn, greaterThan(0));
});
});
3. Dokumentasi Hidup: Nama Tes yang Deskriptif #
Nama tes adalah dokumentasi hidup dari kode kita. Ketika tes dijalankan di server CI/CD dan mengalami kegagalan, nama tes yang deskriptif akan langsung memberi tahu tim pengembang letak kesalahannya tanpa memaksa mereka membuka berkas kode tes secara manual.
Hindari penamaan tes yang malas seperti test 1, fungsi bagi, atau sukses. Gunakan format penamaan deklaratif yang menjelaskan skenario kondisi dan hasil yang diharapkan:
[Fungsi/Metode yang diuji] harus [Hasil Ekspektasi] ketika [Kondisi Skenario]
Perhatikan contoh perbandingan penamaan berikut:
- Buruk:
test('delete user', () => ...) - Baik:
test('deleteUser harus melempar NotFoundException ketika ID pengguna tidak terdaftar', () => ...) - Buruk:
test('hitung total', () => ...) - Baik:
test('calculateTotal harus memberikan potongan diskon 10% ketika total belanja melebihi 100.000', () => ...)
4. Menjamin Tes Deterministik (Bebas Flakiness) #
Tes yang flaky (tes yang kadang sukses dan kadang gagal saat dijalankan ulang tanpa ada perubahan kode) adalah musuh terbesar dalam pengujian otomatis. Hal ini membuat tim pengembang kehilangan kepercayaan pada hasil tes. Penyebab utama flakiness adalah ketergantungan pada status luar yang tidak stabil, seperti waktu nyata atau urutan eksekusi.
A. Menghindari Ketergantungan Waktu Nyata #
Jika logika aplikasi kita bergantung pada waktu (misalnya: token kedaluwarsa setelah 1 jam), jangan gunakan fungsi DateTime.now() secara langsung di dalam kode produksi. Solusi terbaik adalah menyuntikkan (inject) abstraksi kelas waktu (Clock) agar kita dapat mengendalikan waktu secara tiruan dalam pengujian.
// Abstraksi clock untuk dependency injection
abstract class Clock {
DateTime now();
}
class SystemClock implements Clock {
@override
DateTime now() => DateTime.now();
}
// Implementasi FakeClock untuk keperluan testing
class FakeClock implements Clock {
DateTime _currentTime;
FakeClock(this._currentTime);
@override
DateTime now() => _currentTime;
// Majukan waktu secara manual untuk simulasi
void advanceTime(Duration duration) {
_currentTime = _currentTime.add(duration);
}
}
Saat menguji logika token kedaluwarsa, kita cukup memajukan waktu pada FakeClock secara instan tanpa memicu penundaan fisik:
test('Token harus kedaluwarsa setelah melewati waktu 1 jam', () {
final fakeClock = FakeClock(DateTime(2026, 1, 1, 10, 0, 0));
final authService = AuthService(clock: fakeClock);
final token = authService.generateToken();
// Majukan waktu sejauh 1 jam 5 menit secara instan di memori RAM
fakeClock.advanceTime(const Duration(hours: 1, minutes: 5));
expect(authService.isTokenExpired(token), isTrue);
});
B. Menghindari Ketergantungan Antar Tes #
Setiap tes harus berjalan secara mandiri dan bersih dari sisa-sisa state (side effects) yang ditinggalkan oleh tes sebelumnya. Pastikan untuk selalu memanggil fungsi pembersihan pada metode setUp():
setUp(() {
// Bersihkan seluruh rekaman pemanggilan mock untuk menghindari interferensi
reset(mockProductRepository);
clearInteractions(mockProductRepository);
});
5. Mengoptimalkan Kecepatan dengan FakeAsync #
Dalam unit testing, menggunakan penundaan fisik menggunakan Future.delayed adalah dosa besar karena akan memperlambat waktu eksekusi secara akumulatif. Jika kita menguji logika sistem coba-kembali (retry logic) yang melakukan jeda 5 detik sebelum mencoba lagi, tes kita akan tertahan selama 5 detik nyata.
Untuk mengatasinya, gunakan paket fake_async. Paket ini membuat zona waktu virtual di mana kita dapat mempercepat aliran waktu asinkron secara instan menggunakan metode elapse().
Tambahkan dependensi di pubspec.yaml:
dev_dependencies:
fake_async: ^1.3.1
Implementasikan pengujian retry logic seperti berikut:
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('Harus memicu fungsi retry setelah jeda waktu 10 detik', () {
// Jalankan seluruh kode pengujian di dalam scope fakeAsync
fakeAsync((async) {
bool isRetryTriggered = false;
final connectionService = ConnectionService();
connectionService.connectWithRetry(
onRetry: () => isRetryTriggered = true,
delayDuration: const Duration(seconds: 10),
);
// Pastikan status belum terpicu di awal
expect(isRetryTriggered, isFalse);
// Majukan waktu virtual sejauh 11 detik secara instan
async.elapse(const Duration(seconds: 11));
// Asersi: Status sekarang harus sudah terpicu sukses
expect(isRetryTriggered, isTrue);
});
});
}
6. Isolasi Memori: Manfaat setUp dan tearDown #
Setiap kali kita membuka koneksi basis data tiruan, menginisialisasi ProviderContainer Riverpod, atau mendaftarkan Stream controller di dalam pengujian, objek-objek tersebut akan tetap menempati memori RAM setelah tes selesai jika tidak dibersihkan. Akumulasi memori ini dapat menyebabkan kebocoran memori (memory leaks) yang membuat proses pengujian di komputer lokal kita melambat.
Aturan emas yang wajib kita ikuti: selalu inisialisasi di setUp() dan bersihkan (dispose) di tearDown().
void main() {
group('Pengujian Logika Database', () {
late ProviderContainer container;
late AppDatabase database;
setUp(() {
// Inisialisasi resource bersih untuk masing-masing tes
database = AppDatabase(NativeDatabase.memory());
container = ProviderContainer(
overrides: [databaseProvider.overrideWithValue(database)],
);
});
tearDown(() async {
// Wajib bersihkan resource setelah masing-masing tes selesai
container.dispose();
await database.close();
});
test('Uji operasi...', () async {
// Tes berjalan dengan container dan database yang fresh...
});
});
}
7. Cakupan Kode (Coverage) yang Bermakna #
Mengejar metrik cakupan (test coverage) hingga 100% adalah kesia-siaan yang mengalihkan fokus pengembang dari kualitas tes yang sesungguhnya. Fokuskan pengujian pada area yang memberikan dampak proteksi tertinggi: logika perhitungan bisnis, perubahan status state, dan penanganan kesalahan (error handling).
Sebaliknya, kita harus secara aktif mengecualikan (exclude) berkas-berkas sampah atau berkas hasil generasi otomatis dari laporan coverage agar persentase cakupan merepresentasikan kualitas logika asli aplikasi kita.
Mengecualikan File Secara Inline #
Kita dapat menggunakan komentar khusus untuk mengecualikan baris atau berkas tertentu dari analisis coverage:
// coverage:ignore-file <-- Tempatkan di baris paling atas untuk mengabaikan seluruh isi file ini
import 'package:flutter/material.dart';
class GeneratedRouteFactory {
// Logika routing generated...
}
Mengecualikan File Menggunakan LCOV #
Saat membuat laporan cakupan di server CI/CD, kita dapat menghapus pola berkas generated menggunakan perintah filter lcov:
# Jalankan tes dan buat cakupan data
flutter test --coverage
# Hapus berkas generated (.g.dart, .freezed.dart, dll) dari laporan
lcov --remove coverage/lcov.info "lib/**/*.g.dart" "lib/**/*.freezed.dart" "lib/core/generated/*" -o coverage/cleaned_lcov.info
# Hasilkan visualisasi HTML dari laporan yang sudah bersih
genhtml coverage/cleaned_lcov.info -o coverage/html
8. Menghemat Waktu: Apa yang Tidak Perlu Diuji #
Menulis tes untuk baris kode yang tidak memiliki risiko kegagalan logika bisnis adalah pemborosan waktu pengembangan. Berikut adalah checklist elemen yang sebaiknya dilewatkan dari pengujian:
- Konstruktor Kelas & Properti Getter/Setter Sederhana:
class AppConfig { final String apiHost; AppConfig(this.apiHost); // Jangan buang waktu menulis tes untuk baris ini } - Jembatan Pihak Ketiga Tanpa Logika Tambahan:
class LoggerHelper { void logInfo(String msg) => debugPrint(msg); // Jangan ditest } - Inisialisasi Rute Navigasi Trivial: Deklarasi daftar rute GoRouter dasar tanpa ada filter keamanan token.
- Kode Kerangka Kerja Flutter: Kita tidak perlu menguji apakah widget
Textbawaan Flutter benar-benar merender teks ke layar; tim Flutter sudah menguji widget tersebut secara ketat di repositori mereka. Kita hanya perlu menguji apakah kita mengirimkan teks yang benar ke widget tersebut.
9. Alur Otomatisasi CI/CD Testing Pipeline #
Menulis tes tidak akan berguna jika tes tersebut tidak dijalankan secara disiplin. Jalur integrasi kontinu (CI/CD pipeline) menjamin bahwa setiap kode baru yang dikirimkan oleh tim pengembang wajib lolos seluruh tes sebelum diizinkan masuk ke cabang utama (main branch).
Berikut adalah rancangan alur kerja otomatisasi pengujian di server CI/CD:
graph TD
Trigger["Push / Pull Request ke Repository"] --> Linter["Jalankan Linter & Formatter"]
Linter -->|Lulus| RunTests["Jalankan Unit & Widget Test (flutter test)"]
RunTests -->|Lulus| Coverage["Analisis Test Coverage & Exclude Generated Files"]
Coverage -->|Threshold Terpenuhi| BuildApp["Build Versi Uji (patrol build)"]
BuildApp -->|Sukses| Integration["Jalankan Integration Test (patrol test) di Emulator"]
Integration -->|Lulus| Deploy["Aplikasi Siap di-Deploy ke Staging / Production"]
RunTests -->|Gagal| Fail["Picu Notifikasi Kegagalan Build di Slack/Email"]
Coverage -->|Gagal| Fail
Integration -->|Gagal| FailPola pipeline di atas memastikan tidak ada kode rusak yang tidak sengaja lolos ke tangan pengguna akhir di fase rilis produksi.
10. Checklist Peninjauan (Review Checklist) Pengujian #
Gunakan checklist praktis berikut saat melakukan peninjauan kode (pull request review) untuk memastikan kualitas kode pengujian tim kita memenuhi standar industri:
KUALITAS STRUKTUR & METRIK:
□ Nama tes menggunakan format deskriptif: [method] harus [hasil] ketika [kondisi].
□ Struktur pengujian mengikuti pola AAA (Arrange, Act, Assert) secara konsisten.
□ Seluruh kode tiruan (Mock) diisolasi hanya untuk dependensi luar (API, Database).
□ Berkas testing diletakkan di direktori test/ dengan jalur yang mencerminkan lib/.
KECEPATAN & KESTABILAN (ANTI-FLAKINESS):
□ Tidak ada penundaan fisik nyata (Future.delayed) di dalam unit/widget test.
□ FakeAsync digunakan untuk menguji logika penundaan waktu (retry/timeout).
□ Objek Clock tiruan digunakan jika logika bisnis bergantung pada DateTime.now().
□ Setiap tes bersifat mandiri dan tidak terpengaruh urutan eksekusi tes lain.
PEMBERSIHAN RESOURCE & LEAKS:
□ Semua instance database in-memory ditutup secara eksplisit di tearDown().
□ Objek ProviderContainer Riverpod di-dispose setelah selesai tes.
□ StreamController ditutup di tearDown() untuk mencegah kebocoran memori.
MIGRASI & INTEGRASI:
□ File-file buatan generator luar (*.g.dart) dikecualikan dari laporan coverage.
□ Integration test berfokus pada alur bisnis kritis (E2E), bukan detail UI minor.
□ Golden test menggunakan penanganan konsistensi rendering font untuk server CI/CD.
Ringkasan #
- Fokus Perilaku: Tes yang baik harus menguji fungsionalitas eksternal yang terlihat (behavior), bukan struktur internal variabel privat agar tes tidak mudah rusak saat refactoring.
- Asersi Atomik: Kelompokkan tes menggunakan
group()dansetUp()agar masing-masing unit tes hanya memverifikasi satu fokus asersi secara spesifik.- Anti-Flakiness: Hindari ketergantungan pada waktu nyata atau urutan eksekusi. Gunakan objek
Clocktiruan dan reset mock state di setiapsetUp().- Kecepatan Virtual: Gunakan paket
fake_asyncdan metodeelapse()untuk memajukan waktu asinkron secara instan tanpa memicu penundaan fisik yang lambat.- Pembersihan RAM: Biasakan untuk selalu membersihkan alokasi memori database atau container di dalam blok
tearDown()untuk mencegah kebocoran memori.- Metrik Sehat: Fokuskan metrik coverage pada bagian logika bisnis krusial, transisi state, dan error handling. Abaikan file generated (
*.g.dart) dari laporan cakupan.- Otomatisasi Disiplin: Rancang pipeline CI/CD yang otomatis mengeksekusi linter, unit test, widget test, dan integration test di setiap aktivitas push kode.
← Sebelumnya: Integration Test Berikutnya: Performance Profiling →