Multiplatform library yang belum teruji adalah library yang siap mengecewakan konsumen di banyak platform sekaligus. Pada Bagian 4 ini kita menutup proyek KMP dengan dua langkah final: pengujian di commonTest yang berjalan identik di setiap target platform, dan packaging library ke artifact per platform (JAR untuk JVM, AAR untuk Android, klib untuk iOS). Pengujian commonTest adalah keuntungan utama KMP: tulis sekali, jalan di mana saja.

commonTest: Test Sekali, Jalan di Semua Platform #

Source set commonTest berisi test yang memakai kode commonMain. Saat Gradle menjalankan test untuk target JVM, kode commonTest + commonMain + jvmMain dikompilasi menjadi satu dan dijalankan di JVM. Saat untuk target iOS, kombinasi yang sama dikompilasi dengan Kotlin/Native dan dijalankan di simulator. Test yang sama, hasil yang sama.

Aturan commonTest:

  • Hanya boleh memakai kotlin.test (multiplatform) dan kode dari commonMain.
  • Tidak boleh memakai API spesifik platform (java.io.File, android.util.Log).
  • Mengandalkan abstraksi interface dan expect/actual untuk kebutuhan test.

Test Runner untuk commonTest #

Kode berikut mendemonstrasikan test runner untuk ValidatorTransaksi dan TransaksiRepoInMemory (kode commonMain dari Bagian 1 dan 2). Sepuluh test case berjalan identik di setiap platform target. Lalu, konsep packaging library dijelaskan:

kotlin
// Bagian 4: Testing dan packaging.
// Mendemonstrasikan test runner untuk commonMain code (berjalan di semua
// platform target) dan konsep packaging library via Gradle publish.

data class Transaksi(
    val id: Long,
    val jenis: String,
    val jumlah: Long,
    val kategori: String,
    val tanggalIso: String
)

object ValidatorTransaksi {
    private val ISO_RE = Regex("""^\d{4}-\d{2}-\d{2}$""")

    fun validasi(jenis: String, jumlah: Long, kategori: String, tanggalIso: String): Result<Transaksi> {
        if (jumlah <= 0) return Result.failure(IllegalArgumentException("Jumlah harus positif"))
        if (kategori.isBlank()) return Result.failure(IllegalArgumentException("Kategori wajib diisi"))
        if (!ISO_RE.matches(tanggalIso)) return Result.failure(IllegalArgumentException("Format tanggal harus ISO yyyy-MM-dd"))
        return Result.success(Transaksi(0, jenis, jumlah, kategori.trim(), tanggalIso))
    }
}

class TransaksiRepoInMemory {
    private val daftar = mutableListOf<Transaksi>()
    private var urutan = 0L

    fun simpan(t: Transaksi): Transaksi {
        urutan += 1
        val denganId = t.copy(id = urutan)
        daftar.add(denganId)
        return denganId
    }
    fun semua(): List<Transaksi> = daftar.toList()
    fun hapus(id: Long): Boolean = daftar.removeIf { it.id == id }
}

// === commonTest: test runner mini (mirror kotlin.test assertions) ===
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 assertFalse(v: Boolean, msg: String = "diharapkan false") { if (v) throw AssertionError(msg) }

    fun ringkasan() {
        println()
        println("========================================")
        println("Total: ${lulus + gagal} | Lulus: $lulus | Gagal: $gagal")
        if (failures.isNotEmpty()) println("Failures: $failures")
        println("========================================")
    }
}

fun main() {
    println("=== Test Suite (commonTest) ===")
    println("Berjalan identik di JVM, Android, iOS, JS\n")

    TestRunner.case("validasi: input valid mengembalikan success") {
        val hasil = ValidatorTransaksi.validasi("PEMASUKAN", 1_000_000, "Gaji", "2026-01-15")
        TestRunner.assertTrue(hasil.isSuccess)
        val t = hasil.getOrThrow()
        TestRunner.assertEquals("PEMASUKAN", t.jenis)
        TestRunner.assertEquals(1_000_000L, t.jumlah)
        TestRunner.assertEquals("Gaji", t.kategori)
    }

    TestRunner.case("validasi: jumlah negatif mengembalikan failure") {
        val hasil = ValidatorTransaksi.validasi("PEMASUKAN", -100, "Gaji", "2026-01-15")
        TestRunner.assertTrue(hasil.isFailure)
        val err = hasil.exceptionOrNull()
        TestRunner.assertTrue(err is IllegalArgumentException)
        TestRunner.assertTrue(err?.message?.contains("Jumlah harus positif") == true)
    }

    TestRunner.case("validasi: jumlah nol mengembalikan failure") {
        val hasil = ValidatorTransaksi.validasi("PEMASUKAN", 0, "Gaji", "2026-01-15")
        TestRunner.assertTrue(hasil.isFailure)
    }

    TestRunner.case("validasi: kategori kosong mengembalikan failure") {
        val hasil = ValidatorTransaksi.validasi("PEMASUKAN", 500_000, "", "2026-01-15")
        TestRunner.assertTrue(hasil.isFailure)
        TestRunner.assertTrue(hasil.exceptionOrNull()?.message?.contains("Kategori") == true)
    }

    TestRunner.case("validasi: kategori whitespace-only mengembalikan failure") {
        val hasil = ValidatorTransaksi.validasi("PEMASUKAN", 500_000, "   ", "2026-01-15")
        TestRunner.assertTrue(hasil.isFailure)
    }

    TestRunner.case("validasi: tanggal salah format mengembalikan failure") {
        val hasil = ValidatorTransaksi.validasi("PEMASUKAN", 500_000, "Gaji", "15-01-2026")
        TestRunner.assertTrue(hasil.isFailure)
        TestRunner.assertTrue(hasil.exceptionOrNull()?.message?.contains("ISO") == true)
    }

    TestRunner.case("validasi: kategori di-trim saat sukses") {
        val hasil = ValidatorTransaksi.validasi("PEMASUKAN", 500_000, "  Gaji  ", "2026-01-15")
        TestRunner.assertTrue(hasil.isSuccess)
        TestRunner.assertEquals("Gaji", hasil.getOrThrow().kategori)
    }

    TestRunner.case("repo: simpan menambah urutan ID") {
        val repo = TransaksiRepoInMemory()
        val t1 = repo.simpan(Transaksi(0, "PEMASUKAN", 100, "A", "2026-01-01"))
        val t2 = repo.simpan(Transaksi(0, "PEMASUKAN", 200, "B", "2026-01-02"))
        TestRunner.assertEquals(1L, t1.id)
        TestRunner.assertEquals(2L, t2.id)
        TestRunner.assertEquals(2, repo.semua().size)
    }

    TestRunner.case("repo: hapus menghapus dan mengembalikan true") {
        val repo = TransaksiRepoInMemory()
        repo.simpan(Transaksi(0, "PEMASUKAN", 100, "A", "2026-01-01"))
        val ok = repo.hapus(1)
        TestRunner.assertTrue(ok)
        TestRunner.assertEquals(0, repo.semua().size)
    }

    TestRunner.case("repo: hapus id tidak ada mengembalikan false") {
        val repo = TransaksiRepoInMemory()
        val ok = repo.hapus(999)
        TestRunner.assertFalse(ok)
    }

    TestRunner.ringkasan()

    println()
    println("=== Packaging konsep (KMP library) ===")
    println("Source set       Output")
    println("---------------- ----------------------------------")
    println("commonMain  -->  keuangan-common-metadata.jar")
    println("jvmMain     -->  keuangan-jvm-1.0.0.jar")
    println("androidMain -->  keuangan-android-1.0.0.aar")
    println("iosMain     -->  keuangan-iosX64-1.0.0.klib, keuangan-iosArm64-1.0.0.klib")
    println()
    println("Publish via Gradle:")
    println("  ./gradlew publishAllPublicationsToMavenLocalRepository")
    println()
    println("Konsumsi oleh aplikasi JVM:")
    println("  implementation(\"com.example:keuangan-jvm:1.0.0\")")
    println("Konsumsi oleh aplikasi Android:")
    println("  implementation(\"com.example:keuangan-android:1.0.0\")")
    println("Konsumsi oleh aplikasi iOS (Swift):")
    println("  import Keuangan  // via Objective-C framework")
}

Output yang Dihasilkan:

text
=== Test Suite (commonTest) ===
Berjalan identik di JVM, Android, iOS, JS

PASS  validasi: input valid mengembalikan success
PASS  validasi: jumlah negatif mengembalikan failure
PASS  validasi: jumlah nol mengembalikan failure
PASS  validasi: kategori kosong mengembalikan failure
PASS  validasi: kategori whitespace-only mengembalikan failure
PASS  validasi: tanggal salah format mengembalikan failure
PASS  validasi: kategori di-trim saat sukses
PASS  repo: simpan menambah urutan ID
PASS  repo: hapus menghapus dan mengembalikan true
PASS  repo: hapus id tidak ada mengembalikan false

========================================
Total: 10 | Lulus: 10 | Gagal: 0
========================================

=== Packaging konsep (KMP library) ===
Source set       Output
---------------- ----------------------------------
commonMain  -->  keuangan-common-metadata.jar
jvmMain     -->  keuangan-jvm-1.0.0.jar
androidMain -->  keuangan-android-1.0.0.aar
iosMain     -->  keuangan-iosX64-1.0.0.klib, keuangan-iosArm64-1.0.0.klib

Publish via Gradle:
  ./gradlew publishAllPublicationsToMavenLocalRepository

Konsumsi oleh aplikasi JVM:
  implementation("com.example:keuangan-jvm:1.0.0")
Konsumsi oleh aplikasi Android:
  implementation("com.example:keuangan-android:1.0.0")
Konsumsi oleh aplikasi iOS (Swift):
  import Keuangan  // via Objective-C framework

Mari kita bedah komponen utama:

  1. TestRunner.case("validasi: input valid ...") { ... }: setiap test case dibungkus lambda yang dieksekusi dalam try/catch. Assertion melempar AssertionError yang dicatat sebagai FAIL tanpa menghentikan suite.
  2. TestRunner.assertEquals(expected, actual): assertion yang membandingkan dengan equals. AssertionError membawa pesan dengan kedua nilai sehingga mudah di-debug.
  3. TestRunner.assertTrue(hasil.exceptionOrNull()?.message?.contains("Jumlah harus positif") == true): assertion kompleks yang memeriksa pesan exception. Pakai == true karena contains mengembalikan Boolean? setelah chain null safety.
  4. Sepuluh test case mencakup happy path dan edge case: validasi sukses, validasi gagal untuk tiga jenis galat (jumlah, kategori, tanggal), repo simpan dengan urutan ID, repo hapus untuk dua kasus (sukses dan tidak ditemukan). Cakupan ini melindungi terhadap regresi saat refactor.
  5. Packaging matrix: library KMP menghasilkan artifact per target. commonMain menjadi metadata yang dipakai semua target; jvmMain menghasilkan JAR; androidMain menghasilkan AAR (Android Archive); iosMain menghasilkan .klib (Kotlin library) untuk setiap arsitektur (iosX64, iosArm64, iosSimulatorArm64).

kotlin.test Multiplatform #

Di KMP asli, test memakai kotlin.test yang menyediakan assertion dan runner multiplatform:

kotlin commonTest dengan kotlin.test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ValidatorTransaksiTest {
    @Test
    fun `jumlah negatif mengembalikan failure`() {
        val hasil = ValidatorTransaksi.validasi("PEMASUKAN", -100, "Gaji", "2026-01-15")
        assertTrue(hasil.isFailure)
        assertTrue(hasil.exceptionOrNull() is IllegalArgumentException)
    }
}

kotlin.test mendukung JUnit 4 di JVM, JUnit 5 di JVM, XCTest di iOS, dan Node test runner di JS. Source code test sama persis; runner dipilih otomatis berdasarkan target.

Packaging Library #

Gradle dengan plugin org.jetbrains.kotlin.multiplatform menghasilkan artifact per target saat ./gradlew build:

Source setArtifactKonsumen
commonMainmetadata JAR (artifact kosong di beberapa versi)Internal, dipakai compiler
jvmMainkeuangan-jvm-1.0.0.jarAplikasi JVM/Gradle
androidMainkeuangan-android-1.0.0.aarAplikasi Android
iosX64Main, iosArm64Main, iosSimulatorArm64Mainkeuangan-iosX64-1.0.0.klib dst.Aplikasi iOS (via Xcode framework)

Untuk konsumsi iOS dari Swift, library KMP dapat dikemas sebagai Objective-C framework via binaries.framework. Swift meng-import framework ini dan memakai class Kotlin seperti class native Swift.

PUBLISH ke Maven Repository #

Untuk mendistribusi library, publish ke Maven repository (Maven Central, GitHub Packages, internal Nexus):

kotlin build.gradle.kts publishing
publishing {
    repositories {
        maven {
            url = uri("https://repo.example.com/maven")
            credentials {
                username = System.getenv("MAVEN_USER")
                password = System.getenv("MAVEN_PASSWORD")
            }
        }
    }
}

Setiap target menghasilkan publikasi terpisah: keuangan-jvm, keuangan-android, keuangan-iosX64, dst. Konsumen memilih publikasi sesuai platform yang dipakai.

Referensi Lanjutan #

  • Kotlin Multiplatform — halaman utama KMP untuk merancang pengujian dan distribusi aplikasi multiplatform
  • kotlin.test stdlib API — rujukan kotlin.test sebagai assertion lintas platform untuk test kode commonMain

Ringkasan Bagian 4 dan Penutup Proyek #

Pengujian dan packaging kini lengkap. TestRunner menjalankan sepuluh test di commonTest yang validasi validator dan repo. Packaging matrix menghasilkan artifact per target (JAR, AAR, klib). Library siap dipublish ke Maven dan dikonsumsi aplikasi di banyak platform.

Proyek Aplikasi Multiplatform telah selesai. Kita membangun shared domain logic di Bagian 1, multiplatform data layer di Bagian 2, platform-specific integration via expect/actual di Bagian 3, serta pengujian commonTest dan packaging di Bagian 4. Selanjutnya, kita beralih ke proyek Aplikasi Pemrosesan Data Asinkron yang memperkenalkan coroutines untuk pipeline data dengan concurrency dan retry.