Setelah RepoBarangDb di Bagian 3 mampu menyimpan dan mencari barang di SQLite, kita menutup proyek inventaris dengan dua hal yang menjadi tolok ukur aplikasi bisnis: laporan agregat dan pengujian otomatis. Laporan memberi visi manajerial atas stok yang ada, sementara pengujian otomatis memastikan aturan bisnis seperti “stok tidak boleh negatif” tetap berfungsi setiap kali kode diubah. Pada bagian ini kita menambahkan fungsi totalNilaiStok dan stokRendah pada repo, lalu menulis test runner sederhana untuk memvalidasi perilaku keduanya.
Laporan yang Dihasilkan #
Dua laporan utama yang kita bangun pada bagian ini:
- Total nilai stok — penjumlahan
harga × stokuntuk seluruh barang. Memberi gambaran nilai uang yang tertanam di gudang. - Stok rendah — daftar barang dengan stok di bawah batas tertentu, untuk memicu pembelian ulang.
Kedua laporan memanfaatkan fungsi koleksi Kotlin (sumOf, filter) di atas data yang sudah dimuat dari database. Pendekatan ini sederhana dan cukup untuk dataset kecil hingga menengah. Untuk dataset besar, agregasi sebaiknya dilakukan di sisi database via SELECT SUM(harga * stok) agar tidak perlu memuat seluruh baris ke memori.
Repo dengan Laporan dan Test Suite #
Kode berikut memperluas RepoInventaris dari Bagian 2 dengan laporan agregat, sekaligus menampilkan test runner yang memverifikasi empat skenario inti: tambah barang, transaksi masuk, transaksi keluar, dan penolakan stok negatif.
// Bagian 4: Laporan stok dan pengujian.
// Mendemonstrasikan laporan ringkasan stok (total nilai, kurang stok)
// dan unit test untuk logika inventaris.
import java.sql.Connection
import java.sql.DriverManager
data class Barang(val sku: String, val nama: String, val harga: Long, val stok: Int)
class RepoInventaris(private val conn: Connection) {
init {
conn.createStatement().use { stmt ->
stmt.executeUpdate("""
CREATE TABLE IF NOT EXISTS barang (
sku TEXT PRIMARY KEY,
nama TEXT NOT NULL,
harga INTEGER NOT NULL,
stok INTEGER NOT NULL DEFAULT 0
)
""".trimIndent())
}
}
fun tambah(b: Barang): Boolean {
conn.prepareStatement("INSERT OR REPLACE INTO barang (sku, nama, harga, stok) VALUES (?,?,?,?)").use { ps ->
ps.setString(1, b.sku)
ps.setString(2, b.nama)
ps.setLong(3, b.harga)
ps.setInt(4, b.stok)
return ps.executeUpdate() > 0
}
}
fun semua(): List<Barang> {
val hasil = mutableListOf<Barang>()
conn.prepareStatement("SELECT sku, nama, harga, stok FROM barang ORDER BY sku").use { ps ->
ps.executeQuery().use { rs ->
while (rs.next()) {
hasil.add(Barang(
rs.getString("sku"), rs.getString("nama"),
rs.getLong("harga"), rs.getInt("stok")
))
}
}
}
return hasil
}
fun catatMasuk(sku: String, jumlah: Int): Boolean {
conn.prepareStatement("UPDATE barang SET stok = stok + ? WHERE sku = ?").use { ps ->
ps.setInt(1, jumlah)
ps.setString(2, sku)
return ps.executeUpdate() > 0
}
}
fun catatKeluar(sku: String, jumlah: Int): Boolean {
conn.prepareStatement("UPDATE barang SET stok = stok - ? WHERE sku = ? AND stok >= ?").use { ps ->
ps.setInt(1, jumlah)
ps.setString(2, sku)
ps.setInt(3, jumlah)
return ps.executeUpdate() > 0
}
}
fun totalNilaiStok(): Long = semua().sumOf { it.harga * it.stok }
fun stokRendah(batas: Int): List<Barang> = semua().filter { it.stok < batas }
}
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 formatRupiah(nilai: Long): String {
val str = nilai.toString()
return "Rp" + str.reversed().chunked(3).joinToString(".").reversed()
}
fun main() {
val conn = DriverManager.getConnection("jdbc:sqlite::memory:")
val repo = RepoInventaris(conn)
println("=== Setup data ===")
repo.tambah(Barang("SKU-001", "Buku Kotlin", 25_000, 10))
repo.tambah(Barang("SKU-002", "Pulpen", 3_500, 50))
repo.tambah(Barang("SKU-003", "Penggaris", 5_000, 3))
repo.tambah(Barang("SKU-004", "Spidol", 7_500, 0))
println("Total barang: ${repo.semua().size}")
println()
println("=== Laporan stok ===")
repo.semua().forEach { b ->
println("[${b.sku}] ${b.nama} ${formatRupiah(b.harga)} stok=${b.stok}")
}
println("Total nilai stok: ${formatRupiah(repo.totalNilaiStok())}")
println()
println("=== Stok rendah (< 5) ===")
repo.stokRendah(5).forEach { b ->
println("[${b.sku}] ${b.nama} stok=${b.stok} (rendah)")
}
println()
println("=== Test suite ===")
with(Test) {
nama("catatMasuk menambah stok") {
assertTrue(repo.catatMasuk("SKU-002", 10))
val b = repo.semua().first { it.sku == "SKU-002" }
assertEquals(60, b.stok)
}
nama("catatKeluar mengurangi stok") {
assertTrue(repo.catatKeluar("SKU-002", 5))
val b = repo.semua().first { it.sku == "SKU-002" }
assertEquals(55, b.stok)
}
nama("catatKeluar menolak stok tidak cukup") {
assertFalse(repo.catatKeluar("SKU-001", 999))
val b = repo.semua().first { it.sku == "SKU-001" }
assertEquals(10, b.stok)
}
nama("catatKeluar pada stok 0 gagal") {
assertFalse(repo.catatKeluar("SKU-004", 1))
val b = repo.semua().first { it.sku == "SKU-004" }
assertEquals(0, b.stok)
}
nama("totalNilaiStok = sum(harga * stok)") {
val data = repo.semua()
val manual = data.sumOf { it.harga * it.stok }
assertEquals(manual, repo.totalNilaiStok())
}
nama("stokRendah mengembalikan item di bawah batas") {
val rendah = repo.stokRendah(5)
assertTrue(rendah.any { it.sku == "SKU-003" })
assertTrue(rendah.any { it.sku == "SKU-004" })
}
ringkasan()
}
conn.close()
}
private fun Test.assertFalse(v: Boolean) { if (v) throw AssertionError("diharapkan false") }Output yang Dihasilkan:
=== Setup data ===
Total barang: 4
=== Laporan stok ===
[SKU-001] Buku Kotlin Rp25.000 stok=10
[SKU-002] Pulpen Rp3.500 stok=50
[SKU-003] Penggaris Rp5.000 stok=3
[SKU-004] Spidol Rp7.500 stok=0
Total nilai stok: Rp440.000
=== Stok rendah (< 5) ===
[SKU-003] Penggaris stok=3 (rendah)
[SKU-004] Spidol stok=0 (rendah)
=== Test suite ===
PASS catatMasuk menambah stok
PASS catatKeluar mengurangi stok
PASS catatKeluar menolak stok tidak cukup
PASS catatKeluar pada stok 0 gagal
PASS totalNilaiStok = sum(harga * stok)
PASS stokRendah mengembalikan item di bawah batas
Total: 6 | Lulus: 6 | Gagal: 0Mari kita bedah komponen kunci di atas:
totalNilaiStok(): Long = semua().sumOf { it.harga * it.stok }: ekspresi tunggal yang memuat seluruh barang lalu menjumlahkanharga × stok.sumOfadalah fungsi koleksi Kotlin yang menerima lambda selector dan mengembalikanLonguntuk kasus ini.stokRendah(batas: Int): memfilter barang denganstok < batas. Filter dijalankan di sisi Kotlin setelah data dimuat; untuk dataset besar, pindahkan ke klausaWHERE stok < ?di SQL.UPDATE barang SET stok = stok - ? WHERE sku = ? AND stok >= ?: klausaAND stok >= ?membuat operasi penarikan stok bersifat atomik. Jika stok kurang, pernyataan tidak mengubah baris manapun danexecuteUpdate()mengembalikan0.object Testdengannama(...)danringkasan(): test runner sederhana tanpa dependensi framework. Setiap blok dijalankan dalamtry/catch; assertion yang melemparAssertionErrordicatat sebagai FAIL tanpa menghentikan suite.private fun Test.assertFalse(...): dideklarasikan sebagai extension function di akhir berkas agar blokwith(Test) { ... }dapat memanggilnya tanpa kualifikasi penuh. Pola ini berguna untuk menambah helper pada object tanpa mengubah deklarasi object.
Mengapa Database In-Memory untuk Test #
Test di atas memakai DriverManager.getConnection("jdbc:sqlite::memory:"). Database berjalan di RAM, bukan file. Setiap koneksi :memory: mendapat database terpisah, sehingga test tidak mencemari data test lain. Keuntungan utama: test berjalan cepat (tanpa I/O disk) dan deterministik (state direset per eksekusi).
Untuk test yang perlu berbagi skema antar koneksi, SQLite mendukung jdbc:sqlite:file::memory:?cache=shared. Tapi untuk satu koneksi seperti kasus kita, :memory: polos sudah cukup.
Test Runner Mini vs kotlin.test #
Test runner object Test di atas adalah implementasi minimal untuk belajar. Produksi sebaiknya memakai kotlin.test atau JUnit 5 yang menyediakan:
- Annotation
@Test,@BeforeEach,@AfterEachuntuk setup/teardown, - Assertion yang kaya:
assertEquals(expected, actual),assertContains,assertFailsWith<Exception>, - Integrasi dengan Gradle test runner dan laporan HTML,
- Dukungan parameterized test untuk menjalankan kasus yang sama dengan input berbeda.
Untuk aplikasi sederhana, runner mini cukup. Untuk aplikasi yang berkembang, beralihlah ke kotlin.test.
Referensi Lanjutan #
- kotlin.test stdlib API
— rujukan
kotlin.testuntuk menulis test yang memverifikasi laporan inventaris - Kotlin Collections overview
— pengantar koleksi Kotlin untuk
groupBydansumOfpada laporan agregat inventaris
Ringkasan Bagian 4 dan Penutup Proyek #
Laporan agregat (totalNilaiStok, stokRendah) kini memberi visi manajerial atas data inventaris, sementara test runner memastikan aturan bisnis seperti penolakan stok negatif tetap berfungsi. Pengujian otomatis menjadi jaring pengaman saat kode diubah di masa depan.
Proyek Aplikasi Inventaris telah selesai. Kita membangun model Barang dan TransaksiStok di Bagian 1, transaksi masuk-keluar dengan validasi di Bagian 2, persistensi SQLite dengan pencarian LIKE di Bagian 3, dan laporan agregat serta pengujian di Bagian 4. Selanjutnya, kita beralih ke proyek Aplikasi Android Pencatat Keuangan yang memperkenalkan Jetpack Compose untuk membangun antarmuka pengguna modern di Android.