Repository adalah titik di mana logika aplikasi bertemu dengan database nyata. Bug di layer ini — query salah, constraint dilanggar, migrasi rusak — hanya muncul saat SQL benar-benar dijalankan, bukan saat di-mock. Chapter ini memperkenalkan tiga strategi: H2 in-memory untuk test cepat sehari-hari, Testcontainers Postgres untuk test yang harus memverifikasi SQL Postgres-specific, dan migration test yang memastikan skema naik dari versi lama ke versi baru tanpa kehilangan data.
Tiga Strategi Database Testing #
| Strategi | Karakteristik | Cocok untuk |
|---|---|---|
| In-memory (Map) | Instan, tidak ada SQL | Unit test logika service |
| H2 in-memory | Cepat, SQL asli (dialek H2) | Integration test harian |
| Testcontainers Postgres | Lambat, SQL Postgres asli | Test pipeline CI, kasus kritis |
Pilihan strategi menentukan kepercayaan dan kecepatan test. Untuk development lokal, H2 menyeimbangkan keduanya. Untuk pipeline CI, Testcontainers memberikan kepastian tertinggi karena dialek SQL sama persis dengan produksi.
H2 in-memory #
H2 adalah database Java murni yang dapat berjalan dalam mode in-memory — data hilang saat JVM berakhir. Konfigurasi JDBC URL jdbc:h2:mem:test;DB_CLOSE_DELAY=-1 membuat koneksi tetap hidup selama JVM berjalan.
import java.sql.DriverManager
fun main() {
DriverManager.getConnection("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1").use { conn ->
conn.createStatement().use { stmt ->
stmt.execute("CREATE TABLE produk (id INT PRIMARY KEY, nama VARCHAR(100), harga INT)")
stmt.execute("INSERT INTO produk VALUES (1, 'Buku Kotlin', 75000)")
stmt.executeQuery("SELECT COUNT(*) FROM produk").use { rs ->
rs.next()
println("Jumlah produk: ${rs.getInt(1)}")
}
}
}
}Untuk demonstrasi runnable tanpa JDBC, berikut versi yang mengganti database dengan repository in-memory tetapi mempertahankan pola dan kontrak yang sama:
// database_testing.kt
// Mendemonstrasikan database testing dengan repository in-memory
// yang mensimulasikan skema SQL sederhana. Pola ini setara dengan
// H2 in-memory atau Testcontainers Postgres; perbedaannya hanya
// pada driver dan koneksi JDBC yang dipakai pada produksi.
data class Produk(val id: Int, val nama: String, val harga: Int, val stok: Int)
interface ProdukRepository {
fun insert(produk: Produk): Produk
fun findById(id: Int): Produk?
fun updateStok(id: Int, stokBaru: Int): Boolean
fun delete(id: Int): Boolean
fun semua(): List<Produk>
}
// Implementasi in-memory setara H2 :memory:
class InMemoryProdukRepository : ProdukRepository {
private val tabel = mutableMapOf<Int, Produk>()
private var urutan = 0
override fun insert(produk: Produk): Produk {
urutan++
val baru = produk.copy(id = urutan)
tabel[urutan] = baru
return baru
}
override fun findById(id: Int): Produk? = tabel[id]
override fun updateStok(id: Int, stokBaru: Int): Boolean {
require(stokBaru >= 0) { "Stok tidak boleh negatif" }
val existing = tabel[id] ?: return false
tabel[id] = existing.copy(stok = stokBaru)
return true
}
override fun delete(id: Int): Boolean = tabel.remove(id) != null
override fun semua(): List<Produk> = tabel.values.toList()
}
// Migration test: verifikasi skema naik dari v1 -> v2 dengan kolom baru.
class SkemaMigration {
var versi: Int = 1
private set
val kolom: MutableList<String> = mutableListOf("id", "nama", "harga")
fun migrateKe(target: Int) {
require(target > versi) { "Versi target harus > versi sekarang" }
if (target >= 2 && !kolom.contains("stok")) {
kolom.add("stok")
versi = 2
}
if (target >= 3 && !kolom.contains("kategori")) {
kolom.add("kategori")
versi = 3
}
}
}
fun main() {
// Test 1: insert & findById
val repo = InMemoryProdukRepository()
val p1 = repo.insert(Produk(0, "Buku Kotlin", 75000, 10))
val p2 = repo.insert(Produk(0, "Buku Coroutines", 95000, 5))
check(p1.id == 1 && p2.id == 2)
check(repo.findById(1)?.nama == "Buku Kotlin")
println("[LULUS] Insert & findById: ${repo.findById(1)}")
// Test 2: updateStok
val ok = repo.updateStok(1, 7)
check(ok && repo.findById(1)?.stok == 7)
println("[LULUS] updateStok(1, 7): ${repo.findById(1)}")
// Test 3: updateStok dengan nilai negatif harus menolak
val gagal = try {
repo.updateStok(2, -1)
false // seharusnya melempar
} catch (e: IllegalArgumentException) {
true
}
check(gagal)
println("[LULUS] updateStok(-1) menolak: IllegalArgumentException")
// Test 4: delete
val del = repo.delete(2)
check(del && repo.findById(2) == null)
println("[LULUS] delete(2): ${repo.semua().size} produk tersisa")
// Test 5: migration skema
val skema = SkemaMigration()
println()
println("Skema v${skema.versi}: kolom=${skema.kolom}")
skema.migrateKe(2)
println("Skema v${skema.versi}: kolom=${skema.kolom}")
skema.migrateKe(3)
println("Skema v${skema.versi}: kolom=${skema.kolom}")
check(skema.kolom.contains("stok") && skema.kolom.contains("kategori"))
println("[LULUS] Migration v1->v3 menambahkan kolom 'stok' & 'kategori'")
}Output yang Dihasilkan:
[LULUS] Insert & findById: Produk(id=1, nama=Buku Kotlin, harga=75000, stok=10)
[LULUS] updateStok(1, 7): Produk(id=1, nama=Buku Kotlin, harga=75000, stok=7)
[LULUS] updateStok(-1) menolak: IllegalArgumentException
[LULUS] delete(2): 1 produk tersisa
Skema v1: kolom=[id, nama, harga]
Skema v2: kolom=[id, nama, harga, stok]
Skema v3: kolom=[id, nama, harga, stok, kategori]
[LULUS] Migration v1->v3 menambahkan kolom 'stok' & 'kategori'Mari kita amati beberapa poin kunci pada file di atas:
InMemoryProdukRepository: setara H2 in-memory. TabelMutableMapmenyimpan state; saat JVM berakhir data hilang. Semua method SQL standar (insert, findById, update, delete) direpresentasikan.- Constraint validation:
updateStokmenolak nilai negatif denganIllegalArgumentException. Pada database nyata, ini bisa berupaCHECKconstraint atau validasi aplikasi. SkemaMigration: simulasi alur migration naik dari v1 (kolomid,nama,harga) ke v2 (tambahstok) ke v3 (tambahkategori). Migration test memverifikasi bahwa versi skema dapat dinaikkan tanpa kehilangan data.
Testcontainers Postgres #
Untuk test yang harus memverifikasi SQL Postgres-specific, Testcontainers menjalankan container Docker Postgres nyata. Karena butuh Docker daemon, kode berikut tidak dijalankan langsung — Testcontainers mengelola lifecycle container.
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.AfterAll
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.sql.DriverManager
@Testcontainers
class ProdukPgTest {
companion object {
@Container
val postgres = PostgreSQLContainer<Nothing>("postgres:16-alpine")
@BeforeAll
@JvmStatic
fun setupSchema() {
DriverManager.getConnection(postgres.jdbcUrl, postgres.username, postgres.password).use { c ->
c.createStatement().execute(
"CREATE TABLE produk (id SERIAL PRIMARY KEY, nama TEXT, harga INT, stok INT)"
)
}
}
}
@Test
fun `insert mengembalikan id`() {
DriverManager.getConnection(postgres.jdbcUrl, postgres.username, postgres.password).use { c ->
c.prepareStatement("INSERT INTO produk (nama, harga, stok) VALUES (?, ?, ?)").use { ps ->
ps.setString(1, "Buku Kotlin")
ps.setInt(2, 75000)
ps.setInt(3, 10)
val affected = ps.executeUpdate()
check(affected == 1)
}
}
}
}Migration Testing #
Migration test memverifikasi bahwa skema database dapat dinaikkan dari versi lama ke versi baru tanpa kehilangan data. Library seperti Flyway dan Liquibase mengelola skrip SQL migrasi yang berurutan.
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertTrue
class MigrationTest {
@Test
fun `migrasi v1 ke v3 berhasil`() {
val flyway = Flyway.configure()
.dataSource("jdbc:h2:mem:migtest", "sa", "")
.load()
val result = flyway.migrate()
assertTrue(result.success)
assertTrue(result.targetSchemaVersion == 3)
}
}Strategi Cleanup antar Test #
Saat memakai container nyata atau H2 yang dipakai bersama beberapa test, data uji harus dibersihkan antar test agar tidak terjadi kebocoran state. Tiga pendekatan umum:
| Pendekatan | Cara | Trade-off |
|---|---|---|
@Sql annotation | JUnit + Spring memuat SQL cleanup per test | Eksplisit tetapi verbose |
@Transactional rollback | Transaksi di-rollback otomatis di akhir test | Cepat, tetapi tidak menguji transaksi nyata |
@DirtiesContext | Restart application context per class | Sangat lambat, hindari kecuali darurat |
Pendekatan yang sering dipakai adalah @Transactional dengan rollback — Spring otomatis memulai transaksi sebelum test dan me-rollback setelah test selesai. Test berjalan cepat karena tidak ada commit nyata ke database, dan state bersih untuk test berikutnya. Pola ini cocok untuk repository test yang tidak menguji transaksi nyata.
Referensi Lanjutan #
- Testcontainers — dokumentasi Testcontainers untuk menjalankan database nyata dalam container saat integration test
- kotlin.test stdlib API
— rujukan assertion
kotlin.testyang dipakai untuk memverifikasi hasil query database
Selanjutnya #
Pengujian repository berkembang pesat ketika data uji banyak dan berulang. Chapter test-fixtures akan memperkenalkan pola fixture function, test data builder, dan @BeforeEach shared setup agar test tetap ringkas.