Bab lalu OrderContextLoadTest membuktikan seluruh application context bisa menyala dan tersambung ke database lewat Testcontainers. Tapi itu baru pembuktian "mesinnya nyala" — belum ada satu pun request HTTP sungguhan yang dikirim, belum ada data yang benar-benar disimpan lalu dibaca kembali. Bab ini kita naik satu tingkat lagi: integration test end-to-end yang benar-benar mengirim HTTP request ke endpoint, dan sekaligus membahas kenapa Testcontainers — bukan H2 in-memory — jadi pilihan database untuk pengujian ini.
Apa Itu Testcontainers
Testcontainers adalah library Java/Kotlin yang menjalankan Docker container sungguhan untuk keperluan testing, dan mengelola siklus hidupnya secara otomatis dari dalam kode test. Alih-alih menyambung ke database yang sudah terpasang manual di mesin developer atau CI, test cukup mendeklarasikan "saya butuh PostgreSQL 16" — Testcontainers akan menarik image itu (kalau belum ada), menyalakannya di port acak, dan test tersambung ke situ. Sudah kita lihat mekanismenya di bab lalu lewat PostgreSQLContainer("postgres:16-alpine") di AbstractIntegrationTest.
Kenapa PostgreSQL Asli, Bukan H2
Cara populer lain untuk testing database di ekosistem Java/Kotlin adalah memakai H2, database in-memory yang bisa dikonfigurasi meniru "mode compatibility" PostgreSQL. Godaannya jelas: H2 tidak butuh Docker, boot-nya nyaris instan, dan tidak ada dependency eksternal apa pun. Tapi "meniru" bukan berarti identik — H2 punya SQL dialect dan perilaku sendiri yang berbeda dari PostgreSQL asli di banyak titik: fungsi built-in yang berbeda, perilaku tipe data yang berbeda (termasuk kasus NUMERIC/BigDecimal yang akan kita bahas sebentar lagi), constraint checking yang tidak selalu identik, sampai perilaku case-sensitivity nama kolom/tabel yang bisa berbeda tergantung mode compatibility-nya.
Konsekuensinya: test yang hijau melawan H2 tidak menjamin kode yang sama akan berperilaku sama saat production-nya jalan di PostgreSQL sungguhan. Kamu bisa saja lolos semua test lokal, lalu menemukan bug baru begitu deploy — sesuatu yang seharusnya sudah ketahuan dari fase testing, bukan dari user yang melapor. Testcontainers menghapus celah ini: karena yang jalan di test adalah PostgreSQL 16 yang sama persis dengan yang dipakai production (cuma beda instance), setiap perilaku spesifik Postgres — termasuk yang tidak terduga — akan muncul di test, bukan baru muncul di production. Harga yang dibayar cuma satu: butuh Docker terpasang dan test jadi sedikit lebih lambat karena container sungguhan perlu waktu untuk start (biasanya 1-3 detik, dan berkat pola singleton container di bab lalu, biaya itu cuma dibayar sekali untuk seluruh test run, bukan per test class).
Membaca OrderIntegrationTest.kt
package com.indokoding.orderservice
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.TestRestTemplate
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpStatus
import java.math.BigDecimal
// Fase 3.4 - Integration test lawan PostgreSQL asli lewat Testcontainers (bukan H2),
// mensimulasikan alur end-to-end: HTTP request masuk -> tersimpan ke database ->
// bisa dibaca kembali dengan data yang sama persis seperti request asli dari client.
// @AutoConfigureTestRestTemplate wajib eksplisit di Spring Boot 4 - TestRestTemplate
// pindah ke modul terpisah (spring-boot-resttestclient) dan tidak lagi otomatis
// dikonfigurasi hanya dari webEnvironment = RANDOM_PORT seperti versi sebelumnya.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class OrderIntegrationTest : AbstractIntegrationTest() {
@Autowired
lateinit var restTemplate: TestRestTemplate
@Test
fun `order yang dibuat harus tersimpan dan bisa dibaca kembali dengan data yang sama`() {
val request = CreateOrderRequest("Siti", "Monitor 24 inch", 1, BigDecimal("1800000"))
val createResponse = restTemplate.postForEntity("/api/orders", request, OrderResponse::class.java)
assertEquals(HttpStatus.CREATED, createResponse.statusCode)
val createdId = createResponse.body!!.id
val getResponse = restTemplate.getForEntity("/api/orders/$createdId", OrderResponse::class.java)
assertEquals(HttpStatus.OK, getResponse.statusCode)
assertEquals("Siti", getResponse.body!!.customerName)
// BigDecimal.equals() sensitif terhadap scale ("1800000" != "1800000.00" secara equals()),
// padahal keduanya sama secara nilai numerik. Kolom NUMERIC(19,2) di database membuat
// Postgres mengembalikan angka dengan scale 2 - pakai compareTo() untuk membandingkan
// nilai numeriknya, bukan representasi scale-nya.
assertEquals(0, BigDecimal("1800000").compareTo(getResponse.body!!.totalPrice))
}
@Test
fun `GET order yang tidak ada mengembalikan 404`() {
val response = restTemplate.getForEntity("/api/orders/999999", Map::class.java)
assertEquals(HttpStatus.NOT_FOUND, response.statusCode)
}
}
webEnvironment = RANDOM_PORT dan TestRestTemplate
OrderContextLoadTest di bab lalu memakai @SpringBootTest polos — context dimuat, tapi tidak ada web server sungguhan yang dibuka. Di sini kita butuh lebih dari itu: webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT membuat Spring Boot benar-benar membuka embedded web server (Tomcat) di port acak yang tersedia — persis seperti aplikasi jalan sungguhan, cuma port-nya tidak ditentukan manual supaya tidak bentrok kalau ada test lain jalan bersamaan.
TestRestTemplate adalah HTTP client yang otomatis tahu base URL dan port acak itu, jadi kita bisa memanggil restTemplate.postForEntity("/api/orders", ...) tanpa perlu menuliskan host/port secara eksplisit. Perhatikan anotasi @AutoConfigureTestRestTemplate yang eksplisit ditambahkan — komentar di kode menjelaskan kenapa: di Spring Boot 4, TestRestTemplate pindah ke modul terpisah (spring-boot-resttestclient) dan tidak lagi otomatis dikonfigurasi hanya dari webEnvironment = RANDOM_PORT seperti versi Spring Boot sebelumnya — jadi anotasi ini perlu ditambahkan secara sadar, bukan diasumsikan otomatis ada.
Alur Test: POST, Lalu GET
Test utamanya mensimulasikan alur nyata seorang client API: POST /api/orders untuk membuat order baru, ambil id dari response, lalu GET /api/orders/{id} untuk membaca kembali data yang barusan dibuat — dan pastikan datanya identik dengan yang dikirim. Ini beda secara fundamental dari OrderServiceTest di bab 19: di sana OrderRepository di-mock jadi kita cuma memverifikasi logic OrderService. Di sini, tidak ada yang di-mock sama sekali — request HTTP betulan masuk lewat controller, divalidasi lewat Bean Validation, diproses OrderService, disimpan lewat JPA ke PostgreSQL sungguhan di dalam container, lalu dibaca kembali lewat query sungguhan. Kalau ada lapisan mana pun di alur ini yang salah — mapping JPA yang keliru, kolom yang salah tipe, query yang salah — test ini yang akan menangkapnya, bukan OrderServiceTest.
Kisah Bug: BigDecimal.equals() yang Scale-Sensitive
Versi awal assertion terakhir di test ini ditulis dengan cara paling wajar dan intuitif:
assertEquals(BigDecimal("1800000"), getResponse.body!!.totalPrice)
Test-nya gagal, dengan pesan yang membingungkan kalau belum pernah melihatnya sebelumnya:
expected: <1800000> but was: <1800000.00>
Angkanya sama — 1.800.000 tetaplah 1.800.000 — tapi assertEquals (yang di baliknya memanggil .equals()) tetap menganggapnya tidak sama. Penyebabnya adalah detail dari BigDecimal yang gampang terlewat: BigDecimal.equals() membandingkan scale, bukan cuma nilai numerik. BigDecimal("1800000") punya scale 0 (tidak ada digit di belakang koma), sedangkan BigDecimal("1800000.00") punya scale 2 — meski keduanya merepresentasikan nilai yang identik secara matematis, .equals() menganggap keduanya berbeda karena representasi internalnya berbeda.
Kenapa nilai yang keluar dari database punya scale 2? Karena kolom totalPrice di tabel orders didefinisikan sebagai NUMERIC(19,2) — presisi 19 digit, 2 di antaranya di belakang koma (standar untuk kolom uang). Begitu angka 1800000 (yang dikirim client tanpa desimal) tersimpan ke kolom bertipe ini dan dibaca kembali, PostgreSQL/JPA mengembalikannya sebagai 1800000.00, mengikuti scale yang didefinisikan kolomnya. Ini perilaku yang benar dan diharapkan dari sisi database — tapi mengejutkan kalau kamu menulis assertion dengan asumsi BigDecimal berperilaku seperti angka biasa.
Solusinya:
assertEquals(0, BigDecimal("1800000").compareTo(getResponse.body!!.totalPrice))
BigDecimal.compareTo() hanya membandingkan nilai numerik, mengabaikan scale sepenuhnya — compareTo() mengembalikan 0 kalau kedua nilai secara matematis sama, tidak peduli representasi internalnya. Pola assertEquals(0, a.compareTo(b)) ini bukan sekadar workaround lokal untuk satu test ini saja — ini pola standar yang perlu diingat setiap kali membandingkan BigDecimal yang datang dari hasil query database (atau operasi aritmetika lain yang bisa mengubah scale) dengan literal yang kamu tulis manual di kode test. Aturan praktisnya: kalau salah satu sisi perbandingan BigDecimal berasal dari database, jangan pernah pakai assertEquals/.equals() langsung — selalu compareTo() == 0.
Test Kedua: 404 untuk Order yang Tidak Ada
Test kedua di file ini lebih sederhana tapi sama pentingnya:
@Test
fun `GET order yang tidak ada mengembalikan 404`() {
val response = restTemplate.getForEntity("/api/orders/999999", Map::class.java)
assertEquals(HttpStatus.NOT_FOUND, response.statusCode)
}
Ini memverifikasi ujung ke ujung bahwa OrderNotFoundException yang dilempar OrderService.getOrderById() (yang sudah kita uji secara terisolasi di OrderServiceTest lewat mock di bab 19) benar-benar diterjemahkan jadi HTTP 404 oleh lapisan exception handling (GlobalExceptionHandler) saat dijalankan sungguhan — sesuatu yang tidak pernah tersentuh sama sekali oleh unit test yang mock repository-nya.
Dua bug di bab ini dan bab lalu — container lifecycle dan BigDecimal scale — punya benang merah yang sama: keduanya cuma bisa ketahuan lewat integration test yang benar-benar menyentuh database dan Spring context sungguhan. Unit test yang mock semuanya tidak akan pernah menemukan salah satunya. Ini alasan kenapa Fase 3 ini sengaja membangun kedua lapisan test secara berjenjang, bukan cuma mengandalkan satu jenis test saja.
Bab berikutnya kita turun sedikit di kecepatan test: menulis controller test dengan MockMvc yang menguji layer HTTP dengan detail yang sama seperti bab ini, tapi tanpa database sama sekali — dan membandingkan langsung trade-off kecepatan versus cakupannya dengan OrderIntegrationTest yang baru kita tulis ini.
Bagian dari Series: Kotlin Backend Microservice - Belajar Fundamental sampai Microservice Nyata
Belajar Kotlin + Spring Boot dari fundamental bahasa (null safety, OOP, coroutines) sampai membangun sistem microservice sungguhan - REST API, Postgre...
Lihat Series Lengkap