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 #

StrategiKarakteristikCocok untuk
In-memory (Map)Instan, tidak ada SQLUnit test logika service
H2 in-memoryCepat, SQL asli (dialek H2)Integration test harian
Testcontainers PostgresLambat, SQL Postgres asliTest 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.

kotlin Contoh: H2 in-memory JDBC
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:

kotlin
// 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:

text
[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:

  1. InMemoryProdukRepository: setara H2 in-memory. Tabel MutableMap menyimpan state; saat JVM berakhir data hilang. Semua method SQL standar (insert, findById, update, delete) direpresentasikan.
  2. Constraint validation: updateStok menolak nilai negatif dengan IllegalArgumentException. Pada database nyata, ini bisa berupa CHECK constraint atau validasi aplikasi.
  3. SkemaMigration: simulasi alur migration naik dari v1 (kolom id, nama, harga) ke v2 (tambah stok) ke v3 (tambah kategori). 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.

kotlin Contoh: Testcontainers Postgres
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.

kotlin Contoh: Flyway migration test
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:

PendekatanCaraTrade-off
@Sql annotationJUnit + Spring memuat SQL cleanup per testEksplisit tetapi verbose
@Transactional rollbackTransaksi di-rollback otomatis di akhir testCepat, tetapi tidak menguji transaksi nyata
@DirtiesContextRestart application context per classSangat 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.test yang 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.