Aplikasi yang berjalan sekali belum tentu benar. Pada Bagian 4 kita mengunci perilaku yang telah dibangun melalui unit test dengan kotlin.test, lalu memaketnya sebagai executable JAR sehingga dapat dijalankan tanpa lingkungan pengembangan. Pengujian dan packaging biasanya dianggap terakhir, tetapi sebenarnya menentukan apakah proyek dapat dipercaya.

Memisahkan Logika yang Dapat Diuji #

Kunci pengujian yang baik: logika bisnis tidak bergantung pada I/O eksternal. RepoTugas di bawah menyimpan daftar tugas di memori sehingga test berjalan cepat dan deterministik, tanpa menyentuh filesystem:

kotlin
// Bagian 4: Unit test dengan kotlin.test dan packaging sebagai executable JAR.
// Mendemonstrasikan struktur fungsi yang dapat diuji tanpa efek samping,
// pemisahan logika bisnis dari I/O, dan konvensi penamaan test.

data class Tugas(val id: Int, val judul: String, val selesai: Boolean = false)

object RepoTugas {
    private var urutanId = 0
    private val daftar = mutableListOf<Tugas>()

    fun reset() {
        urutanId = 0
        daftar.clear()
    }

    fun tambah(judul: String): Tugas {
        require(judul.length >= 3) { "Judul minimal 3 karakter" }
        urutanId += 1
        val tugas = Tugas(urutanId, judul.trim())
        daftar.add(tugas)
        return tugas
    }

    fun semua(): List<Tugas> = daftar.toList()

    fun tandaiSelesai(id: Int): Tugas? {
        val indeks = daftar.indexOfFirst { it.id == id }
        if (indeks == -1) return null
        val diperbarui = daftar[indeks].copy(selesai = true)
        daftar[indeks] = diperbarui
        return diperbarui
    }

    fun hapus(id: Int): Boolean = daftar.removeIf { it.id == id }
}

// Harness test sederhana tanpa framework, mensimulasikan apa yang akan
// ditulis dengan kotlin.test dalam proyek Gradle nyata.
object TestRunner {
    private var lulus = 0
    private var gagal = 0

    fun nama(nama: String, blok: () -> Unit) {
        try {
            blok()
            println("PASS  $nama")
            lulus++
        } catch (e: Throwable) {
            println("FAIL  $nama: ${e.message}")
            gagal++
        }
    }

    fun assertEquals(diharapkan: Any?, aktual: Any?) {
        if (diharapkan != aktual) {
            throw AssertionError("diharapkan <$diharapkan> aktual <$aktual>")
        }
    }

    fun assertNull(value: Any?) {
        if (value != null) throw AssertionError("diharapkan null, aktual <$value>")
    }

    fun assertTrue(value: Boolean) {
        if (!value) throw AssertionError("diharapkan true")
    }

    fun ringkasan() {
        println()
        println("Total: ${lulus + gagal} | Lulus: $lulus | Gagal: $gagal")
    }
}

fun main() {
    RepoTugas.reset()
    with(TestRunner) {
        nama("tambah menyimpan judul") {
            val t = RepoTugas.tambah("Belajar Kotlin")
            assertEquals(1, t.id)
            assertEquals("Belajar Kotlin", t.judul)
            assertTrue(!t.selesai)
        }
        nama("tambah menolak judul pendek") {
            var lempar = false
            try {
                RepoTugas.tambah("OK")
            } catch (e: IllegalArgumentException) {
                lempar = true
            }
            assertTrue(lempar)
        }
        nama("tandaiSelesai memperbarui status") {
            val t = RepoTugas.tandaiSelesai(1)
            assertEquals(true, t?.selesai)
        }
        nama("tandaiSelesai mengembalikan null untuk id tidak ada") {
            assertNull(RepoTugas.tandaiSelesai(99))
        }
        nama("hapus mengembalikan true saat ada") {
            assertTrue(RepoTugas.hapus(1))
        }
        nama("hapus mengembalikan false saat tidak ada") {
            assertEquals(false, RepoTugas.hapus(99))
        }
        ringkasan()
    }
}

Output yang Dihasilkan:

text
PASS  tambah menyimpan judul
PASS  tambah menolak judul pendek
PASS  tandaiSelesai memperbarui status
PASS  tandaiSelesai mengembalikan null untuk id tidak ada
PASS  hapus mengembalikan true saat ada
PASS  hapus mengembalikan false saat tidak ada

Total: 6 | Lulus: 6 | Gagal: 0

Mari kita bedah komponen utama:

  1. object RepoTugas: repository memori dengan state privat. Field daftar disembunyikan; pemanggil hanya dapat berinteraksi melalui fungsi publik.
  2. require(judul.length >= 3) { ... }: preconditon yang melempar IllegalArgumentException saat gagal. Tidak ditangkap karena menandakan bug pemanggilan, bukan kesalahan pengguna.
  3. daftar.toList(): mengembalikan snapshot imutabel. Pemanggil tidak dapat mengubah internal RepoTugas melalui list yang dikembalikan.
  4. t.copy(selesai = true): data class menyediakan copy() untuk membuat instance baru dengan satu field diubah, tanpa mengubah instance asli.
  5. TestRunner.nama("...") { ... }: harness sederhana yang menangkap exception dan menandai PASS/FAIL. Pada proyek Gradle nyata, kita memakai @Test dari kotlin.test dengan assertion yang sama.
  6. RepoTugas.reset(): dipanggil di awal main() untuk memastikan state bersih. Pada Gradle, @BeforeEach melakukan hal serupa.

Struktur Test dengan Gradle #

Pada proyek Gradle nyata, test ditulis dalam src/test/kotlin/ dengan anotasi @Test dari kotlin.test. Berkas build.gradle.kts yang khas terlihat seperti:

kotlin build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.21"
    application
}

dependencies {
    testImplementation(kotlin("test"))
}

application {
    mainClass.set("TugasKt")
}

tasks.jar {
    manifest { attributes("Main-Class" to "TugasKt") }
    from(configurations.runtimeClasspath.get().filter { it.name.endsWith("jar") }.map { zipTree(it) })
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

Konfigurasi di atas memakai plugin application yang menyediakan task run untuk menjalankan aplikasi dan task jar untuk membangun executable JAR. Baris from(...) menggabungkan dependensi runtime ke dalam JAR (fat JAR).

Pengujian Kasus Batas #

Test yang baik tidak hanya memeriksa jalur bahagia. Tiga kategori kasus yang harus dicakup:

  • Kasus normal: input valid, output sesuai harapan.
  • Kasus batas: ID terbesar, judul tepat pada batas minimal, daftar kosong.
  • Kasus galat: input tidak valid, ID tidak ada, file rusak.
kotlin Contoh test kasus batas dengan kotlin.test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class RepoTugasTest {
    @Test
    fun `tambah menerima judul tepat batas minimal`() {
        RepoTugas.reset()
        val t = RepoTugas.tambah("abc")
        assertEquals(3, t.judul.length)
    }

    @Test
    fun `tambah menolak judul di bawah batas`() {
        RepoTugas.reset()
        assertFailsWith<IllegalArgumentException> { RepoTugas.tambah("ab") }
    }
}

Konvensi penamaan test dengan backtick memungkinkan deskripsi panjang dalam Bahasa Indonesia yang langsung membaca seperti kalimat spesifikasi.

Packaging sebagai Executable JAR #

Packaging menggabungkan semua class yang dikompilasi menjadi satu file JAR dengan manifest yang menyebut titik masuk. Setelah packaging, pengguna cukup menjalankan:

bash
java -jar tugas.jar tambah "Belajar Kotlin"

Tidak perlu menginstal Kotlin compiler atau mengunduh dependensi tambahan, karena plugin application menggabungkan semua yang dibutuhkan ke dalam JAR. Distribusi menjadi satu berkas yang dapat dibagikan melalui USB, email, atau repositori internal.

Referensi Lanjutan #

Ringkasan Bagian 4 dan Penutup Proyek #

Pengujian dan packaging kini lengkap. RepoTugas dapat diuji tanpa I/O, harness TestRunner menunjukkan pola yang nantinya digantikan oleh kotlin.test, dan konfigurasi Gradle menghasilkan executable JAR yang siap dibagikan.

Proyek Aplikasi Command Line telah selesai. Kita membangun kerangka CLI pada Bagian 1, validasi terstruktur pada Bagian 2, penyimpanan JSON pada Bagian 3, dan pengujian serta packaging pada Bagian 4. Setiap Bagian menghasilkan program yang dapat dijalankan dan diuji sebelum melanjutkan, sehingga kesalahan dapat ditemukan lebih awal. Selanjutnya, kita akan naik ke proyek yang lebih kompleks: Sistem Manajemen Perpustakaan dengan domain modeling dan persistensi database.