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 daricommonMain. - Tidak boleh memakai API spesifik platform (
java.io.File,android.util.Log). - Mengandalkan abstraksi interface dan
expect/actualuntuk 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:
// 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:
=== 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 frameworkMari kita bedah komponen utama:
TestRunner.case("validasi: input valid ...") { ... }: setiap test case dibungkus lambda yang dieksekusi dalamtry/catch. Assertion melemparAssertionErroryang dicatat sebagai FAIL tanpa menghentikan suite.TestRunner.assertEquals(expected, actual): assertion yang membandingkan denganequals.AssertionErrormembawa pesan dengan kedua nilai sehingga mudah di-debug.TestRunner.assertTrue(hasil.exceptionOrNull()?.message?.contains("Jumlah harus positif") == true): assertion kompleks yang memeriksa pesan exception. Pakai== truekarenacontainsmengembalikanBoolean?setelah chain null safety.- Sepuluh test case mencakup happy path dan edge case: validasi sukses, validasi gagal untuk tiga jenis galat (jumlah, kategori, tanggal), repo
simpandengan urutan ID, repohapusuntuk dua kasus (sukses dan tidak ditemukan). Cakupan ini melindungi terhadap regresi saat refactor. - Packaging matrix: library KMP menghasilkan artifact per target.
commonMainmenjadi metadata yang dipakai semua target;jvmMainmenghasilkan JAR;androidMainmenghasilkan AAR (Android Archive);iosMainmenghasilkan.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:
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 set | Artifact | Konsumen |
|---|---|---|
commonMain | metadata JAR (artifact kosong di beberapa versi) | Internal, dipakai compiler |
jvmMain | keuangan-jvm-1.0.0.jar | Aplikasi JVM/Gradle |
androidMain | keuangan-android-1.0.0.aar | Aplikasi Android |
iosX64Main, iosArm64Main, iosSimulatorArm64Main | keuangan-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):
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.testsebagai assertion lintas platform untuk test kodecommonMain
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.