Aplikasi yang berjalan di memori hilang saat proses berakhir. Pada Bagian 4 kita menambahkan persistensi ke file JSON agar data buku tetap ada. Sekalian, kita menulis unit test untuk logika peminjaman/pengembalian sehingga perubahan kode di masa depan dapat divalidasi dengan cepat.

Persistensi Sederhana #

File JSON menyimpan array berisi entri buku. Karena kita belum memakai library serialization, format dibuat minimal: ID, judul, dan stok. Field lain seperti penulis dan tahun terbit dapat ditambahkan dengan pola yang sama:

kotlin
// Bagian 4: Persistensi dan pengujian.
// Mendemonstrasikan persistensi ke JSON sederhana dan unit test harness
// untuk logika peminjaman/pengembalian.

import java.io.File

data class Buku(val id: String, val judul: String, val stok: Int)
data class Anggota(val id: String, val nama: String)
enum class StatusTransaksi { AKTIF, DIKEMBALIKAN }
data class Transaksi(
    val id: String,
    val bukuId: String,
    val anggotaId: String,
    val status: StatusTransaksi = StatusTransaksi.AKTIF
)

object Persistensi {
    fun simpanBuku(file: File, daftar: List<Buku>) {
        val isi = daftar.joinToString(",") { b ->
            "{\"id\":\"${b.id}\",\"judul\":\"${b.judul}\",\"stok\":${b.stok}}"
        }
        file.writeText("[$isi]")
    }

    private val RE = Regex("""\{"id":"([^"]+)","judul":"([^"]+)","stok":(\d+)\}""")
    fun muatBuku(file: File): List<Buku> {
        if (!file.exists()) return emptyList()
        return RE.findAll(file.readText()).map { m ->
            val (id, judul, stok) = m.destructured
            Buku(id, judul, stok.toInt())
        }.toList()
    }
}

class Perpustakaan {
    private val buku = mutableMapOf<String, Buku>()
    private val transaksi = mutableListOf<Transaksi>()
    private var urutan = 0

    fun tambahBuku(b: Buku) { buku[b.id] = b }
    fun stok(bukuId: String): Int = buku[bukuId]?.stok ?: 0

    fun pinjam(bukuId: String, anggotaId: String): Result<Transaksi> {
        val b = buku[bukuId] ?: return Result.failure(IllegalStateException("Buku tidak ditemukan"))
        if (b.stok <= 0) return Result.failure(IllegalStateException("Stok habis"))
        buku[bukuId] = b.copy(stok = b.stok - 1)
        urutan += 1
        val t = Transaksi("T%03d".format(urutan), bukuId, anggotaId)
        transaksi.add(t)
        return Result.success(t)
    }

    fun kembalikan(transaksiId: String): Result<Transaksi> {
        val idx = transaksi.indexOfFirst { it.id == transaksiId }
        if (idx == -1) return Result.failure(IllegalStateException("Tidak ditemukan"))
        if (transaksi[idx].status == StatusTransaksi.DIKEMBALIKAN)
            return Result.failure(IllegalStateException("Sudah dikembalikan"))
        val t = transaksi[idx].copy(status = StatusTransaksi.DIKEMBALIKAN)
        transaksi[idx] = t
        val b = buku[t.bukuId]!!
        buku[t.bukuId] = b.copy(stok = b.stok + 1)
        return Result.success(t)
    }
}

object Test {
    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(a: Any?, b: Any?) { if (a != b) throw AssertionError("diharapkan <$a> aktual <$b>") }
    fun assertTrue(v: Boolean) { if (!v) throw AssertionError("diharapkan true") }
    fun ringkasan() { println(); println("Total: ${lulus + gagal} | Lulus: $lulus | Gagal: $gagal") }
}

fun main() {
    val fileBuku = File("buku-demo.json")
    fileBuku.deleteOnExit()

    println("=== Persistensi buku ===")
    Persistensi.simpanBuku(fileBuku, listOf(
        Buku("B001", "Belajar Kotlin", 2),
        Buku("B002", "Pemrograman Modern", 1)
    ))
    println("Disimpan ke: ${fileBuku.name}")
    val dimuat = Persistensi.muatBuku(fileBuku)
    println("Dimuat: ${dimuat.size} buku")
    dimuat.forEach { println("  [${it.id}] ${it.judul} stok=${it.stok}") }
    fileBuku.delete()

    println()
    println("=== Unit test logika ===")
    val p = Perpustakaan()
    p.tambahBuku(Buku("B001", "Belajar Kotlin", 1))
    with(Test) {
        nama("pinjam mengurangi stok") {
            val t = p.pinjam("B001", "A001").getOrThrow()
            assertEquals(0, p.stok("B001"))
            assertEquals(StatusTransaksi.AKTIF, t.status)
        }
        nama("pinjam menolak stok habis") {
            val r = p.pinjam("B001", "A002")
            assertTrue(r.isFailure)
        }
        nama("kembalikan menambah stok") {
            val k = p.kembalikan("T001").getOrThrow()
            assertEquals(StatusTransaksi.DIKEMBALIKAN, k.status)
            assertEquals(1, p.stok("B001"))
        }
        nama("kembalikan menolak duplikat") {
            val r = p.kembalikan("T001")
            assertTrue(r.isFailure)
        }
        ringkasan()
    }
}

Output yang Dihasilkan:

text
=== Persistensi buku ===
Disimpan ke: buku-demo.json
Dimuat: 2 buku
  [B001] Belajar Kotlin stok=2
  [B002] Pemrograman Modern stok=1

=== Unit test logika ===
PASS  pinjam mengurangi stok
PASS  pinjam menolak stok habis
PASS  kembalikan menambah stok
PASS  kembalikan menolak duplikat

Total: 4 | Lulus: 4 | Gagal: 0

Mari kita bedah komponen utama:

  1. object Persistensi: menyatukan fungsi serialisasi dan deserialisasi. State RE (regex) dibuat sekali dan dipakai ulang.
  2. simpanBuku: joinToString(",") menggabungkan entri, lalu dibungkus [ dan ]. Hasilnya selalu array JSON valid.
  3. RE.findAll(file.readText()): findAll mengembalikan Sequence<MatchResult> yang efisien untuk file besar karena tidak memuat seluruh hasil ke memori.
  4. file.deleteOnExit(): JVM mendaftarkan file untuk dihapus saat program keluar. Berguna pada test agar tidak meninggalkan sampah.
  5. object Test: harness sederhana. Pada Gradle, ganti dengan @Test dari kotlin.test dan assertion yang sama.
  6. with(Test) { ... }: scope function with membuat assertion tersedia tanpa prefix Test.. Pola ini membuat test lebih ringkas dibaca.

Migrasi ke kotlin.test #

Pada proyek Gradle nyata, harness Test diganti dengan anotasi @Test dari kotlin.test. Struktur assertion tetap mirip:

kotlin Contoh test dengan kotlin.test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFailsWith

class PerpustakaanTest {
    private val p = Perpustakaan().apply {
        tambahBuku(Buku("B001", "Belajar Kotlin", 1))
    }

    @Test
    fun `pinjam mengurangi stok dan mengembalikan transaksi AKTIF`() {
        val t = p.pinjam("B001", "A001").getOrThrow()
        assertEquals(0, p.stok("B001"))
        assertEquals(StatusTransaksi.AKTIF, t.status)
    }

    @Test
    fun `kembalikan menolak pengembalian ganda`() {
        p.pinjam("B001", "A001").getOrThrow()
        p.kembalikan("T001").getOrThrow()
        assertTrue(p.kembalikan("T001").isFailure)
    }
}

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

Pola Test yang Baik #

Tiga kategori test yang harus dicakup:

  • Jalur bahagia: input valid, output sesuai harapan.
  • Kasus batas: stok 0, stok 1, transaksi pertama, transaksi terakhir.
  • Kasus galat: ID tidak ada, pengembalian ganda, stok negatif.

Test yang baik tidak bergantung pada urutan. Setiap @Test harus dapat berjalan terpisah; jika butuh state awal, gunakan @BeforeEach untuk reset.

Referensi Lanjutan #

Ringkasan Bagian 4 dan Penutup Proyek #

Persistensi JSON dan unit test kini lengkap. Persistensi menyimpan dan memuat buku dengan format yang konsisten, sementara harness Test (atau kotlin.test di Gradle) memvalidasi logika peminjaman dan pengembalian.

Proyek Sistem Manajemen Perpustakaan telah selesai. Kita membangun domain model di Bagian 1, operasi CRUD di Bagian 2, logika bisnis peminjaman di Bagian 3, dan persistensi serta pengujian di Bagian 4. Domain model Buku/Anggota/Transaksi sekarang dapat disimpan, dimuat, dan diuji. Selanjutnya, kita akan beralih ke proyek Aplikasi Keuangan Pribadi yang menambah konsep kategori, anggaran, dan analisis transaksi.