Tampilan
Kenapa penting
Setiap kali kamu mengubah kode — menambah diskon member, memperbaiki pembulatan, me-refactor service — ada risiko merusak perilaku yang sudah jalan. Mengecek ulang semuanya secara manual lewat main atau Postman tidak bertahan lama: lambat, membosankan, dan pasti ada yang terlewat. Automated test adalah kode yang memeriksa kode lain dan bisa dijalankan ribuan kali dalam hitungan detik, di laptopmu maupun di CI sebelum merge. Di tim backend, pull request tanpa test biasanya ditolak, dan test yang bagus sekaligus menjadi dokumentasi perilaku yang selalu mutakhir. JUnit adalah framework test standar de facto di ekosistem Java, termasuk di Spring Boot.
Konsep inti
Piramida test
Tidak semua test sama. Model yang umum dipakai adalah piramida test:
| Lapisan | Menguji | Kecepatan | Jumlah ideal |
|---|---|---|---|
| End-to-end / UI | alur lengkap lewat HTTP/UI, sistem nyata | lambat | sedikit |
| Integration test | beberapa komponen + infrastruktur (DB, Kafka) | sedang | secukupnya |
| Unit test (alas) | satu class/method, tanpa I/O | milidetik | banyak |
Unit test adalah alasnya: cepat, stabil, dan menunjuk lokasi bug dengan tepat. Bab ini fokus di sana. Integration test dengan database dan Testcontainers dibahas di modul berikutnya.
JUnit Jupiter: test pertama
Versi terbaru adalah JUnit 6; API untuk menulis test (paket org.junit.jupiter.api, disebut Jupiter) sama dengan JUnit 5, jadi tutorial JUnit 5 tetap berlaku. Dependency-nya org.junit.jupiter:junit-jupiter dengan scope test, dan file test diletakkan di src/test/java dengan paket yang sama seperti class yang diuji — seperti yang sudah kamu lihat di bab Maven dan struktur project.
Class yang akan kita uji:
java
public class DiscountCalculator {
public long apply(long price, int percent) {
if (price < 0) {
throw new IllegalArgumentException("price tidak boleh negatif: " + price);
}
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException("percent harus 0..100: " + percent);
}
return price - price * percent / 100;
}
}Test pertamanya:
java
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("DiscountCalculator")
class DiscountCalculatorTest {
@Test
@DisplayName("diskon 10% dari 100.000 menjadi 90.000")
void appliesTenPercent() {
// Arrange — siapkan object dan data
DiscountCalculator calculator = new DiscountCalculator();
long price = 100_000;
// Act — jalankan perilaku yang diuji
long result = calculator.apply(price, 10);
// Assert — periksa hasilnya
assertEquals(90_000, result);
}
}Hal yang perlu diperhatikan:
- Method
@Testcukup package-private, mengembalikanvoid, dan tanpa parameter. Class test juga tidak perlupublic. @DisplayNamememberi nama yang dibaca manusia di IDE dan laporan test. Tanpa itu, yang tampil adalah nama method.- Arrange-Act-Assert (AAA) membagi test menjadi tiga blok: siapkan, jalankan, periksa. Satu test idealnya punya satu blok Act — bila ada dua aksi yang ingin diperiksa, biasanya itu dua test.
Ketika test gagal, JUnit memberi tahu nilai yang diharapkan dan yang didapat. Misalnya bila persen diskon salah ketik menjadi 1:
text
org.opentest4j.AssertionFailedError: expected: <90000> but was: <99000>Urutan argumen assertEquals adalah (expected, actual). Membaliknya tidak membuat test salah lulus, tetapi pesan gagalnya jadi terbalik dan membingungkan.
Assertion bawaan
Kelas org.junit.jupiter.api.Assertions menyediakan method statis yang paling sering dipakai:
| Method | Memeriksa |
|---|---|
assertEquals(expected, actual) | nilai sama (pakai equals) |
assertTrue(cond) / assertFalse(cond) | kondisi boolean |
assertNull(x) / assertNotNull(x) | ada tidaknya nilai |
assertThrows(Type.class, lambda) | lambda melempar exception bertipe itu |
assertAll(lambda...) | menjalankan semua assertion, lapor semua yang gagal |
Setiap assertion menerima argumen terakhir opsional berupa pesan, misalnya assertEquals(90_000, result, "harga setelah diskon member GOLD"), yang muncul di depan pesan gagal:
text
org.opentest4j.AssertionFailedError: harga setelah diskon member GOLD ==> expected: <90000> but was: <99000>Hindari assertTrue(result == 90_000): bila gagal, pesannya hanya expected: <true> but was: <false> — kamu tidak tahu nilai sebenarnya. Pilih assertion yang paling spesifik.
assertAll berguna saat beberapa pemeriksaan saling terkait. Tanpa assertAll, assertion pertama yang gagal menghentikan test; dengan assertAll, semua dijalankan lalu dilaporkan sekaligus (MultipleFailuresError).
Menguji exception dengan assertThrows
Validasi input adalah perilaku penting yang wajib diuji. assertThrows menerima tipe exception dan lambda berisi kode yang seharusnya melempar, lalu mengembalikan exception-nya agar pesannya bisa diperiksa:
java
@Test
@DisplayName("menolak harga negatif")
void rejectsNegativePrice() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> calculator.apply(-1, 10));
assertEquals("price tidak boleh negatif: -1", ex.getMessage());
}Bila lambda selesai tanpa exception, test gagal dengan Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown. assertThrows juga lulus untuk subclass dari tipe yang diminta; gunakan tipe yang spesifik agar test tidak terlalu longgar.
Lifecycle: @BeforeEach, @AfterEach, @BeforeAll
JUnit membuat object class test baru untuk setiap method test. Di atasnya ada hook lifecycle:
@BeforeEach/@AfterEach— dijalankan sebelum/sesudah setiap test. Tempat membuat object yang diuji dan membersihkan sisa.@BeforeAll/@AfterAll— dijalankan sekali untuk seluruh class, sebelum object test ada, sehingga harusstatic. Dipakai untuk persiapan mahal seperti menyalakan server tiruan.
java
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class LifecycleTest {
@BeforeAll
static void beforeAll() { System.out.println("@BeforeAll (sekali)"); }
@BeforeEach
void beforeEach() { System.out.println("@BeforeEach (object " + System.identityHashCode(this) % 1000 + ")"); }
@Test
void first() { System.out.println(" test pertama"); }
@Test
void second() { System.out.println(" test kedua"); }
@AfterEach
void afterEach() { System.out.println("@AfterEach"); }
}Salah satu output saat dijalankan (angka object bisa berbeda):
text
@BeforeAll (sekali)
@BeforeEach (object 99)
test kedua
@AfterEach
@BeforeEach (object 208)
test pertama
@AfterEachPerhatikan dua hal: setiap test mendapat object berbeda, dan second berjalan lebih dulu daripada first. Urutan method test tidak mengikuti urutan di file — jadi jangan pernah membuat test yang bergantung pada test lain.
Bila @BeforeAll lupa diberi static, JUnit menolak class itu saat discovery:
text
@BeforeAll method 'void FailDemoTest.notStatic()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).Parameterized test
Banyak test hanya berbeda di data. @ParameterizedTest (dari artefak junit-jupiter-params, sudah termasuk di junit-jupiter) menjalankan satu method untuk setiap set argumen:
java
@ParameterizedTest(name = "{0} diskon {1}% = {2}")
@CsvSource({
"100000, 10, 90000",
"85000, 15, 72250",
"99999, 50, 50000"
})
@DisplayName("tabel harga dan diskon")
void table(long price, int percent, long expected) {
assertEquals(expected, calculator.apply(price, percent));
}
@ParameterizedTest(name = "persen {0} ditolak")
@ValueSource(ints = {-1, 101, 250})
@DisplayName("persen di luar 0..100 ditolak")
void rejectsInvalidPercent(int percent) {
assertThrows(IllegalArgumentException.class, () -> calculator.apply(10_000, percent));
}
static Stream<Arguments> memberDiscounts() {
return Stream.of(
Arguments.of("SILVER", 5, 95_000L),
Arguments.of("GOLD", 10, 90_000L));
}
@ParameterizedTest(name = "member {0}")
@MethodSource("memberDiscounts")
@DisplayName("diskon per tingkat member")
void memberTier(String tier, int percent, long expected) {
assertEquals(expected, calculator.apply(100_000, percent));
}@ValueSource— satu argumen per eksekusi (ints,longs,strings, ...).@CsvSource— beberapa argumen per baris; teks dikonversi otomatis ke tipe parameter.@MethodSource— data dari methodstaticyang mengembalikanStream<Arguments>; cocok untuk object kompleks seperti record.
Setiap baris dilaporkan sebagai test sendiri, sehingga kamu langsung tahu kombinasi mana yang gagal. Atribut name mengatur label tiap baris; JUnit 6 menampilkan argumen teks dalam tanda kutip, misalnya member "GOLD".
@Nested dan @Disabled
@Nested pada inner class (bukan static) mengelompokkan test per skenario. Inner class bisa memakai field dan @BeforeEach milik class luar, dan laporan test tampil bertingkat seperti kalimat: "Inventory › reserve() ketika stok kurang › melempar exception".
@Disabled("alasan") menonaktifkan test sementara. Selalu tulis alasan dan rujukan tiket, lalu benar-benar kembali memperbaikinya — test yang di-disable lalu dilupakan sama saja dengan tidak punya test.
java
@Test
@Disabled("ISSUE-042: aturan pembulatan diskon belum diputuskan")
@DisplayName("pembulatan ke kelipatan 100")
void roundingToHundreds() {
}Penamaan test
Nama test harus menjelaskan perilaku, bukan method: rejectsNegativePrice lebih informatif daripada testApply2. Konvensi di project latihan exercises/ mengikuti pola ini: class ShippingQuoteTest menguji ShippingQuote, nama method berbahasa Inggris (billableKgRoundsUp), dan @DisplayName berbahasa Indonesia yang bisa dibaca seperti spesifikasi — "billableKg() membulatkan berat ke atas per kg". Untuk menjalankan satu class test saja:
bash
./mvnw -pl m01-fondasi-java test -Dtest=ShippingQuoteTestContoh di dunia backend
Service inventaris harus menahan stok saat checkout dan menolak bila stok kurang — tanpa mengubah stok ketika gagal. Test berikut memakai @BeforeEach untuk data awal, @Nested per skenario, dan memeriksa state setelah exception.
java
import java.util.HashMap;
import java.util.Map;
public class Inventory {
private final Map<String, Integer> stock = new HashMap<>();
public void restock(String sku, int qty) {
if (qty <= 0) {
throw new IllegalArgumentException("qty harus positif: " + qty);
}
stock.merge(sku, qty, Integer::sum);
}
public void reserve(String sku, int qty) {
int available = available(sku);
if (qty > available) {
throw new InsufficientStockException(sku, qty, available);
}
stock.put(sku, available - qty);
}
public int available(String sku) {
return stock.getOrDefault(sku, 0);
}
}java
public class InsufficientStockException extends RuntimeException {
public InsufficientStockException(String sku, int requested, int available) {
super("Stok " + sku + " kurang: diminta " + requested + ", tersedia " + available);
}
}java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@DisplayName("Inventory")
class InventoryTest {
private Inventory inventory;
@BeforeEach
void setUp() {
inventory = new Inventory();
inventory.restock("KOPI-250", 10);
}
@Nested
@DisplayName("reserve() ketika stok cukup")
class EnoughStock {
@Test
@DisplayName("mengurangi stok tersedia")
void reducesAvailableStock() {
inventory.reserve("KOPI-250", 3);
assertEquals(7, inventory.available("KOPI-250"));
}
@Test
@DisplayName("boleh mengambil seluruh stok")
void canTakeAll() {
inventory.reserve("KOPI-250", 10);
assertEquals(0, inventory.available("KOPI-250"));
}
}
@Nested
@DisplayName("reserve() ketika stok kurang")
class NotEnoughStock {
@Test
@DisplayName("melempar InsufficientStockException dan stok tidak berubah")
void rejectsAndKeepsStock() {
InsufficientStockException ex = assertThrows(InsufficientStockException.class,
() -> inventory.reserve("KOPI-250", 11));
assertEquals("Stok KOPI-250 kurang: diminta 11, tersedia 10", ex.getMessage());
assertEquals(10, inventory.available("KOPI-250"));
}
@Test
@DisplayName("SKU yang tidak dikenal dianggap stok 0")
void unknownSku() {
assertThrows(InsufficientStockException.class, () -> inventory.reserve("TEH-100", 1));
}
}
@ParameterizedTest(name = "restock {0} ditolak")
@ValueSource(ints = {0, -5})
@DisplayName("restock() menolak jumlah tidak positif")
void restockRejectsNonPositive(int qty) {
assertThrows(IllegalArgumentException.class, () -> inventory.restock("KOPI-250", qty));
}
}Laporan test-nya (seperti yang tampil di panel test IDE) terbaca seperti spesifikasi:
text
Inventory
restock() menolak jumlah tidak positif
✔ restock 0 ditolak
✔ restock -5 ditolak
reserve() ketika stok kurang
✔ melempar InsufficientStockException dan stok tidak berubah
✔ SKU yang tidak dikenal dianggap stok 0
reserve() ketika stok cukup
✔ mengurangi stok tersedia
✔ boleh mengambil seluruh stokTest rejectsAndKeepsStock memeriksa hal yang sering terlupa: setelah operasi gagal, state tidak boleh setengah berubah. Bug semacam ini — stok berkurang padahal pesanan ditolak — mahal bila lolos ke production.
Pertanyaan interview
1. Apa beda unit test dan integration test?
Unit test menguji satu unit (class/method) secara terisolasi tanpa I/O nyata, sehingga cepat dan menunjuk bug dengan tepat. Integration test menguji beberapa komponen bersama infrastruktur sungguhan seperti database atau message broker — lebih lambat, tetapi menangkap masalah konfigurasi dan integrasi yang tidak terlihat di unit test. Piramida test menyarankan banyak unit test dan lebih sedikit integration/E2E test.
2. Kenapa @BeforeAll harus static?
Karena secara default JUnit membuat object class test baru untuk setiap method test. @BeforeAll dijalankan sekali sebelum semua test, saat belum ada object, sehingga harus static. Pengecualiannya bila class diberi @TestInstance(Lifecycle.PER_CLASS), yang membuat satu object dipakai bersama — konsekuensinya state di field bisa bocor antartest.
3. Kapan memakai @ParameterizedTest?
Saat beberapa test punya logika yang sama dan hanya berbeda di data input/output, misalnya tabel tarif atau nilai batas validasi. Hasilnya lebih ringkas, menambah kasus cukup satu baris, dan setiap kombinasi dilaporkan terpisah. Jangan dipaksakan bila tiap kasus butuh Arrange atau Assert yang berbeda — pisahkan saja menjadi test biasa.
Jebakan umum
- Urutan argumen
assertEqualsterbalik. Tulis(expected, actual)agar pesan gagal tidak menyesatkan. - Test bergantung pada test lain atau urutan eksekusi. Urutan method tidak dijamin; setiap test harus menyiapkan datanya sendiri lewat Arrange atau
@BeforeEach. - Memakai
assertTrue(a == b)untuk membandingkan nilai. Pesan gagalnya tidak menunjukkan nilai aktual. PakaiassertEquals. - Test tanpa assertion. Test yang hanya memanggil method selalu lulus selama tidak ada exception — tidak membuktikan apa pun.
@Disabledtanpa alasan dan tanpa tindak lanjut. Test mati diam-diam; tulis alasan dan nomor tiket.- Menangkap exception dengan
try/catchdi test lalu tidak melakukan apa-apa. Kegagalan tertelan. PakaiassertThrows.
Ringkasan
- Automated test menjaga perilaku saat kode berubah; unit test adalah alas piramida test karena cepat dan presisi.
- JUnit 6 memakai API Jupiter yang sama dengan JUnit 5:
@Test,@DisplayName, dan assertion diAssertions. - Susun test dengan pola Arrange-Act-Assert dan nama yang menjelaskan perilaku.
- Setiap test mendapat object class test baru;
@BeforeEachmenyiapkan data,@BeforeAll(static) untuk persiapan sekali jalan. assertThrowsmemeriksa exception dan mengembalikannya;assertAllmelaporkan semua assertion yang gagal.@ParameterizedTest(@ValueSource,@CsvSource,@MethodSource) menghapus duplikasi;@Nestedmengelompokkan skenario.