Kode yang berhasil dikompilasi belum tentu berperilaku benar. Compiler javac hanya memeriksa tipe dan sintaks; kesalahan logika seperti rumus diskon yang salah tetap lolos kompilasi dan baru terlihat saat aplikasi berjalan. Automated test menutup celah tersebut dengan mengeksekusi kode secara terkendali, membandingkan hasil nyata dengan hasil yang diharapkan, lalu melaporkan ketidaksesuaian dalam hitungan detik. Pada Chapter ini kita akan membangun fondasi pengujian: piramida pengujian sebagai peta strategi, perbedaan test-first dan test-after, pola arrange-act-assert, dan sebuah runner pengujian yang kita rakit sendiri dari Java murni supaya framework seperti JUnit tidak lagi terasa seperti kotak tertutup.
Piramida Pengujian #
Strategi pengujian yang sehat berbentuk piramida: banyak unit test di dasar, sedikit integration test di tengah, dan sangat sedikit end-to-end test di puncak. Unit test menguji satu method atau class secara terisolasi sehingga cepat dan lokasi kegagalan mudah dilacak. Integration test menggabungkan beberapa komponen — misalnya service bersama repository — untuk memastikan mereka bekerja sama. End-to-end test menjalankan aplikasi utuh dari luar, paling mendekati pengalaman pengguna, tetapi paling lambat dan paling rapuh terhadap perubahan kecil.
Perbandingan ketiga lapisan dapat dirangkum dalam tabel berikut:
| Aspek | Unit test | Integration test | End-to-end test |
|---|---|---|---|
| Cakupan | Satu method/class | Beberapa komponen | Aplikasi utuh |
| Kecepatan | Milidetik | Detik | Menit |
| Jumlah ideal | Ratusan hingga ribuan | Puluhan | Satu digit |
| Ketahanan terhadap perubahan | Tinggi | Sedang | Rendah |
Kompromi utamanya adalah kecepatan lawan keyakinan. Semakin luas lingkup sebuah test, semakin yakin kita bahwa sistem utuh berjalan, tetapi semakin lambat suite berjalan dan semakin sulit mencari penyebab saat test gagal. Karena itu piramida menyarankan proporsi yang timpang ke bawah: mayoritas keyakinan dibangun dari unit test yang cepat, lalu dilapisi sedikit test bernilai tinggi di atasnya.
Test Dulu atau Test Kemudian #
Ada dua kebiasaan menulis test yang sama-sama sah. Pendekatan test-first (seperti dalam Test-Driven Development) menulis test yang gagal lebih dahulu, baru menulis implementasi sampai test tersebut lulus; test menjadi spesifikasi yang bisa dijalankan. Pendekatan test-after menulis implementasi lebih dahulu, lalu mengunci perilakunya dengan test agar refactor di masa depan tidak mengubah hasil secara tidak sengaja. Test-first unggul saat spesifikasi masih kabur karena test memaksa kita mendefinisikan perilaku secara konkret. Test-after unggul saat kita mengeksplore API yang masih bergerak. Apapun pilihannya, yang terpenting: kode produksi tanpa test adalah kode yang tidak bisa diubah dengan percaya diri.
Pola Arrange-Act-Assert #
Hampir semua unit test yang baik mengikuti pola tiga bagian arrange-act-assert. Bagian arrange menyiapkan data masukan dan kondisi awal; bagian act memanggil satu perilaku yang sedang diuji; bagian assert membandingkan hasil nyata dengan harapan. Pola ini menjaga test tetap fokus pada satu perilaku dan membuat niatnya terbaca sejak baris pertama. Bayangkan test sebagai eksperimen laboratorium: kita menyiapkan alat, melakukan satu tindakan terukur, lalu mengamati hasilnya — bukan serangkaian tindakan yang bercampur.
Runner Pengujian Buatan Sendiri #
Framework pengujian seperti JUnit sering terasa misterius bagi pemula. Padahal intinya sederhana: sebuah program penjalan yang menemukan method uji, mengeksekusi satu per satu, menangkap kegagalan, lalu mencetak ringkasan. Kita akan membuktikannya dengan merakit mini framework dari empat berkas Java murni. KecilAssert menyediakan method assertion yang melempar AssertionError saat perbandingan tidak sesuai:
import java.util.Objects;
public final class KecilAssert {
private KecilAssert() {
// kelas utilitas: tidak perlu instance
}
public static void assertEquals(Object diharapkan, Object aktual) {
if (!Objects.equals(diharapkan, aktual)) {
throw new AssertionError(
"diharapkan '" + diharapkan + "' tetapi aktual '" + aktual + "'");
}
}
public static void assertTrue(boolean kondisi, String pesan) {
if (!kondisi) {
throw new AssertionError("kondisi salah: " + pesan);
}
}
}Kalkulator adalah class produksi yang sedang diuji, sedangkan KalkulatorTest berisi method uji dengan awalan test:
public class Kalkulator {
public int tambah(int a, int b) {
return a + b;
}
public int bagi(int a, int b) {
if (b == 0) {
throw new ArithmeticException("pembagi tidak boleh nol");
}
return a / b;
}
}public class KalkulatorTest {
private final Kalkulator kalkulator = new Kalkulator();
public void testTambahBilanganPositif() {
// arrange: data masukan disiapkan
int a = 2;
int b = 3;
// act: perilaku yang diuji dipanggil
int hasil = kalkulator.tambah(a, b);
// assert: hasil dibandingkan dengan harapan
KecilAssert.assertEquals(5, hasil);
}
public void testTambahNegatif() {
int hasil = kalkulator.tambah(-4, -6);
KecilAssert.assertEquals(-10, hasil);
}
public void testBagiHasilBulat() {
int hasil = kalkulator.bagi(10, 2);
KecilAssert.assertEquals(5, hasil);
}
}Terakhir, RunnerManual adalah penjalan pengujian. Runner ini memakai reflection API untuk menemukan seluruh method di KalkulatorTest, menyaring yang berawalan test, lalu memanggil masing-masing lewat Method.invoke sambil menghitung lulus dan gagal:
import java.lang.reflect.Method;
import java.util.Arrays;
public class RunnerManual {
public static void main(String[] args) {
int lulus = 0;
int gagal = 0;
Method[] semua = KalkulatorTest.class.getDeclaredMethods();
Arrays.sort(semua, (m1, m2) -> m1.getName().compareTo(m2.getName()));
for (Method method : semua) {
if (!method.getName().startsWith("test")) {
continue; // bukan method uji
}
Object uji = new KalkulatorTest();
try {
method.invoke(uji);
lulus++;
System.out.println("LULUS " + method.getName());
} catch (Exception e) {
gagal++;
Throwable sebab = e.getCause() != null ? e.getCause() : e;
System.out.println("GAGAL " + method.getName()
+ " -> " + sebab.getMessage());
}
}
System.out.println("ringkasan: " + lulus + " lulus, " + gagal + " gagal");
}
}Output yang Dihasilkan:
LULUS testBagiHasilBulat
LULUS testTambahBilanganPositif
LULUS testTambahNegatif
ringkasan: 3 lulus, 0 gagalOutput tersebut persis seperti apa yang dilaporkan Maven atau Gradle saat menjalankan JUnit: daftar test yang lulus, pesan kegagalan bila ada, dan ringkasan akhir. Mari kita bedah baris-baris kunci di RunnerManual:
- Baris
Method[] semua = ...getDeclaredMethods(): reflection mengembalikan seluruh method milikKalkulatorTest— termasuk method non-test bila ada — dalam bentuk array objekMethod. - Baris
Arrays.sort(...): spesifikasi Java tidak menjamin urutan array darigetDeclaredMethods(). Runner mengurutkan berdasarkan nama method supaya laporan selalu deterministik, teknik yang juga dipakai framework nyata demi laporan yang stabil. - Baris
if (!method.getName().startsWith("test")): penyaringan method uji berdasarkan konvensi penamaan, cara paling sederhana menandai test tanpa anotasi. - Baris
method.invoke(uji): JVM memanggil method uji pada instance baru. Bila method melempar exception, reflection membungkusnya dalamInvocationTargetException, sehingga penyebab aslinya diambil lewatgetCause(). - Baris
ringkasan: ...: hitungan lulus/gagal dicetak satu kali di akhir — kontrak laporan yang membuat suite dapat diperiksa mesin CI.
Untuk menjalankan berkas-berkas ini di terminal, cukup satu perintah karena source-file launcher Java (JEP 458) mengompilasi sekaligus berkas lain yang dirujuk:
java RunnerManual.javaDari Runner Buatan Sendiri ke Framework Nyata #
Framework JUnit 5 melakukan hal yang sama dengan runner buatan kita, tetapi menandai method uji lewat anotasi @Test alih-alih awalan nama, dan memakai anotasi tambahan untuk lifecycle serta test berparameter. Test KalkulatorTest yang sama dalam wujud JUnit terlihat seperti ini:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class KalkulatorTest {
private final Kalkulator kalkulator = new Kalkulator();
@Test
void tambahBilanganPositif() {
assertEquals(5, kalkulator.tambah(2, 3));
}
@Test
void bagiHasilBulat() {
assertEquals(5, kalkulator.bagi(10, 2));
}
}Dengan fondasi ini, Chapter berikutnya memperdalam lapisan terbawah piramida: cara merancang unit test yang kecil, cepat, terisolasi, dan deterministik.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
Kalkulator.java
Unduh berkaspublic class Kalkulator {
public int tambah(int a, int b) {
return a + b;
}
public int bagi(int a, int b) {
if (b == 0) {
throw new ArithmeticException("pembagi tidak boleh nol");
}
return a / b;
}
}
KalkulatorTest.java
Unduh berkaspublic class KalkulatorTest {
private final Kalkulator kalkulator = new Kalkulator();
public void testTambahBilanganPositif() {
// arrange: data masukan disiapkan
int a = 2;
int b = 3;
// act: perilaku yang diuji dipanggil
int hasil = kalkulator.tambah(a, b);
// assert: hasil dibandingkan dengan harapan
KecilAssert.assertEquals(5, hasil);
}
public void testTambahNegatif() {
int hasil = kalkulator.tambah(-4, -6);
KecilAssert.assertEquals(-10, hasil);
}
public void testBagiHasilBulat() {
int hasil = kalkulator.bagi(10, 2);
KecilAssert.assertEquals(5, hasil);
}
}
KecilAssert.java
Unduh berkasimport java.util.Objects;
public final class KecilAssert {
private KecilAssert() {
// kelas utilitas: tidak perlu instance
}
public static void assertEquals(Object diharapkan, Object aktual) {
if (!Objects.equals(diharapkan, aktual)) {
throw new AssertionError(
"diharapkan '" + diharapkan + "' tetapi aktual '" + aktual + "'");
}
}
public static void assertTrue(boolean kondisi, String pesan) {
if (!kondisi) {
throw new AssertionError("kondisi salah: " + pesan);
}
}
}
RunnerManual.java
Unduh berkasimport java.lang.reflect.Method;
import java.util.Arrays;
public class RunnerManual {
public static void main(String[] args) {
int lulus = 0;
int gagal = 0;
Method[] semua = KalkulatorTest.class.getDeclaredMethods();
Arrays.sort(semua, (m1, m2) -> m1.getName().compareTo(m2.getName()));
for (Method method : semua) {
if (!method.getName().startsWith("test")) {
continue; // bukan method uji
}
Object uji = new KalkulatorTest();
try {
method.invoke(uji);
lulus++;
System.out.println("LULUS " + method.getName());
} catch (Exception e) {
gagal++;
Throwable sebab = e.getCause() != null ? e.getCause() : e;
System.out.println("GAGAL " + method.getName()
+ " -> " + sebab.getMessage());
}
}
System.out.println("ringkasan: " + lulus + " lulus, " + gagal + " gagal");
}
}