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:

kotlin
// 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:

text
=== 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 SUCCESSFUL

Mari kita bedah komponen utama:

  1. require(jumlah > 0) { "Jumlah harus positif" }: fungsi bawaan Kotlin yang melempar IllegalArgumentException jika kondisi false. Pesan dari lambda hanya dievaluasi saat gagal, sehingga hemat CPU pada kasus sukses. require ideal untuk validasi input parameter.
  2. object TestRunner dengan case, assertEquals, assertFailsWith: test runner mini yang meniru JUnit. Setiap case dijalankan dalam try/catch; assertion melempar AssertionError yang dicatat sebagai FAIL tanpa menghentikan suite.
  3. assertFailsWith { ... }: helper untuk menguji bahwa blok memang melempar exception. Mengembalikan Throwable agar pemanggil dapat memeriksa jenis dan pesan exception. Tanpa helper ini, melempar exception dalam test akan dianggap failure, padahal justru itu yang diharapkan.
  4. semua(): List<Transaksi> = daftar.toList(): toList() membuat salinan defensif. Test memverifikasi bahwa dua panggilan semua() mengembalikan instance berbeda dan perubahan internal setelahnya tidak memengaruhi snapshot sebelumnya. Pola ini mencegah bug kelas “ConcurrentModificationException”.
  5. object BuildConfig: cerminan BuildConfig.java yang dihasilkan Gradle otomatis. Saat buildType = "release", Gradle mengatur DEBUG = false dan menambahkan field seperti APPLICATION_ID, VERSION_CODE. Kode dapat membaca BuildConfig.DEBUG untuk mengubah perilaku (misalnya menonaktifkan logging di release).

Compose UI Test #

Selain unit test, Compose menyediakan createComposeRule() untuk UI test. Contoh pengujian alur tambah transaksi:

kotlin Contoh Compose UI Test
@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:

text proguard-rules.pro (ilustrasi)
-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:

kotlin signingConfigs 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 #

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.