Pengujian kode KMP memiliki kebutuhan khusus: kita ingin menulis test sekali dan menjalankannya di setiap target. Bila test ditulis per source set spesifik, kita harus menduplikasi test yang sama untuk JVM, Android, dan iOS—salah satu masalah yang KMP coba selesaikan. Solusinya adalah source set commonTest yang berisi test untuk kode commonMain. Test di sini memakai library kotlin.test yang abstraksi terhadap framework per platform—JUnit di JVM, instrumentation test di Android, XCTest di iOS. Pada chapter ini kita akan memahami struktur commonTest, melihat simulasi test yang sama dijalankan di tiga target, dan mempelajari pola test yang baik untuk kode multiplatform.

Struktur commonTest #

Source set commonTest berisi test yang memverifikasi kode commonMain. Source set androidUnitTest, iosX64Test, dan jvmTest berisi test spesifik platform—misalnya test yang memakai API java.io.File hanya boleh berada di jvmTest. Kompilator Kotlin me-resolve commonTest per target: test yang sama dijalankan dengan runner JUnit di JVM, runner instrumentation di Android, dan runner XCTest di iOS.

kotlin build.gradle.kts: konfigurasi commonTest
kotlin {
    sourceSets {
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test"))
                implementation(kotlin("test-annotations-common"))
            }
        }
        val androidUnitTest by getting {
            dependencies {
                implementation(kotlin("test-junit"))
            }
        }
    }
}

kotlin("test") adalah dependency meta yang di-resolve ke framework sesuai target. Di JVM menjadi JUnit 4 atau 5; di Android menjadi instrumentation runner; di iOS menjadi XCTest.

Library kotlin.test #

kotlin.test menyediakan assertion yang konsisten lintas platform: assertEquals(expected, actual), assertTrue(value), assertFailsWith<Exception> { ... }, dan assertNotNull(value). Assertion ini ditulis sekali dan berjalan di semua target tanpa modifikasi.

kotlin commonTest: contoh test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ValidatorEmailTest {
    @Test
    fun emailValid_diterima() {
        assertTrue(ValidatorEmail.valid("[email protected]"))
    }

    @Test
    fun emailTanpaDomain_ditolak() {
        assertTrue(!ValidatorEmail.valid("budi@"))
    }
}

Simulasi Test di Tiga Target #

Demo berikut mensimulasikan pola commonTest dengan framework test ringan buatan sendiri. Test suite yang sama—ValidatorEmail, ValidatorTelepon, HitungPajak—dijalankan oleh tiga runner yang berbeda untuk mensimulasikan JUnit, instrumentation runner, dan XCTest.

kotlin
// multiplatform_testing.kt
// commonTest source set: tulis test sekali, jalankan di setiap target.
// Demo: framework test ringan + eksekusi di tiga target tersimulasi.

// --- commonMain: kode yang akan diuji ---
data class Validasi(val aturan: List<(String) -> Boolean>) {
    fun periksa(input: String): List<String> {
        val hasil = mutableListOf<String>()
        for ((i, rule) in aturan.withIndex()) {
            if (!rule(input)) hasil.add("Aturan $i gagal untuk input: $input")
        }
        return hasil
    }
}

object ValidatorEmail {
    private val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
    fun valid(email: String): Boolean = regex.matches(email)
}

object ValidatorTelepon {
    fun valid(telp: String): Boolean =
        telp.all { it.isDigit() } && telp.length in 8..13
}

class HitungPajak(private val tarifPct: Double) {
    fun hitung(harga: Long): Long {
        require(harga >= 0) { "harga tidak boleh negatif" }
        return ((harga * tarifPct) / 100.0).toLong()
    }
}

// --- commonTest: framework test ringan (simulasi kotlin.test) ---
// kotlin.test menyediakan assertEquals, assertTrue, fail.
// Di sini kita implementasi sederhana dengan logger yang terhubung
// ke platform masing-masing (JvmTest, AndroidTest, IosTest).

data class HasilTest(val nama: String, val lulus: Boolean, val pesan: String)

class PengumpulTest(private val runner: String) {
    private val hasil = mutableListOf<HasilTest>()

    fun assertEquals(expected: Any?, actual: Any?, nama: String) {
        val ok = expected == actual
        hasil.add(HasilTest(nama, ok,
            if (ok) "" else "expected=$expected actual=$actual"))
    }

    fun assertTrue(value: Boolean, nama: String) {
        hasil.add(HasilTest(nama, value, if (value) "" else "expected true"))
    }

    fun assertThrows(aksi: () -> Unit, nama: String) {
        val thrown = try { aksi(); false } catch (e: Throwable) { true }
        hasil.add(HasilTest(nama, thrown, if (thrown) "" else "expected exception"))
    }

    fun cetakRingkasan(): List<HasilTest> {
        println("--- Runner: $runner ---")
        for (h in hasil) {
            val status = if (h.lulus) "PASS" else "FAIL"
            println("  [$status] ${h.nama}${if (h.pesan.isEmpty()) "" else " — ${h.pesan}"}")
        }
        val lulus = hasil.count { it.lulus }
        val gagal = hasil.count { !it.lulus }
        println("  Total: $lulus lulus, $gagal gagal")
        println()
        return hasil
    }
}

// --- commonTest: definisi test yang dipakai di semua target ---
fun jalankanSuite(runner: String, target: String): List<HasilTest> {
    val t = PengumpulTest(runner)

    // Test ValidatorEmail
    t.assertTrue(ValidatorEmail.valid("[email protected]"), "email valid budi")
    t.assertTrue(ValidatorEmail.valid("[email protected]"), "email valid siti")
    t.assertTrue(!ValidatorEmail.valid("bukan-email"), "email tolak plain text")
    t.assertTrue(!ValidatorEmail.valid("budi@"), "email tolak tanpa domain")

    // Test ValidatorTelepon
    t.assertTrue(ValidatorTelepon.valid("081234567890"), "telp valid 12 digit")
    t.assertTrue(!ValidatorTelepon.valid("08123"), "telp tolak 5 digit")
    t.assertTrue(!ValidatorTelepon.valid("08abc"), "telp tolak ada huruf")

    // Test HitungPajak
    val pajak = HitungPajak(11.0)
    t.assertEquals(11L, pajak.hitung(100), "pajak 11% dari 100 = 11")
    t.assertEquals(0L, pajak.hitung(0), "pajak 11% dari 0 = 0")
    t.assertEquals(1100L, pajak.hitung(10_000), "pajak 11% dari 10000 = 1100")
    t.assertThrows({ pajak.hitung(-1) }, "pajak negatif harus throws")

    // Test yang bergantung platform (mis. formatting angka)
    t.assertEquals(
        "11% dari 100",
        "11% dari 100",  // simulasi: format konsisten lintas platform
        "format string konsisten lintas platform"
    )

    println("=== Target: $target ===")
    return t.cetakRingkasan()
}

fun main() {
    println("=== commonTest: test yang sama, tiga target ===\n")

    val semuaHasil = mutableListOf<Pair<String, List<HasilTest>>>()
    for ((runner, target) in listOf(
        "JvmTestRunner" to "JVM (OpenJDK 21)",
        "AndroidTestRunner" to "Android 14 (API 34)",
        "IosTestRunner" to "iOS 17.2 (simulator)"
    )) {
        semuaHasil.add(target to jalankanSuite(runner, target))
    }

    // Ringkasan akhir
    println("=== Ringkasan akhir ===")
    var totalLulus = 0
    var totalGagal = 0
    for ((target, hasil) in semuaHasil) {
        val l = hasil.count { it.lulus }
        val g = hasil.count { !it.lulus }
        println("  $target: $l lulus, $g gagal")
        totalLulus += l
        totalGagal += g
    }
    println("  TOTAL: $totalLulus lulus, $totalGagal gagal")
}

Output yang Dihasilkan:

text
=== commonTest: test yang sama, tiga target ===

=== Target: JVM (OpenJDK 21) ===
--- Runner: JvmTestRunner ---
  [PASS] email valid budi
  [PASS] email valid siti
  [PASS] email tolak plain text
  [PASS] email tolak tanpa domain
  [PASS] telp valid 12 digit
  [PASS] telp tolak 5 digit
  [PASS] telp tolak ada huruf
  [PASS] pajak 11% dari 100 = 11
  [PASS] pajak 11% dari 0 = 0
  [PASS] pajak 11% dari 10000 = 1100
  [PASS] pajak negatif harus throws
  [PASS] format string konsisten lintas platform
  Total: 12 lulus, 0 gagal

=== Target: Android 14 (API 34) ===
--- Runner: AndroidTestRunner ---
  [PASS] email valid budi
  [PASS] email valid siti
  [PASS] email tolak plain text
  [PASS] email tolak tanpa domain
  [PASS] telp valid 12 digit
  [PASS] telp tolak 5 digit
  [PASS] telp tolak ada huruf
  [PASS] pajak 11% dari 100 = 11
  [PASS] pajak 11% dari 0 = 0
  [PASS] pajak 11% dari 10000 = 1100
  [PASS] pajak negatif harus throws
  [PASS] format string konsisten lintas platform
  Total: 12 lulus, 0 gagal

=== Target: iOS 17.2 (simulator) ===
--- Runner: IosTestRunner ---
  [PASS] email valid budi
  [PASS] email valid siti
  [PASS] email tolak plain text
  [PASS] email tolak tanpa domain
  [PASS] telp valid 12 digit
  [PASS] telp tolak 5 digit
  [PASS] telp tolak ada huruf
  [PASS] pajak 11% dari 100 = 11
  [PASS] pajak 11% dari 0 = 0
  [PASS] pajak 11% dari 10000 = 1100
  [PASS] pajak negatif harus throws
  [PASS] format string konsisten lintas platform
  Total: 12 lulus, 0 gagal

=== Ringkasan akhir ===
  JVM (OpenJDK 21): 12 lulus, 0 gagal
  Android 14 (API 34): 12 lulus, 0 gagal
  iOS 17.2 (simulator): 12 lulus, 0 gagal
  TOTAL: 36 lulus, 0 gagal

Mari kita bedah bagian kunci dari kode di atas:

  1. data class Validasi: kode di commonMain yang akan diuji. Berisi daftar aturan berupa lambda—kode murni Kotlin tanpa API platform. Inilah tipe kode yang cocok untuk commonTest.
  2. ValidatorEmail, ValidatorTelepon: validator bisnis yang berada di commonMain. Memakai Regex yang tersedia di semua target KMP. Test untuk kedua validator hanya perlu ditulis sekali.
  3. HitungPajak: class dengan require() untuk input validation. Test memverifikasi hasil perhitungan dan memastikan exception dilempar untuk input negatif.
  4. PengumpulTest: simulasi framework kotlin.test. Menyediakan assertEquals, assertTrue, assertThrows. Pada proyek nyata, runner ini digantikan JUnit (JVM), Espresso runner (Android), XCTest (iOS).
  5. jalankanSuite(runner, target): definisi test suite yang sama dijalankan untuk tiga runner. Inilah inti commonTest—kode test ditulis sekali, runner berbeda per target.
  6. Output menunjukkan tiga run identik: 12 test PASS di setiap target. Bila satu test gagal di satu target saja, kemungkinan ada bug spesifik platform—misalnya Locale formatting berbeda di iOS vs JVM.

Pola Test yang Baik untuk KMP #

Beberapa prinsip membantu menulis test KMP yang efektif. Pertama, letakkan test untuk kode commonMain di commonTest—tidak di androidUnitTest atau jvmTest. Kedua, hindari dependency ke library test spesifik platform di commonTest—misalnya Mockito hanya boleh di jvmTest. Ketiga, bila test membutuhkan resource platform (filesystem, database), pisahkan ke source set spesifik.

Test yang cocok di commonTestTest yang harus di source set spesifik
Validasi inputAkses filesystem
Perhitungan matematikaPanggilan network
Parsing stringUI rendering
Logika bisnisInteraksi database

Referensi Lanjutan #

  • Run tests — panduan menjalankan unit test pada tiap target KMP

Penutup #

commonTest adalah source set yang melengkapi KMP—kode test ditulis sekali, dijalankan di setiap target. Library kotlin.test menyediakan assertion yang konsisten lintas platform. Dengan pola ini, satu bug yang muncul di satu platform dapat ditemukan tanpa menulis test terpisah per target. Chapter berikutnya akan masuk ke topik library KMP: bagaimana membangun library untuk dipublikasi ke Maven Central.