Chapter sebelumnya membangun stub buatan tangan dan menunjukkan konsep memutus dependensi eksternal. Menulis class pengganti manual untuk setiap interface, menandai jawaban, dan memeriksa rekaman panggilan adalah pekerjaan yang berulang persis sama di setiap test — dan pekerjaan yang berulang adalah kandidat otomasi. Mockito adalah framework de facto untuk otomasi tersebut: satu panggilan membuat implementasi buatan dari interface apa pun, beberapa baris memasang jawaban, dan satu statement memverifikasi bahwa interaksi yang diharapkan benar-benar terjadi. Pada Chapter ini kita akan memakai Mockito dalam gaya BDD — given/willReturn, verify, matcher seperti anyString, dan ArgumentCaptor — lalu membangun padanan manualnya dari Java murni agar konsep verifikasi interaksi terlihat telanjang.

Menambahkan Mockito ke Proyek #

Mockito ditambahkan sebagai dependensi test, biasanya bersama integrasi Jupiter-nya:

xml Dependensi Mockito di pom.xml
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.15.2</version>
    <scope>test</scope>
</dependency>

Artefak mockito-junit-jupiter otomatis membawa mockito-core. Dengan ekstensi ini, field mock dapat dibuat langsung lewat anotasi:

java
@ExtendWith(MockitoExtension.class)
class KasirTest {

    @Mock
    GatewayPembayaran pembayaran;

    @InjectMocks
    Kasir kasir;
}

Anotasi @Mock meminta Mockito membuat mock untuk interface GatewayPembayaran, sedangkan @InjectMocks membuat instance Kasir dan menyuntikkan mock ke constructornya.

Membuat Mock dan Memasang Jawaban #

Kode lengkap satu test Mockito bergaya BDD terlihat seperti ini:

java
import static org.mockito.BDDMockito.*;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void pesananBerhasilMenghasilkanStruk() {
    // given: jawaban gateway disiapkan
    given(pembayaran.bayar("PSN-1", 250_000L))
        .willReturn(new HasilBayar(true, "OK-42"));

    // when: perilaku yang diuji
    String struk = kasir.proses(new Pesanan("PSN-1", 250_000L));

    // then: hasil dan interaksi diverifikasi
    assertThat(struk).isEqualTo("STRUK-PSN-1");
    verify(pembayaran).bayar("PSN-1", 250_000L);
}

Di balik layar, Mockito memakai dynamic proxy (untuk interface) atau subclass yang dihasilkan saat runtime (untuk class) untuk membuat implementasi GatewayPembayaran yang mencatat setiap panggilan method beserta argumennya. Ketika given(...) dijalankan, Mockito berada dalam mode perekaman: panggilan yang ditulis di dalam argumen given tidak benar-benar mengeksekusi logika, melainkan didaftarkan sebagai aturan jawaban. Panggilan willReturn melengkapi aturan itu. Gaya given/willReturn identik fungsinya dengan when/thenReturn klasik; gaya BDD hanya membuat urutan kalimatnya terbaca given-when-then.

Matcher Argumen dan Verifikasi Interaksi #

Verifikasi menjadi ringkas berkat matcher bawaan:

java
verify(pembayaran).bayar(eq("PSN-1"), anyLong());   // jumlah berapa pun
verify(pembayaran, times(1)).bayar(anyString(), anyLong());
verify(pembayaran, never()).bayar(eq("PSN-9"), anyLong());

Matcher anyString(), anyLong(), dan eq(...) menggantikan pembandingan eksak; times(1) dan never() mengatur berapa kali panggilan diharapkan. Satu aturan penting: matcher tidak boleh dicampur dengan argumen biasa — begitu satu argumen memakai matcher, argumen lain wajib dibungkus eq(...).

Menangkap Argumen dengan ArgumentCaptor #

Kadang nilai argumen baru diketahui setelah kode berjalan, atau kita ingin memeriksa argumen yang dikonstruksi di dalam method produksi. ArgumentCaptor merekam argumen asli untuk diperiksa belakangan:

java
@Captor
ArgumentCaptor<Long> jumlahCaptor;

@Test
void jumlahPesananDiteruskanKeGateway() {
    kasir.proses(new Pesanan("PSN-1", 250_000L));

    verify(pembayaran).bayar(eq("PSN-1"), jumlahCaptor.capture());
    assertThat(jumlahCaptor.getValue()).isEqualTo(250_000L);
}

Panggilan capture() bertindak sebagai matcher yang menampung argumen aktual ke dalam captor; getValue() mengambil nilai terakhir dan getAllValues() seluruh nilai bila method dipanggil berkali-kali.

Padanan Manual dari Java Murni #

Apa yang sebenarnya dilakukan mock, jawaban, dan verify? Program berikut membangun padanan mini tanpa Mockito: class FakePembayaran merekam setiap panggilan sebagai record, jawaban dapat dipasang ulang antar skenario, dan method pemeriksaan bertindak sebagai verify serta captor:

import java.util.ArrayList;
import java.util.List;

public class ContohVerifikasiInteraksi {

    record HasilBayar(boolean sukses, String kode) {}

    interface GatewayPembayaran {
        HasilBayar bayar(String idPesanan, long jumlah);
    }

    // ---- padanan mock(): merekam semua panggilan, jawaban dapat dipasang ----

    static final class FakePembayaran implements GatewayPembayaran {

        record Panggilan(String idPesanan, long jumlah) {}

        private final List<Panggilan> rekaman = new ArrayList<>();
        private HasilBayar jawabanBerikutnya = new HasilBayar(true, "OK");

        void jawabDengan(HasilBayar hasil) {
            this.jawabanBerikutnya = hasil;
        }

        @Override
        public HasilBayar bayar(String idPesanan, long jumlah) {
            rekaman.add(new Panggilan(idPesanan, jumlah));
            return jawabanBerikutnya;
        }

        // padanan verify(gateway).bayar(id, ...)

        boolean pernahMemanggil(String idPesanan) {
            return rekaman.stream().anyMatch(p -> p.idPesanan().equals(idPesanan));
        }

        // padanan ArgumentCaptor: ambil ulang argumen yang diteruskan

        long jumlahUntuk(String idPesanan) {
            return rekaman.stream()
                .filter(p -> p.idPesanan().equals(idPesanan))
                .findFirst()
                .map(Panggilan::jumlah)
                .orElse(-1L);
        }

        int totalPanggilan() {
            return rekaman.size();
        }
    }

    record Pesanan(String id, long total) {}

    static final class Kasir {

        private final GatewayPembayaran pembayaran;

        Kasir(GatewayPembayaran pembayaran) {
            this.pembayaran = pembayaran;
        }

        String proses(Pesanan pesanan) {
            HasilBayar hasil = pembayaran.bayar(pesanan.id(), pesanan.total());
            if (!hasil.sukses()) {
                throw new IllegalStateException("pembayaran ditolak: " + hasil.kode());
            }
            return "STRUK-" + pesanan.id();
        }
    }

    public static void main(String[] args) {
        FakePembayaran mock = new FakePembayaran();
        mock.jawabDengan(new HasilBayar(true, "OK-42")); // pasang jawaban
        Kasir kasir = new Kasir(mock);

        String struk = kasir.proses(new Pesanan("PSN-1", 250_000L));

        System.out.println("struk                    : " + struk);
        System.out.println("verify PSN-1 dipanggil   : " + mock.pernahMemanggil("PSN-1"));
        System.out.println("verify jumlah diteruskan : " + mock.jumlahUntuk("PSN-1"));
        System.out.println("verify PSN-9 dipanggil   : " + mock.pernahMemanggil("PSN-9"));
        System.out.println("total panggilan          : " + mock.totalPanggilan());

        // skenario kedua: jawaban penolakan dari gateway
        mock.jawabDengan(new HasilBayar(false, "KURANG-SALDO"));
        try {
            kasir.proses(new Pesanan("PSN-2", 500_000L));
        } catch (IllegalStateException e) {
            System.out.println("penolakan                : " + e.getMessage());
        }
        System.out.println("total panggilan          : " + mock.totalPanggilan());
    }
}

Output yang Dihasilkan:

text
struk                    : STRUK-PSN-1
verify PSN-1 dipanggil   : true
verify jumlah diteruskan : 250000
verify PSN-9 dipanggil   : false
total panggilan          : 1
penolakan                : pembayaran ditolak: KURANG-SALDO
total panggilan          : 2

Bedah baris-per-baris untuk bagian intinya:

  1. Baris private final List<Panggilan> rekaman = new ArrayList<>();: inilah memori mock — setiap panggilan bayar disimpan lengkap dengan argumennya, persis rekaman internal yang dipelihara proxy Mockito.
  2. Baris void jawabDengan(HasilBayar hasil): padanan given(...).willReturn(...) — menetapkan jawaban untuk panggilan berikutnya; test kedua memasang jawaban penolakan tanpa membuat mock baru.
  3. Baris rekaman.add(new Panggilan(idPesanan, jumlah));: method yang di-mock tidak berisi logika nyata; satu-satunya pekerjaannya adalah mencatat lalu mengembalikan jawaban yang dipasang.
  4. Baris boolean pernahMemanggil(String idPesanan): padanan verify(gateway).bayar(eq(idPesanan), anyLong()) — memindai rekaman dan melaporkan apakah interaksi tersebut pernah terjadi.
  5. Baris long jumlahUntuk(String idPesanan): padanan ArgumentCaptor — mengambil ulang argumen jumlah yang benar-benar diteruskan kode produksi, sehingga test dapat memastikan nilai yang dikirim ke gateway tepat.
  6. Baris try { kasir.proses(...); } catch (IllegalStateException e): skenario gagal membuktikan jawaban penolakan menjalar menjadi exception di lapisan service — perilaku yang sama yang diuji lewat assertThrows bila memakai JUnit.

Padanan manual di atas memang lebih panjang, tetapi ia memperlihatkan bahwa mocking sebenarnya sederhana: rekaman panggilan, jawaban yang dipasang, dan pemindaian rekaman. Chapter berikutnya memperjelas peta lengkapnya — membedakan stub, mock, fake, dan spy sebagai satu keluarga test double.

Referensi #

Berkas Sumber

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

ContohVerifikasiInteraksi.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.List;

public class ContohVerifikasiInteraksi {

    record HasilBayar(boolean sukses, String kode) {}

    interface GatewayPembayaran {
        HasilBayar bayar(String idPesanan, long jumlah);
    }

    // ---- padanan mock(): merekam semua panggilan, jawaban dapat dipasang ----

    static final class FakePembayaran implements GatewayPembayaran {

        record Panggilan(String idPesanan, long jumlah) {}

        private final List<Panggilan> rekaman = new ArrayList<>();
        private HasilBayar jawabanBerikutnya = new HasilBayar(true, "OK");

        void jawabDengan(HasilBayar hasil) {
            this.jawabanBerikutnya = hasil;
        }

        @Override
        public HasilBayar bayar(String idPesanan, long jumlah) {
            rekaman.add(new Panggilan(idPesanan, jumlah));
            return jawabanBerikutnya;
        }

        // padanan verify(gateway).bayar(id, ...)

        boolean pernahMemanggil(String idPesanan) {
            return rekaman.stream().anyMatch(p -> p.idPesanan().equals(idPesanan));
        }

        // padanan ArgumentCaptor: ambil ulang argumen yang diteruskan

        long jumlahUntuk(String idPesanan) {
            return rekaman.stream()
                .filter(p -> p.idPesanan().equals(idPesanan))
                .findFirst()
                .map(Panggilan::jumlah)
                .orElse(-1L);
        }

        int totalPanggilan() {
            return rekaman.size();
        }
    }

    record Pesanan(String id, long total) {}

    static final class Kasir {

        private final GatewayPembayaran pembayaran;

        Kasir(GatewayPembayaran pembayaran) {
            this.pembayaran = pembayaran;
        }

        String proses(Pesanan pesanan) {
            HasilBayar hasil = pembayaran.bayar(pesanan.id(), pesanan.total());
            if (!hasil.sukses()) {
                throw new IllegalStateException("pembayaran ditolak: " + hasil.kode());
            }
            return "STRUK-" + pesanan.id();
        }
    }

    public static void main(String[] args) {
        FakePembayaran mock = new FakePembayaran();
        mock.jawabDengan(new HasilBayar(true, "OK-42")); // pasang jawaban
        Kasir kasir = new Kasir(mock);

        String struk = kasir.proses(new Pesanan("PSN-1", 250_000L));

        System.out.println("struk                    : " + struk);
        System.out.println("verify PSN-1 dipanggil   : " + mock.pernahMemanggil("PSN-1"));
        System.out.println("verify jumlah diteruskan : " + mock.jumlahUntuk("PSN-1"));
        System.out.println("verify PSN-9 dipanggil   : " + mock.pernahMemanggil("PSN-9"));
        System.out.println("total panggilan          : " + mock.totalPanggilan());

        // skenario kedua: jawaban penolakan dari gateway
        mock.jawabDengan(new HasilBayar(false, "KURANG-SALDO"));
        try {
            kasir.proses(new Pesanan("PSN-2", 500_000L));
        } catch (IllegalStateException e) {
            System.out.println("penolakan                : " + e.getMessage());
        }
        System.out.println("total panggilan          : " + mock.totalPanggilan());
    }
}