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:

AspekUnit testIntegration testEnd-to-end test
CakupanSatu method/classBeberapa komponenAplikasi utuh
KecepatanMilidetikDetikMenit
Jumlah idealRatusan hingga ribuanPuluhanSatu digit
Ketahanan terhadap perubahanTinggiSedangRendah

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:

java KecilAssert.java
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:

java Kalkulator.java
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:

text
LULUS  testBagiHasilBulat
LULUS  testTambahBilanganPositif
LULUS  testTambahNegatif
ringkasan: 3 lulus, 0 gagal

Output 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:

  1. Baris Method[] semua = ...getDeclaredMethods(): reflection mengembalikan seluruh method milik KalkulatorTest — termasuk method non-test bila ada — dalam bentuk array objek Method.
  2. Baris Arrays.sort(...): spesifikasi Java tidak menjamin urutan array dari getDeclaredMethods(). Runner mengurutkan berdasarkan nama method supaya laporan selalu deterministik, teknik yang juga dipakai framework nyata demi laporan yang stabil.
  3. Baris if (!method.getName().startsWith("test")): penyaringan method uji berdasarkan konvensi penamaan, cara paling sederhana menandai test tanpa anotasi.
  4. Baris method.invoke(uji): JVM memanggil method uji pada instance baru. Bila method melempar exception, reflection membungkusnya dalam InvocationTargetException, sehingga penyebab aslinya diambil lewat getCause().
  5. 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:

bash
java RunnerManual.java

Dari 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:

java
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 berkas
java
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;
    }
}

KalkulatorTest.java

Unduh berkas
java
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);
    }
}

KecilAssert.java

Unduh berkas
java
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);
        }
    }
}

RunnerManual.java

Unduh berkas
java
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");
    }
}