Tampilan
Kenapa penting
Test yang lulus hari ini akan gagal suatu saat nanti — dan saat itu terjadi, yang membacanya mungkin rekan setim pukul sebelas malam. Pesan gagal seperti expected: <true> but was: <false> memaksanya membuka debugger; pesan yang menyebut elemen mana di indeks berapa yang berbeda langsung menunjuk masalah. AssertJ membuat assertion terbaca seperti kalimat dan menghasilkan pesan gagal yang jelas, sehingga dipakai luas di project Java dan sudah termasuk di spring-boot-starter-test. Bab ini juga membahas Test-Driven Development (TDD): kebiasaan menulis test lebih dulu, yang membantu kamu merancang kode kecil-kecil dengan umpan balik cepat.
Konsep inti
assertThat: assertion yang mengalir
AssertJ (org.assertj:assertj-core, scope test) punya satu titik masuk: Assertions.assertThat(actual). Method ini mengembalikan object assertion yang sesuai tipe actual, sehingga IDE hanya menawarkan pemeriksaan yang masuk akal, dan pemeriksaan bisa dirangkai:
java
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
class AssertjBasicsTest {
@Test
void strings() {
String sku = "KOPI-250";
assertThat(sku).startsWith("KOPI-").endsWith("250").hasSize(8).doesNotContain(" ");
assertThat(" ").isBlank();
}
@Test
void collections() {
List<String> statuses = List.of("NEW", "PAID", "SHIPPED");
assertThat(statuses)
.hasSize(3)
.contains("PAID")
.doesNotContain("CANCELLED")
.containsExactly("NEW", "PAID", "SHIPPED");
assertThat(statuses).containsExactlyInAnyOrder("SHIPPED", "NEW", "PAID");
}
}Urutan membacanya alami: "pastikan sku diawali KOPI-, diakhiri 250, panjangnya 8". Perhatikan juga bahwa nilai aktual selalu di assertThat(...) dan nilai harapan di method pemeriksanya — tidak ada lagi kebingungan urutan argumen seperti di assertEquals. Hindari mencampur Assertions milik JUnit dan AssertJ dalam satu file; pilih satu gaya agar konsisten. Project latihan di exercises/ memakai AssertJ.
Pesan gagal yang jelas
Bandingkan dua cara memeriksa urutan status. Versi JUnit assertTrue(statuses.equals(expected)) hanya melapor expected: <true> but was: <false>. Versi AssertJ containsExactly melapor:
text
Expecting actual:
["NEW", "PAID", "SHIPPED"]
to contain exactly (and in same order):
["NEW", "SHIPPED", "PAID"]
but there were differences at these indexes:
- element at index 1: expected "SHIPPED" but was "PAID"
- element at index 2: expected "PAID" but was "SHIPPED"Kamu bisa menambah konteks dengan as(...), yang muncul di awal pesan — berguna bila satu test memeriksa beberapa hal serupa:
java
assertThat(statuses).as("status pesanan ORD-1").hasSize(2);text
[status pesanan ORD-1]
Expected size: 2 but was: 3 in:
["NEW", "PAID", "SHIPPED"]as(...) harus dipanggil sebelum method pemeriksa; bila diletakkan sesudahnya, pemeriksaan sudah terjadi dan deskripsinya tidak dipakai.
extracting dan filteredOn untuk list object
Di backend, yang diuji biasanya list object, bukan list string. extracting mengambil satu atau beberapa properti dari setiap elemen, dan filteredOn menyaring elemen dulu:
java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.Test;
record Order(String id, String status, BigDecimal total) {}
class OrderListTest {
@Test
void extractingProperties() {
List<Order> orders = List.of(
new Order("ORD-1", "PAID", new BigDecimal("150000")),
new Order("ORD-2", "NEW", new BigDecimal("80000")),
new Order("ORD-3", "PAID", new BigDecimal("42500")));
assertThat(orders).extracting(Order::id).containsExactly("ORD-1", "ORD-2", "ORD-3");
assertThat(orders)
.filteredOn(order -> order.status().equals("PAID"))
.extracting(Order::id, Order::status)
.containsExactly(tuple("ORD-1", "PAID"), tuple("ORD-3", "PAID"));
}
}Tanpa extracting, kamu harus membuat object Order lengkap sebagai nilai harapan — termasuk field yang tidak relevan untuk test itu. Dengan extracting, test hanya menyebut hal yang penting.
Exception dan BigDecimal
assertThatThrownBy menerima lambda, menangkap exception yang dilempar, lalu memeriksanya. Variasi assertThatIllegalArgumentException() lebih ringkas untuk tipe yang umum:
java
assertThatThrownBy(() -> Integer.parseInt("12a"))
.isInstanceOf(NumberFormatException.class)
.hasMessageContaining("12a");
assertThatIllegalArgumentException()
.isThrownBy(() -> inventory.restock("KOPI-250", 0))
.withMessage("qty harus positif: 0");Bila lambda tidak melempar apa pun, pesannya Expecting code to raise a throwable.
Untuk uang, kamu sudah tahu dari M01 bahwa BigDecimal dipakai, bukan double. Jebakannya: BigDecimal.equals juga membandingkan scale, sehingga 10.50 dan 10.5 dianggap berbeda:
java
BigDecimal total = new BigDecimal("10.50");
assertThat(total).isEqualByComparingTo("10.5"); // lulus — memakai compareTo
assertThat(total).isEqualTo(new BigDecimal("10.5")); // gagaltext
expected: 10.5
but was: 10.50Soft assertions
Assertion biasa berhenti di kegagalan pertama. Saat memeriksa beberapa field dari satu hasil, kamu mungkin ingin melihat semua yang salah sekaligus. Itulah SoftAssertions:
java
import java.math.BigDecimal;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
class OrderSoftTest {
@Test
void checksAllFields() {
Order order = new Order("ORD-1", "NEW", new BigDecimal("150000"));
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(order.id()).isEqualTo("ORD-1");
softly.assertThat(order.status()).isEqualTo("PAID");
softly.assertThat(order.total()).isEqualByComparingTo("125000");
});
}
}text
Multiple Failures (2 failures)
-- failure 1 --
expected: "PAID"
but was: "NEW"
-- failure 2 --
expected: 125000
but was: 150000Gunakan secukupnya: bila semua field selalu dicek bersama, soft assertion menghemat siklus "perbaiki satu, jalankan, ketemu yang lain".
Siklus TDD: red → green → refactor
TDD membalik urutan biasa. Alih-alih menulis kode lalu test, kamu mengulang tiga langkah pendek:
- Red — tulis satu test kecil untuk perilaku berikutnya. Jalankan; pastikan gagal (dan gagal karena alasan yang benar).
- Green — tulis kode produksi paling sederhana yang membuat test lulus. Boleh terlihat "curang".
- Refactor — rapikan kode (nama, duplikasi, struktur) sambil semua test tetap hijau.
Mari praktikkan untuk kalkulator ongkir dengan aturan: tarif per kg tergantung zona (JAWA Rp10.000, BALI Rp15.000, PAPUA Rp25.000), berat dibulatkan ke atas per kg, input tidak valid ditolak.
Langkah 1 — red. Test pertama, padahal class-nya belum ada:
java
@Test
@DisplayName("paket 1 kg ke zona JAWA dikenai Rp10.000")
void oneKgToJava() {
ShippingCostCalculator calculator = new ShippingCostCalculator();
long cost = calculator.cost(1_000, "JAWA");
assertThat(cost).isEqualTo(10_000);
}Hasilnya bahkan tidak terkompilasi — error: cannot find symbol ... class ShippingCostCalculator. Error kompilasi juga dihitung "merah".
Langkah 1 — green. Kode paling sederhana:
java
public class ShippingCostCalculator {
public long cost(int weightGrams, String zone) {
return 10_000;
}
}Mengembalikan konstanta terasa konyol, tetapi itu disengaja: test berikutnya yang akan "memaksa" logika sungguhan muncul. Kode tidak boleh lebih pintar daripada yang dituntut test.
Langkah 2. Test cost(3_000, "JAWA") harus 30_000. Merah:
text
expected: 30000L
but was: 10000LGreen: return weightGrams / 1_000 * 10_000L;
Langkah 3. Test cost(2_500, "JAWA") harus 30_000 karena dibulatkan ke 3 kg. Merah — pembagian bilangan bulat membuang sisa:
text
expected: 30000L
but was: 20000LGreen: long kg = (weightGrams + 999) / 1_000; return kg * 10_000;
Langkah 4. Test tarif zona: cost(1_000, "BALI") harus 15_000 dan cost(2_000, "PAPUA") harus 50_000. Merah (expected: 15000L but was: 10000L). Green sekaligus refactor: tarif dipindah ke Map, pembulatan diberi nama sendiri. Keempat test tetap hijau — itulah jaring pengaman refactor.
java
import java.util.Map;
public class ShippingCostCalculator {
private static final Map<String, Long> RATE_PER_KG = Map.of(
"JAWA", 10_000L,
"BALI", 15_000L,
"PAPUA", 25_000L);
public long cost(int weightGrams, String zone) {
return billableKg(weightGrams) * RATE_PER_KG.get(zone);
}
private static long billableKg(int weightGrams) {
return (weightGrams + 999) / 1_000;
}
}Langkah 5. Test input tidak valid:
java
@Test
@DisplayName("menolak zona yang tidak dikenal")
void rejectsUnknownZone() {
assertThatThrownBy(() -> new ShippingCostCalculator().cost(1_000, "MARS"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("zona tidak dikenal: MARS");
}
@Test
@DisplayName("menolak berat nol atau negatif")
void rejectsNonPositiveWeight() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ShippingCostCalculator().cost(0, "JAWA"))
.withMessageContaining("berat");
}Merahnya informatif: berat 0 diterima diam-diam (Expecting code to raise a throwable.), dan zona MARS meledak sebagai NullPointerException karena Map.get mengembalikan null:
text
Expecting actual throwable to be an instance of:
java.lang.IllegalArgumentException
but was:
java.lang.NullPointerException: Cannot invoke "java.lang.Long.longValue()" because the return value of "java.util.Map.get(Object)" is nullGreen — versi akhir:
java
public long cost(int weightGrams, String zone) {
if (weightGrams <= 0) {
throw new IllegalArgumentException("berat harus positif: " + weightGrams);
}
Long rate = RATE_PER_KG.get(zone);
if (rate == null) {
throw new IllegalArgumentException("zona tidak dikenal: " + zone);
}
return billableKg(weightGrams) * rate;
}Enam test hijau, dan setiap baris kode produksi ada karena sebuah test menuntutnya. Bug NPE di langkah 4 tertangkap sebelum sempat sampai ke production.
Kapan TDD membantu, kapan tidak
| TDD sangat membantu | TDD kurang cocok |
|---|---|
| logika bisnis dengan aturan jelas (tarif, diskon, pajak) | eksplorasi/prototipe saat kamu belum tahu bentuk solusi |
| perbaikan bug — tulis test yang mereproduksi bug dulu | kode "lem" tipis yang hanya meneruskan panggilan |
| refactor kode warisan yang perilakunya harus dijaga | tampilan UI yang sering berubah |
Untuk prototipe, banyak engineer menulis kode dulu, lalu membuang dan menulis ulang versi produksinya dengan TDD. Yang tidak bisa ditawar adalah hasilnya: kode produksi punya test, apa pun urutan menulisnya.
Test yang baik: FIRST
- Fast — milidetik, agar dijalankan sesering mungkin.
- Isolated — tidak bergantung pada test lain atau urutan eksekusi.
- Repeatable — hasil sama di mana pun dan kapan pun; tanpa ketergantungan pada jam sistem, jaringan, atau data acak yang tidak dikendalikan.
- Self-validating — lulus/gagal ditentukan assertion, bukan dengan membaca output di konsol.
- Timely — ditulis bersamaan dengan kodenya (atau sebelumnya, di TDD), bukan "nanti kalau sempat".
Contoh di dunia backend
Laporan pesanan harian: dari daftar pesanan, ambil yang sudah dibayar, urutkan dari total terbesar, dan hitung omzetnya. Test-nya memakai extracting untuk urutan ID dan isEqualByComparingTo untuk total uang.
java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
record Order(String id, String status, BigDecimal total) {}
record DailyReport(List<Order> paidOrders, BigDecimal revenue) {}
class DailyReportService {
DailyReport build(List<Order> orders) {
if (orders == null) {
throw new IllegalArgumentException("orders wajib diisi");
}
List<Order> paid = orders.stream()
.filter(order -> order.status().equals("PAID"))
.sorted(Comparator.comparing(Order::total).reversed())
.toList();
BigDecimal revenue = paid.stream()
.map(Order::total)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new DailyReport(paid, revenue);
}
}
class DailyReportServiceTest {
private final DailyReportService service = new DailyReportService();
@Test
@DisplayName("hanya pesanan PAID, urut total terbesar, omzet dijumlahkan")
void buildsReportFromPaidOrders() {
List<Order> orders = List.of(
new Order("ORD-1", "PAID", new BigDecimal("42500.00")),
new Order("ORD-2", "NEW", new BigDecimal("80000")),
new Order("ORD-3", "PAID", new BigDecimal("150000")),
new Order("ORD-4", "CANCELLED", new BigDecimal("99000")));
DailyReport report = service.build(orders);
assertThat(report.paidOrders()).extracting(Order::id).containsExactly("ORD-3", "ORD-1");
assertThat(report.revenue()).isEqualByComparingTo("192500");
}
@Test
@DisplayName("hari tanpa pesanan menghasilkan omzet nol")
void emptyDay() {
DailyReport report = service.build(List.of());
assertThat(report.paidOrders()).isEmpty();
assertThat(report.revenue()).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("menolak input null")
void rejectsNull() {
assertThatIllegalArgumentException()
.isThrownBy(() -> service.build(null))
.withMessage("orders wajib diisi");
}
}Perhatikan nilai 42500.00 (scale 2) dijumlahkan dengan 150000 (scale 0) menjadi 192500.00. Dengan isEqualTo(new BigDecimal("192500")) test akan gagal walau nilainya benar; isEqualByComparingTo membandingkan nilainya saja.
Pertanyaan interview
1. Apa kelebihan AssertJ dibanding assertion bawaan JUnit?
API-nya fluent dan bergantung tipe: assertThat(list) hanya menawarkan pemeriksaan list, sehingga IDE membantu menemukan assertion yang tepat. Pemeriksaan bisa dirangkai, ada fitur khusus seperti extracting, filteredOn, dan soft assertions, dan pesan gagalnya jauh lebih detail — menyebut elemen, indeks, dan nilai yang berbeda. JUnit tetap menjadi test runner-nya; AssertJ hanya menggantikan lapisan assertion.
2. Jelaskan siklus TDD dan manfaatnya.
Red: tulis satu test kecil yang gagal. Green: tulis kode minimal sampai lulus. Refactor: rapikan dengan test tetap hijau. Manfaatnya: setiap perilaku punya test, desain cenderung kecil dan mudah diuji karena kamu memakai API-nya lebih dulu sebagai "klien", dan refactor jadi aman. Biayanya: butuh disiplin, dan kurang efektif saat solusi belum jelas sama sekali.
3. Kenapa BigDecimal sebaiknya dibandingkan dengan isEqualByComparingTo?
BigDecimal.equals membandingkan nilai dan scale, sehingga 10.5 dan 10.50 tidak sama. Hasil perhitungan uang sering punya scale berbeda tergantung operasinya. isEqualByComparingTo memakai compareTo, yang hanya membandingkan nilai numerik — itu yang biasanya dimaksud dalam aturan bisnis.
Jebakan umum
- Memanggil
as(...)setelah method pemeriksa. Deskripsinya diabaikan; letakkan sebelumisEqualTodan kawan-kawan. isEqualTountukBigDecimal. Gagal karena perbedaan scale; pakaiisEqualByComparingTo.assertThat(x)tanpa pemeriksa.assertThat(total);tidak memeriksa apa pun dan selalu lulus.- Melompati langkah red. Test yang belum pernah terlihat gagal bisa jadi tidak menguji apa-apa — misalnya salah objek atau assertion yang selalu benar.
- Langkah green terlalu besar. Menulis seluruh fitur sekaligus menghilangkan umpan balik kecil; tambahkan satu perilaku per siklus.
- Melemahkan test agar hijau. Saat test menemukan bug (seperti NPE zona), perbaiki kode produksinya, jangan ubah harapannya.
Ringkasan
- AssertJ
assertThat(actual)memberi assertion fluent sesuai tipe dan pesan gagal yang menunjuk perbedaan secara tepat. - Untuk collection:
containsExactly(urutan penting),containsExactlyInAnyOrder,extracting, danfilteredOn. assertThatThrownBymemeriksa tipe dan pesan exception;isEqualByComparingTomembandingkanBigDecimaltanpa peduli scale;SoftAssertionsmelaporkan semua kegagalan sekaligus.- TDD: red (satu test gagal) → green (kode minimal) → refactor (rapikan, tetap hijau).
- TDD paling berguna untuk logika bisnis, perbaikan bug, dan refactor; kurang cocok untuk eksplorasi.
- Test yang baik memenuhi FIRST: Fast, Isolated, Repeatable, Self-validating, Timely.