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,@ParameterizedTestsebagai 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,@MethodSourcemenghilangkan duplikasi test dengan input berbeda. Satu test, banyak data.@Nesteduntuk organisasi — kelompokkan test terkait dalam kelas inner dengan@Nested. Membuat output test lebih terstruktur dan mudah dibaca.runTestuntuk coroutine — gantikanrunBlockingdenganrunTestuntuk test coroutine.runTestmenggantidelay()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_gagallebih baik daritestPenarikan. Gunakan backtick untuk nama natural language di Kotlin.