Unit Test

Unit Test #

Unit test adalah kode yang memverifikasi bahwa kode lain bekerja dengan benar — setiap fungsi, metode, dan kelas diuji secara terpisah dari dependensinya. Test yang baik bukan hanya “memastikan kode bekerja sekarang”, tapi juga menjadi dokumentasi hidup yang menjelaskan apa yang seharusnya dilakukan kode tersebut, dan safety net yang langsung berteriak ketika ada perubahan yang merusak perilaku yang sudah benar. Kotlin memiliki dua ekosistem testing utama: JUnit 5 (standar industri JVM, sangat didukung oleh semua tool) dan Kotest (dibuat khusus untuk Kotlin dengan sintaks yang lebih ekspresif). Artikel ini membahas keduanya secara mendalam, termasuk semua assertion yang tersedia, pola test yang baik, parameterized test, lifecycle, dan pengujian coroutine.

Setup Dependensi #

// build.gradle.kts
dependencies {
    // JUnit 5
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")

    // Kotest (opsional — alternatif atau pelengkap JUnit)
    testImplementation("io.kotest:kotest-runner-junit5:5.8.1")
    testImplementation("io.kotest:kotest-assertions-core:5.8.1")
    testImplementation("io.kotest:kotest-property:5.8.1")

    // Coroutine testing
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
}

tasks.test {
    useJUnitPlatform()  // wajib untuk JUnit 5 dan Kotest
}

JUnit 5 — Dasar #

Anatomi Test #

import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*

class KalkulatorTest {

    // Instansiasi objek yang diuji di sini
    private lateinit var kalkulator: Kalkulator

    @BeforeEach  // dijalankan sebelum SETIAP test
    fun setUp() {
        kalkulator = Kalkulator()
    }

    @AfterEach   // dijalankan setelah setiap test
    fun tearDown() {
        // bersihkan resource jika perlu
    }

    @BeforeAll   // dijalankan SEKALI sebelum semua test di kelas ini
    companion object {
        @JvmStatic
        @BeforeAll
        fun inisialisasiKelas() {
            println("Mempersiapkan kelas test")
        }
    }

    @Test
    fun `penjumlahan dua angka positif menghasilkan jumlah yang benar`() {
        // Arrange — siapkan data
        val a = 5
        val b = 3

        // Act — jalankan kode yang diuji
        val hasil = kalkulator.tambah(a, b)

        // Assert — verifikasi hasil
        assertEquals(8, hasil)
    }

    @Test
    @DisplayName("Pembagian dengan nol melempar ArithmeticException")
    fun bagi_denganNol_melemparException() {
        assertThrows<ArithmeticException> {
            kalkulator.bagi(10, 0)
        }
    }

    @Test
    @Disabled("Belum diimplementasikan — lihat tiket BUG-123")
    fun `fitur yang belum selesai`() {
        // test ini dilewati saat dijalankan
    }
}

Semua Assertion JUnit 5 #

// assertEquals dan assertNotEquals
assertEquals(5, 2 + 3)
assertEquals("Kotlin", bahasa)
assertNotEquals(0, hasil)

// assertTrue dan assertFalse
assertTrue(daftar.isNotEmpty())
assertFalse(pengguna.isAktif)

// assertNull dan assertNotNull
assertNull(ambilDariCache("kunci-tidak-ada"))
assertNotNull(ambilPengguna(1))

// assertThrows — verifikasi exception
val exception = assertThrows<IllegalArgumentException> {
    validasiUmur(-5)
}
assertEquals("Umur tidak boleh negatif", exception.message)

// assertDoesNotThrow — verifikasi tidak ada exception
assertDoesNotThrow {
    validasiUmur(25)
}

// assertAll — jalankan semua assertion, laporkan semua yang gagal sekaligus
// (bukan berhenti di assertion pertama yang gagal)
assertAll("properti produk",
    { assertEquals("Laptop", produk.nama) },
    { assertEquals(15_000_000.0, produk.harga) },
    { assertTrue(produk.stok >= 0) }
)

// assertIterableEquals — bandingkan list/iterable elemen per elemen
assertIterableEquals(
    listOf(1, 2, 3, 4, 5),
    hasil.sorted()
)

// assertTimeout — verifikasi eksekusi tidak melampaui batas waktu
assertTimeout(java.time.Duration.ofMillis(100)) {
    operasiYangHarusCepat()
}

Pola AAA — Arrange, Act, Assert #

Setiap test yang baik mengikuti pola tiga bagian: siapkan kondisi, jalankan kode yang diuji, verifikasi hasilnya. Pisahkan ketiganya dengan komentar atau baris kosong untuk keterbacaan:

// Kode yang akan diuji
class LayananDiskon {
    fun hitungHargaAkhir(harga: Double, persenDiskon: Int): Double {
        require(harga > 0) { "Harga harus positif" }
        require(persenDiskon in 0..100) { "Diskon harus antara 0 dan 100" }
        return harga * (1 - persenDiskon / 100.0)
    }
}

class LayananDiskonTest {
    private val layanan = LayananDiskon()

    @Test
    fun `diskon 20 persen pada harga 100000 menghasilkan 80000`() {
        // Arrange
        val harga = 100_000.0
        val diskon = 20

        // Act
        val hasilAkhir = layanan.hitungHargaAkhir(harga, diskon)

        // Assert
        assertEquals(80_000.0, hasilAkhir)
    }

    @Test
    fun `diskon 0 persen mengembalikan harga asli`() {
        val hasilAkhir = layanan.hitungHargaAkhir(50_000.0, 0)
        assertEquals(50_000.0, hasilAkhir)
    }

    @Test
    fun `diskon 100 persen mengembalikan 0`() {
        val hasilAkhir = layanan.hitungHargaAkhir(75_000.0, 100)
        assertEquals(0.0, hasilAkhir)
    }

    @Test
    fun `harga negatif melempar IllegalArgumentException`() {
        val exception = assertThrows<IllegalArgumentException> {
            layanan.hitungHargaAkhir(-100.0, 20)
        }
        assertTrue(exception.message!!.contains("positif"))
    }

    @Test
    fun `diskon di atas 100 melempar IllegalArgumentException`() {
        assertThrows<IllegalArgumentException> {
            layanan.hitungHargaAkhir(100_000.0, 150)
        }
    }
}

Parameterized Test — Satu Test, Banyak Data #

Parameterized test memungkinkan menjalankan test yang sama dengan berbagai input tanpa duplikasi kode:

import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.*

class ValidasiEmailTest {
    private val validator = ValidatorEmail()

    // ValueSource — untuk satu parameter
    @ParameterizedTest
    @ValueSource(strings = [
        "[email protected]",
        "[email protected]",
        "[email protected]"
    ])
    fun `email valid diterima`(email: String) {
        assertTrue(validator.isValid(email), "Seharusnya valid: $email")
    }

    @ParameterizedTest
    @ValueSource(strings = [
        "bukan-email",
        "tanpa-at-sign",
        "@tanpa-local",
        "spasi di [email protected]"
    ])
    fun `email tidak valid ditolak`(email: String) {
        assertFalse(validator.isValid(email), "Seharusnya tidak valid: $email")
    }

    // CsvSource — untuk banyak parameter
    @ParameterizedTest(name = "harga {0} diskon {1}% = {2}")
    @CsvSource(
        "100000, 0,   100000.0",
        "100000, 10,  90000.0",
        "100000, 50,  50000.0",
        "100000, 100, 0.0",
        "75000,  20,  60000.0"
    )
    fun `hitung diskon dengan berbagai input`(
        harga: Double,
        persen: Int,
        hasilDiharapkan: Double
    ) {
        val layanan = LayananDiskon()
        assertEquals(hasilDiharapkan, layanan.hitungHargaAkhir(harga, persen))
    }

    // MethodSource — untuk data kompleks
    @ParameterizedTest
    @MethodSource("sumberDataPengguna")
    fun `validasi pengguna dengan berbagai skenario`(
        nama: String,
        umur: Int,
        valid: Boolean,
        pesanError: String?
    ) {
        val validator = ValidatorPengguna()
        val hasil = validator.validasi(nama, umur)

        if (valid) {
            assertTrue(hasil.isSuccess)
        } else {
            assertTrue(hasil.isFailure)
            assertEquals(pesanError, hasil.exceptionOrNull()?.message)
        }
    }

    companion object {
        @JvmStatic
        fun sumberDataPengguna() = listOf(
            org.junit.jupiter.params.provider.Arguments.of("Budi", 25, true, null),
            org.junit.jupiter.params.provider.Arguments.of("", 25, false, "Nama tidak boleh kosong"),
            org.junit.jupiter.params.provider.Arguments.of("Sari", -1, false, "Umur tidak valid"),
            org.junit.jupiter.params.provider.Arguments.of("Ahmad", 200, false, "Umur tidak valid")
        )
    }
}

Nested Test — Struktur Hierarki #

@Nested memungkinkan pengelompokan test terkait dalam kelas inner, membuat test lebih terorganisir:

import org.junit.jupiter.api.Nested

class RekeningBankTest {
    private lateinit var rekening: RekeningBank

    @BeforeEach
    fun setUp() {
        rekening = RekeningBank("ACC-001", saldoAwal = 1_000_000.0)
    }

    @Nested
    @DisplayName("Operasi Setor")
    inner class Setor {
        @Test
        fun `setor positif menambah saldo`() {
            rekening.setor(500_000.0)
            assertEquals(1_500_000.0, rekening.saldo)
        }

        @Test
        fun `setor nol tidak mengubah saldo`() {
            assertThrows<IllegalArgumentException> { rekening.setor(0.0) }
        }

        @Test
        fun `setor negatif melempar exception`() {
            assertThrows<IllegalArgumentException> { rekening.setor(-100.0) }
        }
    }

    @Nested
    @DisplayName("Operasi Tarik")
    inner class Tarik {
        @Test
        fun `tarik dengan saldo cukup berhasil`() {
            rekening.tarik(300_000.0)
            assertEquals(700_000.0, rekening.saldo)
        }

        @Test
        fun `tarik melebihi saldo melempar exception`() {
            assertThrows<IllegalStateException> {
                rekening.tarik(2_000_000.0)
            }
        }

        @Test
        fun `tarik sampai nol berhasil`() {
            rekening.tarik(1_000_000.0)
            assertEquals(0.0, rekening.saldo)
        }
    }
}

Kotest — Alternatif yang Lebih Ekspresif #

Kotest menyediakan beberapa spec style yang bisa dipilih sesuai preferensi:

import io.kotest.core.spec.style.*
import io.kotest.matchers.*
import io.kotest.matchers.collections.*
import io.kotest.matchers.string.*
import io.kotest.assertions.throwables.*

// StringSpec — paling sederhana, satu string per test
class KalkulatorStringSpec : StringSpec({

    val kalkulator = Kalkulator()

    "penjumlahan dua angka positif menghasilkan jumlah yang benar" {
        kalkulator.tambah(2, 3) shouldBe 5
    }

    "pembagian dengan nol melempar ArithmeticException" {
        shouldThrow<ArithmeticException> {
            kalkulator.bagi(10, 0)
        }
    }
})

// BehaviorSpec — gaya BDD dengan given-when-then
class ProdukBehaviorSpec : BehaviorSpec({

    val layanan = LayananProduk()

    given("sebuah produk dengan stok tersedia") {
        val produk = Produk("Laptop", harga = 15_000_000.0, stok = 5)

        `when`("pengguna memesan 3 unit") {
            layanan.pesan(produk, jumlah = 3)

            then("stok berkurang menjadi 2") {
                produk.stok shouldBe 2
            }
        }

        `when`("pengguna mencoba memesan lebih dari stok") {
            then("exception dilempar") {
                shouldThrow<IllegalStateException> {
                    layanan.pesan(produk, jumlah = 10)
                }
            }
        }
    }
})

// DescribeSpec — mirip RSpec/Jest, cocok untuk developer yang datang dari frontend
class ValidatorDescribeSpec : DescribeSpec({

    describe("ValidasiEmail") {
        val validator = ValidatorEmail()

        describe("format yang valid") {
            it("menerima email standar") {
                validator.isValid("[email protected]") shouldBe true
            }
            it("menerima email dengan subdomain") {
                validator.isValid("[email protected]") shouldBe true
            }
        }

        describe("format yang tidak valid") {
            it("menolak email tanpa @") {
                validator.isValid("bukanemailcom") shouldBe false
            }
            it("menolak email tanpa domain") {
                validator.isValid("budi@") shouldBe false
            }
        }
    }
})

Matcher Kotest yang Kaya #

// String matchers
"Kotlin" shouldBe "Kotlin"
"Hello World" shouldContain "World"
"kotlin" shouldStartWith "kot"
"kotlin" shouldEndWith "lin"
"Kotlin 2024" shouldMatch Regex("""\w+ \d{4}""")
"  ".shouldBeBlank()
"teks".shouldNotBeBlank()

// Number matchers
42 shouldBe 42
3.14 shouldBeGreaterThan 3.0
10 shouldBeLessThan 20
5 shouldBeInRange 1..10

// Collection matchers
listOf(1, 2, 3) shouldHaveSize 3
listOf(1, 2, 3) shouldContain 2
listOf(1, 2, 3) shouldContainAll listOf(1, 3)
listOf(1, 2, 3) shouldNotContain 5
emptyList<Int>().shouldBeEmpty()
listOf(1).shouldNotBeEmpty()
listOf(1, 2, 3).shouldBeSorted()

// Nullable matchers
null.shouldBeNull()
"nilai".shouldNotBeNull()

// Exception matchers
shouldThrow<IllegalArgumentException> {
    throw IllegalArgumentException("test")
}.message shouldBe "test"

shouldNotThrowAny {
    val x = 1 + 1  // tidak ada exception
}

Pengujian Coroutine #

Untuk menguji fungsi suspend, gunakan runTest dari library kotlinx-coroutines-test:

import kotlinx.coroutines.test.*
import kotlinx.coroutines.*

class LayananDataTest {

    @Test
    fun `ambil data berhasil mengembalikan hasil`() = runTest {
        // runTest mengganti delay() dengan virtual time — test tetap cepat
        val layanan = LayananData()
        val hasil = layanan.ambilData()
        assertEquals("Data dari API", hasil)
    }

    @Test
    fun `delay dalam coroutine tidak membuat test lambat`() = runTest {
        val mulai = System.currentTimeMillis()

        // fungsi ini memanggil delay(5000) di dalamnya
        val hasil = operasiDenganDelay()

        val durasi = System.currentTimeMillis() - mulai
        assertEquals("selesai", hasil)
        assertTrue(durasi < 1000, "Test seharusnya selesai cepat dengan virtual time")
    }
}

suspend fun operasiDenganDelay(): String {
    delay(5000)  // dalam runTest, ini langsung selesai (virtual time)
    return "selesai"
}

TestDispatcher — Kontrol Waktu Virtual #

import kotlinx.coroutines.test.*

class CoroutineAdvancedTest {

    @Test
    fun `advanceTimeBy memajukan waktu virtual`() = runTest {
        val hasil = mutableListOf<Int>()

        launch {
            delay(1000)
            hasil.add(1)
        }
        launch {
            delay(2000)
            hasil.add(2)
        }
        launch {
            delay(500)
            hasil.add(3)
        }

        // Maju 600ms virtual time — hanya coroutine dengan delay 500ms yang selesai
        advanceTimeBy(600)
        assertEquals(listOf(3), hasil)

        // Maju lagi 500ms — coroutine dengan delay 1000ms selesai
        advanceTimeBy(500)
        assertEquals(listOf(3, 1), hasil)

        // Selesaikan semua coroutine yang tersisa
        advanceUntilIdle()
        assertEquals(listOf(3, 1, 2), hasil)
    }

    @Test
    fun `runCurrent menjalankan semua coroutine yang siap`() = runTest {
        var dijalankan = false

        launch { dijalankan = true }

        assertFalse(dijalankan)
        runCurrent()  // jalankan coroutine yang sudah siap
        assertTrue(dijalankan)
    }
}

Menguji Flow #

import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.*

class FlowTest {

    @Test
    fun `flow menghasilkan nilai yang benar`() = runTest {
        val flow = flow {
            emit(1)
            delay(100)
            emit(2)
            delay(100)
            emit(3)
        }

        val hasil = flow.toList()
        assertEquals(listOf(1, 2, 3), hasil)
    }

    @Test
    fun `stateFlow menyimpan nilai terbaru`() = runTest {
        val state = MutableStateFlow(0)

        state.value = 42
        assertEquals(42, state.value)

        state.update { it + 1 }
        assertEquals(43, state.value)
    }
}

Prinsip Test yang Baik #

FIRST — Panduan Menulis Test yang Berkualitas #

F — Fast (Cepat)
  Test harus berjalan dalam milidetik. Ratusan test harus selesai
  dalam hitungan detik, bukan menit. Hindari I/O nyata, sleep, atau
  operasi jaringan di unit test — gunakan mock atau virtual time.

I — Isolated (Terisolasi)
  Setiap test harus bisa berjalan sendiri, tidak bergantung pada
  test lain atau urutan eksekusi. State dari satu test tidak boleh
  mempengaruhi test lain.

R — Repeatable (Dapat Diulang)
  Test harus memberikan hasil yang sama setiap kali dijalankan,
  di mesin mana pun. Hindari ketergantungan pada waktu sistem,
  data eksternal, atau konfigurasi mesin.

S — Self-Validating (Memvalidasi Diri Sendiri)
  Test harus menentukan sendiri apakah ia lulus atau gagal — bukan
  dari output yang dibaca manual. Setiap test harus punya assertion.

T — Timely (Tepat Waktu)
  Tulis test sebelum atau bersamaan dengan kode produksi, bukan
  setelah bug ditemukan. Test yang ditulis terlambat sering kali
  tidak menguji kasus-kasus penting.

Penamaan Test yang Deskriptif #

// ANTI-PATTERN: nama tidak menjelaskan apa yang diuji
@Test
fun test1() { ... }

@Test
fun testAdd() { ... }

// BENAR: nama menjelaskan skenario dan hasil yang diharapkan
@Test
fun `penjumlahan dua angka positif menghasilkan jumlah yang benar`() { ... }

@Test
fun `ketika saldo tidak cukup, penarikan melempar InsufficientFundsException`() { ... }

@Test
fun `pengguna baru tanpa riwayat pembelian mendapat diskon selamat datang 10 persen`() { ... }

Satu Konsep per Test #

// ANTI-PATTERN: test menguji terlalu banyak hal sekaligus
@Test
fun `test kalkulator`() {
    assertEquals(5, kalkulator.tambah(2, 3))
    assertEquals(1, kalkulator.kurang(3, 2))
    assertEquals(6, kalkulator.kali(2, 3))
    assertEquals(2.0, kalkulator.bagi(6.0, 3.0))
    assertThrows<ArithmeticException> { kalkulator.bagi(1, 0) }
}

// BENAR: pisahkan menjadi test yang fokus
@Test fun `tambah 2 dan 3 menghasilkan 5`() { assertEquals(5, kalkulator.tambah(2, 3)) }
@Test fun `kurang 3 dari 5 menghasilkan 2`() { assertEquals(2, kalkulator.kurang(5, 3)) }
@Test fun `bagi dengan nol melempar exception`() { assertThrows<ArithmeticException> { kalkulator.bagi(1, 0) } }

Ringkasan #

  • JUnit 5 untuk standar industri — dukungan terluas dari semua tool: IDE, CI, Gradle, Maven. Gunakan @Test, assertEquals, assertThrows, @ParameterizedTest sebagai fondasi.
  • Kotest untuk expressiveness — matcher yang kaya (shouldBe, shouldContain, shouldThrow) dan beberapa spec style (StringSpec, BehaviorSpec, DescribeSpec). Bisa dikombinasikan dengan JUnit 5.
  • Pola AAA — setiap test mengikuti Arrange (siapkan data), Act (jalankan kode), Assert (verifikasi hasil). Pisahkan ketiganya dengan komentar atau baris kosong.
  • Parameterized test untuk banyak skenario@ValueSource, @CsvSource, @MethodSource menghilangkan duplikasi test dengan input berbeda. Satu test, banyak data.
  • @Nested untuk organisasi — kelompokkan test terkait dalam kelas inner dengan @Nested. Membuat output test lebih terstruktur dan mudah dibaca.
  • runTest untuk coroutine — gantikan runBlocking dengan runTest untuk test coroutine. runTest mengganti delay() dengan virtual time — test tetap cepat meski kode produksi punya delay detik.
  • Prinsip FIRST — Fast, Isolated, Repeatable, Self-validating, Timely. Test yang melanggar salah satu prinsip ini biasanya mengindikasikan masalah desain.
  • Nama test yang deskriptif — nama test adalah dokumentasi. ketika_saldo_tidak_cukup_penarikan_gagal lebih baik dari testPenarikan. Gunakan backtick untuk nama natural language di Kotlin.

← Sebelumnya: Web Server   Berikutnya: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact