Unit test yang ideal berjalan dalam milidetik, tetapi kode nyata jarang sesederhana itu. Layanan pendaftaran yang kita tulis mungkin perlu mengirim email lewat SMTP, menyimpan data ke database, atau memanggil API pihak ketiga — dan seluruh dependensi itu lambat, butuh jaringan, atau tidak deterministik. Memakai implementasi sungguhan di unit test membuat suite lambat dan rapuh; solusinya adalah mengganti dependensi tersebut dengan pengganti ringan saat test berjalan. Pada Chapter ini kita akan memahami mengapa dependensi eksternal perlu diputus, melihat bagaimana injeksi dependensi membuka pintu penggantian, membangun stub buatan tangan dari Java murni, lalu menimbang dua aliran pemikiran pengujian dan bahaya mocking yang berlebihan.

Mengapa Kita Memutus Dependensi #

Klasifikasikan dependensi menjadi dua golongan besar. Dependensi murni — function atau class yang hasilnya hanya ditentukan masukannya — tidak perlu diputus; cukup uji langsung seperti biasa. Dependensi eksternal — SMTP server, database, jam sistem, API pembayaran — menimbulkan tiga masalah sekaligus di unit test. Pertama, kecepatan: satu panggilan SMTP bisa memakan ratusan milidetik, dikali ribuan test menjadi menit. Kedua, determinisme: server bisa menolak, waktu jaringan berubah-ubah, dan kuota bisa habis. Ketiga, biaya operasional: test harus punya kredensial, mengirim email sungguhan ke alamat orang nyata, dan meninggalkan jejak di sistem pihak ketiga.

Mengganti dependensi eksternal dengan pengganti terkendali menjadikan test cepat, deterministik, dan aman dijalankan kapan saja. Pengganti itulah keluarga test double — stub, mock, fake, dan spy — yang akan kita bedah satu per satu di Chapter Stubs, Mocks, dan Fakes.

Injeksi Dependensi sebagai Pintu #

Sebelum dependensi bisa diganti, kode harus terstruktur sehingga implementasinya datang dari luar. Teknik standarnya adalah constructor injection: class menerima dependensi lewat parameter constructor yang bertipe interface, bukan membuat implementasinya sendiri:

java
public class LayananPendaftaran {

    private final GatewayNotifikasi notifikasi;

    public LayananPendaftaran(GatewayNotifikasi notifikasi) {
        this.notifikasi = notifikasi;
    }
}

Interface GatewayNotifikasi menjadi kontrak: produksi memasang implementasi SMTP sungguhan, test memasang pengganti ringan. Inilah alasan method new di dalam constructor sering disebut sebagai perekat yang menambatkan class pada satu implementasi — penambatan itu yang tidak kita inginkan pada titik batas sistem.

Stub Buatan Tangan #

Mari kita uji LayananPendaftaran tanpa menyentuh SMTP sama sekali. Kontrak dan class produksinya:

public interface GatewayNotifikasi {

    void kirim(String emailTujuan, String isi);
}
public class LayananPendaftaran {

    private final GatewayNotifikasi notifikasi;

    public LayananPendaftaran(GatewayNotifikasi notifikasi) {
        this.notifikasi = notifikasi;
    }

    public String daftar(String nama, String email) {
        if (nama == null || nama.isBlank()) {
            throw new IllegalArgumentException("nama tidak boleh kosong");
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("email tidak valid: " + email);
        }
        String id = "REG-" + nama.toUpperCase().replace(" ", "-");
        notifikasi.kirim(email, "Selamat datang, " + nama + "! ID Anda " + id + ".");
        return id;
    }
}

Pengganti ringan berikut mengimplementasikan interface yang sama, mencatat panggilan ke memori, dan mengekspos catatannya lewat getter:

public class StubNotifikasi implements GatewayNotifikasi {

    private int jumlahPanggilan = 0;
    private String emailTerakhir;
    private String isiTerakhir;

    @Override
    public void kirim(String emailTujuan, String isi) {
        jumlahPanggilan++;
        emailTerakhir = emailTujuan;
        isiTerakhir = isi;
    }

    public int jumlahPanggilan() {
        return jumlahPanggilan;
    }

    public String emailTerakhir() {
        return emailTerakhir;
    }

    public String isiTerakhir() {
        return isiTerakhir;
    }
}

Dengan pengganti itu, dua skenario pengujian dapat berjalan murni di memori — pendaftaran valid yang mengirim sambutan, dan email tidak valid yang harus ditolak tanpa mengirim apa pun:

public class ContohIsolasi {

    private static int lulus = 0;
    private static int gagal = 0;

    private static void periksa(String namaUji, boolean kondisi, String pesan) {
        if (kondisi) {
            lulus++;
            System.out.println("LULUS  " + namaUji);
        } else {
            gagal++;
            System.out.println("GAGAL  " + namaUji + " -> " + pesan);
        }
    }

    public static void main(String[] args) {
        // test 1: pendaftaran valid mengirim email sambutan
        StubNotifikasi stub = new StubNotifikasi();
        LayananPendaftaran layanan = new LayananPendaftaran(stub);
        String id = layanan.daftar("Budi", "[email protected]");

        periksa("id pendaftaran benar", "REG-BUDI".equals(id), "id aktual " + id);
        periksa("notifikasi terkirim sekali",
            stub.jumlahPanggilan() == 1, "jumlah " + stub.jumlahPanggilan());
        periksa("tujuan email benar",
            "[email protected]".equals(stub.emailTerakhir()),
            "tujuan " + stub.emailTerakhir());

        // test 2: email tidak valid ditolak tanpa notifikasi
        StubNotifikasi stubDua = new StubNotifikasi();
        LayananPendaftaran layananDua = new LayananPendaftaran(stubDua);
        boolean ditolak = false;
        try {
            layananDua.daftar("Siti", "siti-example.com");
        } catch (IllegalArgumentException e) {
            ditolak = true;
        }
        periksa("email tanpa @ ditolak", ditolak, "tidak ada exception");
        periksa("tidak ada notifikasi terkirim",
            stubDua.jumlahPanggilan() == 0, "jumlah " + stubDua.jumlahPanggilan());

        System.out.println("ringkasan: " + lulus + " lulus, " + gagal + " gagal");
    }
}

Output yang Dihasilkan:

text
LULUS  id pendaftaran benar
LULUS  notifikasi terkirim sekali
LULUS  tujuan email benar
LULUS  email tanpa @ ditolak
LULUS  tidak ada notifikasi terkirim
ringkasan: 5 lulus, 0 gagal

Lima pemeriksaan lulus tanpa satu pun paket jaringan keluar. Bedah bagian-bagian pentingnya:

  1. Baris public LayananPendaftaran(GatewayNotifikasi notifikasi): constructor menerima interface, sehingga ContohIsolasi bebas memasang StubNotifikasi tanpa menyentuh kode layanan.
  2. Baris jumlahPanggilan++; di StubNotifikasi.kirim: stub tidak mengirim email — ia hanya mencatat; state internal pengganti menjadi bukti yang nanti diperiksa test.
  3. Baris periksa("notifikasi terkirim sekali", stub.jumlahPanggilan() == 1, ...): test memverifikasi interaksi dengan dependensi, bukan hanya nilai balik method; ini bibit konsep verify yang akan dipertajam Mockito pada Chapter berikutnya.
  4. Baris try { layananDua.daftar(...); } catch (...) { ditolak = true; }: skenario gagal diuji eksplisit — email tanpa @ harus melempar exception.
  5. Baris periksa("tidak ada notifikasi terkirim", stubDua.jumlahPanggilan() == 0, ...): stub kedua dibuat segar untuk test kedua; hitungan nol membuktikan tidak ada efek samping bocor dari skenario gagal.

Dua Sekolah Pengujian #

Komunitas pengujian lama terbelah menjadi dua aliran yang sering disebut sekolah Detroit (classical) dan London (mockist). Sekolah Detroit memakai test double secukupnya — umumnya fake untuk dependensi eksternal — dan lebih menyukai pengujian kelompok class nyata secara bersama agar test mengunci perilaku yang dapat diamati. Sekolah London memecah sistem menjadi unit yang sangat kecil dan memutus hampir semua kolaborator dengan mock, sehingga setiap test hanya menguji satu class dan interaksinya. Aliran London mempercepat lokalisasi kegagalan, tetapi test-nya rapuh terhadap penyusunan ulang internal yang tidak mengubah perilaku.

Keduanya sepakat pada inti yang kita praktikkan di atas: dependensi eksternal diputus, logika murni diuji sungguhan. Perbedaannya seberapa jauh pemutusan itu dibawa ke kolaborator internal.

Bahaya Over-Mocking #

Mock adalah alat yang mudah disalahgunakan. Tiga tanda mocking sudah berlebihan: test gagal setiap kali internal disusun ulang padahal perilaku tidak berubah; test menge-verify puluhan panggilan berurutan yang menjelaskan cara kerja kode, bukan hasilnya; dan object nilai sederhana seperti Money atau Alamat ikut di-mock padahal cukup dibuat biasa. Kebiasaan amannya: mock hanya pada batas arsitektur — I/O, jaringan, waktu, dan layanan pihak ketiga — lalu biarkan object domain nyata ikut serta di dalam test.

Dengan fondasi konsep ini, kita siap memakai Mockito — framework yang membuat stub, mock, dan verifikasi interaksi menjadi pekerjaan beberapa baris.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ContohIsolasi.java

Unduh berkas
java
public class ContohIsolasi {

    private static int lulus = 0;
    private static int gagal = 0;

    private static void periksa(String namaUji, boolean kondisi, String pesan) {
        if (kondisi) {
            lulus++;
            System.out.println("LULUS  " + namaUji);
        } else {
            gagal++;
            System.out.println("GAGAL  " + namaUji + " -> " + pesan);
        }
    }

    public static void main(String[] args) {
        // test 1: pendaftaran valid mengirim email sambutan
        StubNotifikasi stub = new StubNotifikasi();
        LayananPendaftaran layanan = new LayananPendaftaran(stub);
        String id = layanan.daftar("Budi", "[email protected]");

        periksa("id pendaftaran benar", "REG-BUDI".equals(id), "id aktual " + id);
        periksa("notifikasi terkirim sekali",
            stub.jumlahPanggilan() == 1, "jumlah " + stub.jumlahPanggilan());
        periksa("tujuan email benar",
            "[email protected]".equals(stub.emailTerakhir()),
            "tujuan " + stub.emailTerakhir());

        // test 2: email tidak valid ditolak tanpa notifikasi
        StubNotifikasi stubDua = new StubNotifikasi();
        LayananPendaftaran layananDua = new LayananPendaftaran(stubDua);
        boolean ditolak = false;
        try {
            layananDua.daftar("Siti", "siti-example.com");
        } catch (IllegalArgumentException e) {
            ditolak = true;
        }
        periksa("email tanpa @ ditolak", ditolak, "tidak ada exception");
        periksa("tidak ada notifikasi terkirim",
            stubDua.jumlahPanggilan() == 0, "jumlah " + stubDua.jumlahPanggilan());

        System.out.println("ringkasan: " + lulus + " lulus, " + gagal + " gagal");
    }
}

GatewayNotifikasi.java

Unduh berkas
java
public interface GatewayNotifikasi {

    void kirim(String emailTujuan, String isi);
}

LayananPendaftaran.java

Unduh berkas
java
public class LayananPendaftaran {

    private final GatewayNotifikasi notifikasi;

    public LayananPendaftaran(GatewayNotifikasi notifikasi) {
        this.notifikasi = notifikasi;
    }

    public String daftar(String nama, String email) {
        if (nama == null || nama.isBlank()) {
            throw new IllegalArgumentException("nama tidak boleh kosong");
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("email tidak valid: " + email);
        }
        String id = "REG-" + nama.toUpperCase().replace(" ", "-");
        notifikasi.kirim(email, "Selamat datang, " + nama + "! ID Anda " + id + ".");
        return id;
    }
}

StubNotifikasi.java

Unduh berkas
java
public class StubNotifikasi implements GatewayNotifikasi {

    private int jumlahPanggilan = 0;
    private String emailTerakhir;
    private String isiTerakhir;

    @Override
    public void kirim(String emailTujuan, String isi) {
        jumlahPanggilan++;
        emailTerakhir = emailTujuan;
        isiTerakhir = isi;
    }

    public int jumlahPanggilan() {
        return jumlahPanggilan;
    }

    public String emailTerakhir() {
        return emailTerakhir;
    }

    public String isiTerakhir() {
        return isiTerakhir;
    }
}