Lapisan akses data adalah titik integrasi paling penting sekaligus paling sulit diuji: query SQL hanya terbukti benar terhadap database sungguhan, tetapi database menyimpan state lintas test — dan state yang bocor antar test adalah resep kegagalan yang bergantung urutan. Pengujian database yang sehat menyelesaikan tiga masalah sekaligus: isolasi (setiap test melihat data yang sama di awal), kecepatan (suite tidak menit-nit), dan determinisme (hasil tidak bergantung data sisa). Pada Chapter ini kita akan mempelajari pola transaksi yang digulung per test, strategi penyiapan data yang bersih, peran database in-memory untuk suite cepat, dan cara menjaga data uji tetap deterministik.
Tiga Tantangan Pengujian Database #
Tantangan pertama adalah state bersama. Test yang menambah baris tanpa membersihkan membuat test berikutnya membaca data yang tidak diharapkannya; suite lulus di komputer satu orang dan gagal di komputer orang lain hanya karena urutan eksekusi berbeda. Tantangan kedua adalah kecepatan: membuka koneksi, menjalankan DDL, dan mengisi ulang tabel untuk setiap test menghabiskan detik per test. Tantangan ketiga adalah efek samping nyata: test yang menulis ke database produksi bukan test, melainkan insiden menunggu waktu terjadi. Ketiganya dijawab oleh kombinasi pola berikut.
Pola Transaksi yang Digulung #
Pola paling populer untuk integration test database: setiap test berjalan di dalam satu transaksi yang digulung kembali (rollback) di akhir, bukan di-commit. Seluruh INSERT/UPDATE test terlihat oleh test itu sendiri (karena berada dalam transaksi yang sama), tetapi tidak pernah sampai ke penyimpanan permanen — test berikutnya menerima tabel seperti semula tanpa perintah pembersihan apa pun. Di Spring, pola ini diaktifkan cukup dengan satu anotasi:
@SpringBootTest
@Transactional
class TransferRepositoryTest {
@Autowired
TransferRepository repo;
@Test
void transferMengurangiSaldoPengirim() {
repo.simpan(new Saldo("Budi", 100_000L));
repo.transfer("Budi", "Siti", 40_000L);
assertThat(repo.saldo("Budi")).isEqualTo(60_000L);
} // transaksi digulung otomatis: data tidak permanen
}Mekanisme di balik layarnya bisa kita buktikan tanpa framework — repository berikut menyimpan snapshot saat transaksi dimulai dan memulihkannya saat digulung:
import java.util.HashMap;
import java.util.Map;
public class ContohRollback {
// ---- lapisan akses data dengan dukungan transaksi ----
static final class RepoSaldo {
private final Map<String, Long> tabel = new HashMap<>();
private Map<String, Long> snapshot;
void mulaiTransaksi() {
snapshot = new HashMap<>(tabel);
}
void commit() {
snapshot = null;
}
void rollback() {
tabel.clear();
tabel.putAll(snapshot);
snapshot = null;
}
void simpan(String pemilik, long jumlah) {
if (jumlah < 0) {
throw new IllegalArgumentException("saldo tidak boleh negatif: " + jumlah);
}
tabel.put(pemilik, jumlah);
}
Long cari(String pemilik) {
return tabel.get(pemilik);
}
int jumlah() {
return tabel.size();
}
}
public static void main(String[] args) {
RepoSaldo repo = new RepoSaldo();
repo.simpan("Budi", 100_000L);
repo.simpan("Siti", 250_000L);
System.out.println("penyiapan : 2 baris data awal");
// ---- test 1: transfer di dalam transaksi yang digulung ----
repo.mulaiTransaksi();
repo.simpan("Budi", repo.cari("Budi") - 50_000L);
repo.simpan("Siti", repo.cari("Siti") + 50_000L);
System.out.println("dalam transaksi : Budi=" + repo.cari("Budi")
+ ", Siti=" + repo.cari("Siti"));
repo.rollback();
System.out.println("setelah digulung: Budi=" + repo.cari("Budi")
+ ", Siti=" + repo.cari("Siti"));
// ---- test 2: pelanggaran aturan digulung otomatis ----
repo.mulaiTransaksi();
try {
repo.simpan("Budi", -1L);
} catch (IllegalArgumentException e) {
repo.rollback();
System.out.println("pelanggaran : " + e.getMessage());
}
System.out.println("data akhir : " + repo.jumlah()
+ " baris, Budi=" + repo.cari("Budi"));
}
}Output yang Dihasilkan:
penyiapan : 2 baris data awal
dalam transaksi : Budi=50000, Siti=300000
setelah digulung: Budi=100000, Siti=250000
pelanggaran : saldo tidak boleh negatif: -1
data akhir : 2 baris, Budi=100000Perhatikan: transfer terlihat berhasil di dalam transaksi, tetapi setelah digulung saldo kembali ke nilai awal, dan pelanggaran aturan tidak meninggalkan jejak. Bedah baris-per-baris bagian kuncinya:
- Baris
snapshot = new HashMap<>(tabel);: awal transaksi menyalin seluruh isi tabel — padanan BEGIN di database sungguhan; salinan inilah yang menjadi titik pulih. - Baris
tabel.clear(); tabel.putAll(snapshot);: rollback memulihkan state persis ke awal transaksi; test berikutnya melihat tabel yang sama dengan test sebelumnya mulai. - Baris
if (jumlah < 0) throw new IllegalArgumentException(...): aturan integritas menirukan constraint database; pelanggaran memicu penggulungan sehingga penulisan tidak sempat menetap. - Baris
repo.simpan("Budi", repo.cari("Budi") - 50_000L);: operasi transfer berlapis duasimpan; karena seluruh perubahan berada dalam satu transaksi, hasil tengah terlihat konsisten bagi test. - Baris terakhir
data akhir: 2 baris: bukti isolasi — seluruh gejolak dua test meninggalkan tabel utuh berisi data penyiapan awal.
Penyiapan Data yang Bersih #
Rollback menyelesaikan pembersihan akhir; penyiapan awal tetap tanggung jawab kita. Tiga pendekatan yang lazim, dari yang paling sederhana:
| Pendekatan | Cara kerja | Cocok untuk |
|---|---|---|
| Seed sekali + rollback | Data dasar di-commit sebelum suite; tiap test menggulung perubahan | Suite transaksional |
| Setup per test | @BeforeEach menghapus lalu mengisi ulang tabel terkait | Test non-transaksional |
Skrip @Sql | Anotasi menjalankan berkas SQL sebelum/sesudah method uji | Skenario data kompleks |
Prinsip yang sama berlaku pada ketiganya: test hanya boleh bergantung pada data yang dibuat oleh dirinya sendiri atau oleh penyiapan yang eksplisit — tidak pernah pada sisa eksekusi test lain. Bila sebuah test membutuhkan pelanggan premium, buat pelanggan premium itu di setup-nya, jangan berharap test sebelumnya kebetulan menyisakannya.
Database In-Memory untuk Suite Cepat #
Untuk jalur cepat, database seperti H2 atau SQLite mode in-memory menyediakan SQL nyata tanpa I/O disk dan tanpa server — database hidup di memori proses test dan terhapus saat proses selesai:
try (Connection koneksi = DriverManager.getConnection(
"jdbc:h2:mem:uji;DB_CLOSE_DELAY=-1")) {
try (Statement st = koneksi.createStatement()) {
st.execute("CREATE TABLE saldo (pemilik VARCHAR(64), jumlah BIGINT)");
}
} // contoh konsep: memerlukan driver H2 di classpathDatabase in-memory menjawab kecepatan dan isolasi, tetapi tidak menjawab kecocokan dialek: H2 bukan PostgreSQL, dan query yang memakai fitur khas produksi bisa lolos di H2 namun gagal di database sungguhan. Untuk kecocokan penuh, gunakan database yang identik dengan produksi lewat Testcontainers — Chapter khusus akan membahasnya.
Menjaga Data Uji Deterministik #
Data uji database yang baik mengikuti prinsip yang sama dengan test fixture: nilai tetap yang bercerita. Tanggal disimpan sebagai nilai beku (misalnya DATE '2026-01-15') alih-alih tanggal hari ini; primary key ditulis eksplisit (1001, 1002, ...) alih-alih memakai auto-increment yang nilainya bergeser antar eksekusi; dan jumlah baris yang diuji dihitung dari data yang dibuat test, bukan dari SELECT COUNT(*) atas tabel yang isinya tidak kita kendalikan. Saat test gagal, pembaca harus bisa menyimpulkan penyebabnya dari data yang tercetak — data acak atau bergeser membuat laporan gagal tidak dapat dibandingkan antar eksekusi.
Dengan isolasi transaksional dan data yang deterministik, lapisan akses data siap diuji dengan percaya diri. Chapter berikutnya memutar perhatian ke jalur gagal: memverifikasi bahwa kode memang melempar exception yang tepat, lengkap dengan pesan dan penyebabnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ContohRollback.java
Unduh berkasimport java.util.HashMap;
import java.util.Map;
public class ContohRollback {
// ---- lapisan akses data dengan dukungan transaksi ----
static final class RepoSaldo {
private final Map<String, Long> tabel = new HashMap<>();
private Map<String, Long> snapshot;
void mulaiTransaksi() {
snapshot = new HashMap<>(tabel);
}
void commit() {
snapshot = null;
}
void rollback() {
tabel.clear();
tabel.putAll(snapshot);
snapshot = null;
}
void simpan(String pemilik, long jumlah) {
if (jumlah < 0) {
throw new IllegalArgumentException("saldo tidak boleh negatif: " + jumlah);
}
tabel.put(pemilik, jumlah);
}
Long cari(String pemilik) {
return tabel.get(pemilik);
}
int jumlah() {
return tabel.size();
}
}
public static void main(String[] args) {
RepoSaldo repo = new RepoSaldo();
repo.simpan("Budi", 100_000L);
repo.simpan("Siti", 250_000L);
System.out.println("penyiapan : 2 baris data awal");
// ---- test 1: transfer di dalam transaksi yang digulung ----
repo.mulaiTransaksi();
repo.simpan("Budi", repo.cari("Budi") - 50_000L);
repo.simpan("Siti", repo.cari("Siti") + 50_000L);
System.out.println("dalam transaksi : Budi=" + repo.cari("Budi")
+ ", Siti=" + repo.cari("Siti"));
repo.rollback();
System.out.println("setelah digulung: Budi=" + repo.cari("Budi")
+ ", Siti=" + repo.cari("Siti"));
// ---- test 2: pelanggaran aturan digulung otomatis ----
repo.mulaiTransaksi();
try {
repo.simpan("Budi", -1L);
} catch (IllegalArgumentException e) {
repo.rollback();
System.out.println("pelanggaran : " + e.getMessage());
}
System.out.println("data akhir : " + repo.jumlah()
+ " baris, Budi=" + repo.cari("Budi"));
}
}