Aplikasi yang belum teruji adalah aplikasi yang siap mengecewakan pengguna. Pada Bagian 4 ini kita menutup proyek Android dengan dua langkah krusial: pengujian otomatis untuk logika ViewModel dan build release untuk distribusi. Pengujian memastikan aturan bisnis seperti “jumlah harus positif” tetap berfungsi setiap kali kode diubah. Build release menghasilkan APK yang sudah diminifikasi, dioptimalkan, dan ditandatangani siap untuk Google Play atau distribusi langsung.
Dua Level Pengujian #
Pengujian Android terbagi dua level:
- Unit test (di
src/test/) — menguji logika murni tanpa dependensi Android. Berjalan di JVM lokal melalui JUnit, cepat, tidak butuh emulator. Cocok untuk ViewModel, validator, dan mapper. - UI test (di
src/androidTest/) — menguji interaksi pengguna dengan Compose. Berjalan di emulator atau peranti via Espresso/Compose Test. Lambat tetapi menangkap bug visual dan alur.
Untuk logika TransaksiViewModel, unit test sudah cukup. UI test akan ditambahkan saat UI Compose sudah stabil dan kita ingin memastikan alur tap-tambah-simpan-tampil benar-benar berfungsi.
Test Runner untuk ViewModel #
Kode berikut mendemonstrasikan test runner mini untuk TransaksiViewModel dan konsep BuildConfig untuk build release:
// Bagian 4: Pengujian unit dan release.
// Mendemonstrasikan unit test untuk logika ViewModel (saldo, agregasi)
// dan konsep build config untuk release APK.
enum class JenisTransaksi { PEMASUKAN, PENGELUARAN }
data class Transaksi(
val id: Long,
val jenis: JenisTransaksi,
val jumlah: Long,
val kategori: String,
val catatan: String
)
data class RingkasanKeuangan(
val totalPemasukan: Long,
val totalPengeluaran: Long
) {
val saldo: Long get() = totalPemasukan - totalPengeluaran
}
class TransaksiViewModel {
private val daftar = mutableListOf<Transaksi>()
private var urutan = 0L
fun tambah(jenis: JenisTransaksi, jumlah: Long, kategori: String, catatan: String): Transaksi {
require(jumlah > 0) { "Jumlah harus positif" }
require(kategori.isNotBlank()) { "Kategori tidak boleh kosong" }
urutan += 1
val t = Transaksi(urutan, jenis, jumlah, kategori, catatan)
daftar.add(t)
return t
}
fun semua(): List<Transaksi> = daftar.toList()
fun jumlah(): Int = daftar.size
fun ringkasan(): RingkasanKeuangan {
val masuk = daftar.filter { it.jenis == JenisTransaksi.PEMASUKAN }.sumOf { it.jumlah }
val keluar = daftar.filter { it.jenis == JenisTransaksi.PENGELUARAN }.sumOf { it.jumlah }
return RingkasanKeuangan(masuk, keluar)
}
}
// === Test runner mini (mirip JUnit) ===
object TestRunner {
private var lulus = 0
private var gagal = 0
private val failures = mutableListOf<String>()
fun case(nama: String, blok: () -> Unit) {
try {
blok()
println("PASS $nama")
lulus++
} catch (e: Throwable) {
println("FAIL $nama: ${e.message}")
failures.add(nama)
gagal++
}
}
fun assertEquals(expected: Any?, actual: Any?) {
if (expected != actual) {
throw AssertionError("diharapkan <$expected> aktual <$actual>")
}
}
fun assertTrue(v: Boolean, msg: String = "diharapkan true") {
if (!v) throw AssertionError(msg)
}
fun assertFailsWith(blok: () -> Unit): Throwable {
try {
blok()
throw AssertionError("diharapkan exception, tidak ada yang terlempar")
} catch (e: AssertionError) {
throw e
} catch (e: Throwable) {
return e
}
}
fun ringkasan() {
println()
println("========================================")
println("Total: ${lulus + gagal} | Lulus: $lulus | Gagal: $gagal")
if (failures.isNotEmpty()) {
println("Failures: $failures")
}
println("========================================")
}
}
// === Konsep build config untuk release ===
object BuildConfig {
const val APPLICATION_ID = "com.example.pencatatkeuangan"
const val VERSION_NAME = "1.0.0"
const val VERSION_CODE = 1
val DEBUG = false // release = false
val BUILD_TYPE = if (DEBUG) "debug" else "release"
}
fun main() {
println("=== Build Config ===")
println("applicationId : ${BuildConfig.APPLICATION_ID}")
println("versionName : ${BuildConfig.VERSION_NAME}")
println("versionCode : ${BuildConfig.VERSION_CODE}")
println("buildType : ${BuildConfig.BUILD_TYPE}")
println()
println("=== Test Suite: TransaksiViewModel ===")
TestRunner.case("tambah menambah daftar") {
val vm = TransaksiViewModel()
vm.tambah(JenisTransaksi.PEMASUKAN, 1_000_000, "Gaji", "Gaji Januari")
vm.tambah(JenisTransaksi.PENGELUARAN, 200_000, "Makan", "Makan siang")
TestRunner.assertEquals(2, vm.jumlah())
}
TestRunner.case("ringkasan menghitung saldo dengan benar") {
val vm = TransaksiViewModel()
vm.tambah(JenisTransaksi.PEMASUKAN, 5_000_000, "Gaji", "Gaji")
vm.tambah(JenisTransaksi.PENGELUARAN, 750_000, "Makan", "Makan")
vm.tambah(JenisTransaksi.PENGELUARAN, 1_250_000, "Transport", "Bensin")
val r = vm.ringkasan()
TestRunner.assertEquals(5_000_000L, r.totalPemasukan)
TestRunner.assertEquals(2_000_000L, r.totalPengeluaran)
TestRunner.assertEquals(3_000_000L, r.saldo)
}
TestRunner.case("saldo negatif jika pengeluaran lebih besar") {
val vm = TransaksiViewModel()
vm.tambah(JenisTransaksi.PENGELUARAN, 1_000_000, "Makan", "Makan siang")
TestRunner.assertEquals(-1_000_000L, vm.ringkasan().saldo)
}
TestRunner.case("tambah menolak jumlah nol") {
val vm = TransaksiViewModel()
val err = TestRunner.assertFailsWith {
vm.tambah(JenisTransaksi.PEMASUKAN, 0, "Gaji", "Gaji")
}
TestRunner.assertTrue(err is IllegalArgumentException, "harus IllegalArgumentException")
TestRunner.assertEquals(0, vm.jumlah())
}
TestRunner.case("tambah menolak kategori kosong") {
val vm = TransaksiViewModel()
TestRunner.assertFailsWith {
vm.tambah(JenisTransaksi.PEMASUKAN, 1_000, "", "catatan")
}
TestRunner.assertEquals(0, vm.jumlah())
}
TestRunner.case("semua mengembalikan salinan defensif") {
val vm = TransaksiViewModel()
vm.tambah(JenisTransaksi.PEMASUKAN, 1_000, "Gaji", "Gaji")
val list1 = vm.semua()
val list2 = vm.semua()
// Dua panggilan mengembalikan list terpisah (snapshot), tidak berbagi mutasi
TestRunner.assertTrue(list1 !== list2, "harus instance berbeda")
TestRunner.assertEquals(1, list1.size)
TestRunner.assertEquals(1, list2.size)
// Tambah setelah snapshot tidak mengubah list1
vm.tambah(JenisTransaksi.PEMASUKAN, 2_000, "Bonus", "Bonus")
TestRunner.assertTrue(list1.size == 1, "snapshot tidak boleh berubah")
TestRunner.assertEquals(2, vm.jumlah())
}
TestRunner.ringkasan()
println()
println("=== Simulasi build release ===")
println("Step 1: ./gradlew assembleRelease")
println("Step 2: ProGuard/R8 minimize + obfuscate")
println("Step 3: Sign APK dengan keystore release")
println("Step 4: Output: app/build/outputs/apk/release/app-release.apk")
println("Status: BUILD SUCCESSFUL")
}Output yang Dihasilkan:
=== Build Config ===
applicationId : com.example.pencatatkeuangan
versionName : 1.0.0
versionCode : 1
buildType : release
=== Test Suite: TransaksiViewModel ===
PASS tambah menambah daftar
PASS ringkasan menghitung saldo dengan benar
PASS saldo negatif jika pengeluaran lebih besar
PASS tambah menolak jumlah nol
PASS tambah menolak kategori kosong
PASS semua mengembalikan salinan defensif
========================================
Total: 6 | Lulus: 6 | Gagal: 0
========================================
=== Simulasi build release ===
Step 1: ./gradlew assembleRelease
Step 2: ProGuard/R8 minimize + obfuscate
Step 3: Sign APK dengan keystore release
Step 4: Output: app/build/outputs/apk/release/app-release.apk
Status: BUILD SUCCESSFULMari kita bedah komponen utama:
require(jumlah > 0) { "Jumlah harus positif" }: fungsi bawaan Kotlin yang melemparIllegalArgumentExceptionjika kondisi false. Pesan dari lambda hanya dievaluasi saat gagal, sehingga hemat CPU pada kasus sukses.requireideal untuk validasi input parameter.object TestRunnerdengancase,assertEquals,assertFailsWith: test runner mini yang meniru JUnit. Setiapcasedijalankan dalamtry/catch; assertion melemparAssertionErroryang dicatat sebagai FAIL tanpa menghentikan suite.assertFailsWith { ... }: helper untuk menguji bahwa blok memang melempar exception. MengembalikanThrowableagar pemanggil dapat memeriksa jenis dan pesan exception. Tanpa helper ini, melempar exception dalam test akan dianggap failure, padahal justru itu yang diharapkan.semua(): List<Transaksi> = daftar.toList():toList()membuat salinan defensif. Test memverifikasi bahwa dua panggilansemua()mengembalikan instance berbeda dan perubahan internal setelahnya tidak memengaruhi snapshot sebelumnya. Pola ini mencegah bug kelas “ConcurrentModificationException”.object BuildConfig: cerminanBuildConfig.javayang dihasilkan Gradle otomatis. SaatbuildType = "release", Gradle mengaturDEBUG = falsedan menambahkan field sepertiAPPLICATION_ID,VERSION_CODE. Kode dapat membacaBuildConfig.DEBUGuntuk mengubah perilaku (misalnya menonaktifkan logging di release).
Compose UI Test #
Selain unit test, Compose menyediakan createComposeRule() untuk UI test. Contoh pengujian alur tambah transaksi:
@RunWith(AndroidJUnit4::class)
class LayarDaftarTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun tapTambah_navigateKeForm() {
composeRule.setContent { LayarDaftar(vm = ..., onTambah = { navigasiDipanggil = true }) }
composeRule.onNodeWithText("Tambah").performClick()
assertTrue(navigasiDipanggil)
}
}UI test berjalan di emulator, lambat (beberapa detik per test), tetapi menangkap bug yang tidak terlihat di unit test: klik yang tidak terdaftar, teks yang salah terpampang, animasi yang menutup tombol.
Build Release dengan R8 #
Build release berbeda dari debug dalam tiga aspek:
- Minifikasi: R8 (pengganti ProGuard) menghapus class dan method yang tidak terpakai, mengurangi ukuran APK.
- Obfuscation: nama class dan method diubah menjadi pendek (misal
TransaksiViewModel→a.b.c), menyulitkan reverse engineering. - Optimasi: R8 menginline method, menghapus dead code, dan menyederhanakan kontrol alur.
Aktifkan dengan menambahkan minifyEnabled = true dan proguard-rules.pro di build.gradle.kts. Untuk library seperti Room dan Coroutines yang memakai reflection, tambahkan aturan keep di proguard-rules.pro agar tidak dihapus:
-keep class androidx.room.** { *; }
-keepclassmembers class * { @androidx.room.* <methods>; }
-keepclassmembers class kotlinx.coroutines.** { *; }Signing untuk Distribusi #
APK release harus ditandatangani sebelum distribusi. Gradle memakai keystore yang didefinisikan di build.gradle.kts:
signingConfigs {
create("release") {
storeFile = file("release.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = "release"
keyPassword = System.getenv("KEY_PASSWORD")
}
}Keystore adalah file berisi kunci privat. Jangan pernah commit keystore ke repository; simpan di tempat aman (CI secret, password manager). Jika keystore hilang, Anda tidak dapat memperbarui aplikasi di Google Play dengan package name yang sama.
Referensi Lanjutan #
- kotlin.test stdlib API
— rujukan
kotlin.testuntuk menulis test yang memverifikasi perilaku aplikasi sebelum rilis - Kotlin Multiplatform Mobile getting started — panduan KMP Mobile sebagai konteks pengujian dan rilis aplikasi Android Kotlin
Ringkasan Bagian 4 dan Penutup Proyek #
Pengujian dan build release kini lengkap. TestRunner menjalankan enam test yang memvalidasi logika TransaksiViewModel termasuk kasus galat. BuildConfig mendokumentasikan variabel yang diatur Gradle untuk release. R8 mengoptimalkan APK dan signing menandatangani dengan keystore.
Proyek Aplikasi Android Pencatat Keuangan telah selesai. Kita membangun antarmuka Compose di Bagian 1, state management dan navigasi di Bagian 2, database lokal via Room di Bagian 3, serta pengujian dan release di Bagian 4. Selanjutnya, kita beralih ke proyek Aplikasi Web Ktor yang memperkenalkan framework backend Ktor untuk membangun layanan web lengkap.