Kode yang baik tidak hanya bekerja saat semuanya lancar — kode yang baik juga gagal dengan cara yang dirancang. Menarik saldo melebihi sisa, memasukkan teks bukan angka, membaca berkas yang tidak ada: seluruh jalur gagal ini adalah perilaku yang bisa (dan harus) diuji, sama pentingnya dengan jalur sukses. Tanpa test, kontrak kegagalan hanya hidup di kepala penulis; refactoring mudah mengubah jenis exception, menghilangkan pesannya, atau melupakan cause yang dibutuhkan pemanggil untuk diagnosis. Pada Chapter ini kita akan memakai pola assertThrows, memeriksa pesan dan cause, membangun versi Java murni dari assertThrows, dan menutup dengan peringatan tentang tangkapan yang terlalu luas.
Jalur Gagal adalah Perilaku #
Perlakukan exception yang dirancang sebagai bagian dari kontrak method: jenisnya, pesannya, dan data yang dibawanya. Test yang mengunci kontrak kegagalan memberi dua manfaat. Pertama, kegagalannya sendiri diuji: block if (jumlah > saldo) yang terhapus saat refactoring akan tertangkap. Kedua, pemanggil dilindungi: kode di lapisan atas yang menangkap SaldoKurangException untuk menampilkan pesan ramah akan tetap bekerja karena kontraknya dijaga test.
Pola assertThrows #
JUnit menyediakan assertThrows yang mengeksekusi sebuah blok, lulus bila blok tersebut melempar exception bertipe yang diharapkan, dan mengembalikan instance exception untuk pemeriksaan lanjutan:
@Test
void tarikMelebihiSaldoMelemparSaldoKurang() {
Rekening rekening = new Rekening(100_000L);
SaldoKurangException e = assertThrows(
SaldoKurangException.class,
() -> rekening.tarik(150_000L));
assertEquals("saldo 100000 tidak cukup untuk 150000", e.getMessage());
}Test gagal dalam tiga keadaan: blok tidak melempar exception, exception yang terlempar salah jenis, atau assertion lanjutan atas pesan tidak cocok. Ketiganya dilaporkan dengan pesan yang menyebut penyebab persisnya — bandingkan dengan pola manual try-catch plus flag boolean yang hanya melaporkan “seharusnya melempar”.
Memeriksa Pesan dan Cause #
Untuk exception berlapis, periksa getCause() hingga ke akarnya — cause adalah informasi diagnosis yang paling sering hilang diam-diam saat refactoring:
@Test
void setoranBukanAngkaMembawaCauseParsing() {
Rekening rekening = new Rekening(100_000L);
SetoranTidakSahException e = assertThrows(
SetoranTidakSahException.class,
() -> rekening.setor("dua ribu"));
assertThat(e.getMessage()).contains("setoran bukan angka");
assertThat(e.getCause()).isInstanceOf(NumberFormatException.class);
}Untuk pesan, contains lebih tahan daripada equals penuh: format pesan sering disesuaikan sambil tetap memuat informasi kuncinya. Untuk jenis, gunakan assertion yang menyebut kelas secara eksplisit — bukan pemeriksaan string atas nama class.
assertThrows dari Java Murni #
Bagaimana assertThrows bekerja? Seluruh mekanismenya hanya try-catch plus pemeriksaan jenis. Versi mini berikut membongkar cara kerjanya langkah demi langkah:
public class ContohUjiException {
// ---- exception domain ----
static final class SaldoKurangException extends RuntimeException {
SaldoKurangException(String pesan) {
super(pesan);
}
}
static final class SetoranTidakSahException extends RuntimeException {
SetoranTidakSahException(String pesan, Throwable sebab) {
super(pesan, sebab);
}
}
// ---- kode produksi yang diuji jalur gagalnya ----
static final class Rekening {
private long saldo;
Rekening(long saldoAwal) {
this.saldo = saldoAwal;
}
void tarik(long jumlah) {
if (jumlah <= 0) {
throw new IllegalArgumentException("jumlah tarik harus positif: " + jumlah);
}
if (jumlah > saldo) {
throw new SaldoKurangException(
"saldo " + saldo + " tidak cukup untuk " + jumlah);
}
saldo -= jumlah;
}
void setor(String jumlahTeks) {
long jumlah;
try {
jumlah = Long.parseLong(jumlahTeks);
} catch (NumberFormatException sebab) {
throw new SetoranTidakSahException(
"setoran bukan angka: '" + jumlahTeks + "'", sebab);
}
saldo += jumlah;
}
long saldo() {
return saldo;
}
}
// ---- assertThrows mini ala JUnit ----
static <T extends Throwable> T harusMelempar(Class<T> jenis, Runnable blok) {
try {
blok.run();
} catch (Throwable terlempar) {
if (jenis.isInstance(terlempar)) {
return jenis.cast(terlempar);
}
throw new AssertionError("jenis salah: "
+ terlempar.getClass().getSimpleName()
+ ", diharapkan " + jenis.getSimpleName());
}
throw new AssertionError("tidak ada exception terlempar");
}
public static void main(String[] args) {
Rekening rekening = new Rekening(100_000L);
// test 1: jenis dan pesan exception saldo kurang
SaldoKurangException saldoKurang = harusMelempar(SaldoKurangException.class,
() -> rekening.tarik(150_000L));
System.out.println("jenis : " + saldoKurang.getClass().getSimpleName());
System.out.println("pesan : " + saldoKurang.getMessage());
// test 2: aturan masukan pada jumlah tarik
IllegalArgumentException masukanSalah = harusMelempar(IllegalArgumentException.class,
() -> rekening.tarik(-500L));
System.out.println("pesan : " + masukanSalah.getMessage());
// test 3: setoran tidak sah membawa cause dari parsing
SetoranTidakSahException setoran = harusMelempar(SetoranTidakSahException.class,
() -> rekening.setor("dua ribu"));
System.out.println("pesan : " + setoran.getMessage());
System.out.println("cause : " + setoran.getCause().getClass().getSimpleName()
+ " -> " + setoran.getCause().getMessage());
// seluruh operasi gagal: saldo tidak berubah sama sekali
System.out.println("saldo : " + rekening.saldo());
}
}Output yang Dihasilkan:
jenis : SaldoKurangException
pesan : saldo 100000 tidak cukup untuk 150000
pesan : jumlah tarik harus positif: -500
pesan : setoran bukan angka: 'dua ribu'
cause : NumberFormatException -> For input string: "dua ribu"
saldo : 100000Empat pemeriksaan berhasil dan saldo utuh karena seluruh operasi memang ditolak. Bedah baris-per-baris untuk bagian kuncinya:
- Baris
static <T extends Throwable> T harusMelempar(Class<T> jenis, Runnable blok): generic method menerima class exception dan blok lambda; type parameterTmembuat nilai balik langsung bertipe exception yang diharapkan tanpa cast manual. - Baris
if (jenis.isInstance(terlempar)): pemeriksaan jenis memakaiClass.isInstance, sehingga subkelas dari jenis yang diharapkan juga diterima — perilaku yang sama denganassertThrowsJUnit. - Baris
return jenis.cast(terlempar);: instance dikembalikan ke pemanggil untuk pemeriksaan pesan dan cause — inilah alasanassertThrowsmengembalikan nilai, bukan sekadar void. - Baris
throw new AssertionError("tidak ada exception terlempar");: jalur gagal ketika blok ternyata sehat; pesan eksplisit menghindari laporan kosong yang membingungkan. - Baris
catch (NumberFormatException sebab)disetor: kode produksi membungkus exception parsing menjadi exception domain sambil menyimpansebabsebagai cause; test ketiga memverifikasi rantai ini sampai akarnya.
Jebakan Pola try-catch Manual #
Sebelum assertThrows ada, pola umumnya seperti ini:
boolean melempar = false;
try {
rekening.tarik(150_000L);
} catch (SaldoKurangException e) {
melempar = true;
}
assertTrue(melempar, "tarik melebihi saldo seharusnya melempar");Pola ini rapuh karena AssertionError dari assertion di dalam try ikut tertangkap catch yang kebetulan lebih luas, dan pengecekan jenis exception terjadi di dua tempat terpisah. Bila terpaksa manual (misalnya di test berbasis runner buatan sendiri), pastikan catch menyebut jenis persis dan blok try hanya berisi satu panggilan yang diuji.
Exit Code sebagai Sinyal Gagal #
Aplikasi command-line tidak melempar exception ke pengguna — aplikasi CLI melaporkari kegagalan lewat exit code bukan nol, dan pipeline otomasi membaca nilai itu. Test untuk CLI karenanya berjalan di level proses: jalankan program dengan argumen tertentu, lalu pastikan exit code sesuai kontrak (misalnya 1 untuk masukan tidak sah, 2 untuk berkas tidak ditemukan). Java mencapainya dengan System.exit(kode); perlu diingat panggilan tersebut mengakhiri JVM seketika, sehingga dalam test satu proses, kegagalan lebih baik dibangun sebagai exception yang diterjemahkan menjadi exit code hanya di lapisan main paling luar.
Kontrak kegagalan yang teruji membuat kode aman diubah. Selanjutnya kita mengukur seberapa jauh test menjangkau kode lewat test coverage — dan belajar membaca angkanya dengan kepala dingin.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ContohUjiException.java
Unduh berkaspublic class ContohUjiException {
// ---- exception domain ----
static final class SaldoKurangException extends RuntimeException {
SaldoKurangException(String pesan) {
super(pesan);
}
}
static final class SetoranTidakSahException extends RuntimeException {
SetoranTidakSahException(String pesan, Throwable sebab) {
super(pesan, sebab);
}
}
// ---- kode produksi yang diuji jalur gagalnya ----
static final class Rekening {
private long saldo;
Rekening(long saldoAwal) {
this.saldo = saldoAwal;
}
void tarik(long jumlah) {
if (jumlah <= 0) {
throw new IllegalArgumentException("jumlah tarik harus positif: " + jumlah);
}
if (jumlah > saldo) {
throw new SaldoKurangException(
"saldo " + saldo + " tidak cukup untuk " + jumlah);
}
saldo -= jumlah;
}
void setor(String jumlahTeks) {
long jumlah;
try {
jumlah = Long.parseLong(jumlahTeks);
} catch (NumberFormatException sebab) {
throw new SetoranTidakSahException(
"setoran bukan angka: '" + jumlahTeks + "'", sebab);
}
saldo += jumlah;
}
long saldo() {
return saldo;
}
}
// ---- assertThrows mini ala JUnit ----
static <T extends Throwable> T harusMelempar(Class<T> jenis, Runnable blok) {
try {
blok.run();
} catch (Throwable terlempar) {
if (jenis.isInstance(terlempar)) {
return jenis.cast(terlempar);
}
throw new AssertionError("jenis salah: "
+ terlempar.getClass().getSimpleName()
+ ", diharapkan " + jenis.getSimpleName());
}
throw new AssertionError("tidak ada exception terlempar");
}
public static void main(String[] args) {
Rekening rekening = new Rekening(100_000L);
// test 1: jenis dan pesan exception saldo kurang
SaldoKurangException saldoKurang = harusMelempar(SaldoKurangException.class,
() -> rekening.tarik(150_000L));
System.out.println("jenis : " + saldoKurang.getClass().getSimpleName());
System.out.println("pesan : " + saldoKurang.getMessage());
// test 2: aturan masukan pada jumlah tarik
IllegalArgumentException masukanSalah = harusMelempar(IllegalArgumentException.class,
() -> rekening.tarik(-500L));
System.out.println("pesan : " + masukanSalah.getMessage());
// test 3: setoran tidak sah membawa cause dari parsing
SetoranTidakSahException setoran = harusMelempar(SetoranTidakSahException.class,
() -> rekening.setor("dua ribu"));
System.out.println("pesan : " + setoran.getMessage());
System.out.println("cause : " + setoran.getCause().getClass().getSimpleName()
+ " -> " + setoran.getCause().getMessage());
// seluruh operasi gagal: saldo tidak berubah sama sekali
System.out.println("saldo : " + rekening.saldo());
}
}