Tampilan
Kenapa penting
Hampir setiap service backend bergantung pada sesuatu di luar dirinya: payment gateway, database, layanan email, API pihak ketiga. Bila unit test CheckoutService harus memanggil gateway sungguhan, test jadi lambat, butuh jaringan, bisa menagih kartu sungguhan, dan sulit memaksa skenario "pembayaran ditolak" atau "gateway timeout". Test double menggantikan dependency itu dengan objek tiruan yang bisa kamu kendalikan, dan Mockito adalah library standar de facto untuk membuatnya di dunia Java. Di code review, kamu akan sering melihat (dan menulis) @Mock, when, dan verify — dan juga sering melihat test yang rusak karena terlalu banyak mock.
Konsep inti
Kunci: dependency lewat interface
Test double hanya mudah dipasang bila class bergantung pada interface, bukan pada implementasi konkret. Ini polymorphism dari M02: CheckoutService hanya tahu PaymentGateway; di production yang disuntikkan adalah implementasi Midtrans/Xendit, di test yang disuntikkan adalah tiruan.
java
public interface PaymentGateway {
PaymentResult charge(String orderId, long amount);
}
public record PaymentResult(boolean approved, String transactionId) {}
public interface OrderRepository {
void save(Order order);
}
public record Order(String id, long total, OrderStatus status, String transactionId) {}
public enum OrderStatus { PAID, PAYMENT_FAILED }Dependency diterima lewat constructor (constructor injection), bukan dibuat dengan new di dalam service. Kalau service menulis new MidtransGateway() sendiri, tidak ada celah untuk menyisipkan tiruan.
Lima jenis test double
| Jenis | Isinya | Contoh |
|---|---|---|
| Dummy | Hanya mengisi parameter, tidak pernah dipakai | null atau objek kosong untuk logger |
| Stub | Jawaban tetap yang sudah disiapkan | gateway yang selalu menolak |
| Fake | Implementasi sungguhan tapi ringan | repository di memori berbasis Map |
| Mock | Objek yang mencatat panggilan agar bisa diverifikasi | "pastikan save dipanggil tepat sekali" |
| Spy | Objek (asli atau stub) yang juga merekam panggilan untuknya | gateway yang mencatat setiap charge |
Mockito juga punya spy(objekAsli) — membungkus objek sungguhan sehingga method asli tetap berjalan tetapi panggilannya bisa di-verify. Kebutuhannya jarang; sering kali itu tanda class yang diuji perlu dipecah.
Semua jenis ini bisa ditulis tanpa library. Karena PaymentGateway hanya punya satu method abstrak, stub cukup berupa lambda (M04):
java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
interface PaymentGateway {
PaymentResult charge(String orderId, long amount);
}
record PaymentResult(boolean approved, String transactionId) {}
enum OrderStatus { PAID, PAYMENT_FAILED }
record Order(String id, long total, OrderStatus status, String transactionId) {}
interface OrderRepository {
void save(Order order);
}
// Fake: implementasi sungguhan tapi ringan (memori, bukan database)
class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> byId = new HashMap<>();
@Override
public void save(Order order) {
byId.put(order.id(), order);
}
Optional<Order> findById(String id) {
return Optional.ofNullable(byId.get(id));
}
}
// Spy buatan sendiri: stub + mencatat panggilan
class RecordingGateway implements PaymentGateway {
final List<String> calls = new ArrayList<>();
@Override
public PaymentResult charge(String orderId, long amount) {
calls.add(orderId + ":" + amount);
return new PaymentResult(true, "TRX-" + calls.size());
}
}
class CheckoutService {
private final PaymentGateway gateway;
private final OrderRepository orders;
CheckoutService(PaymentGateway gateway, OrderRepository orders) {
this.gateway = gateway;
this.orders = orders;
}
Order checkout(String orderId, long amount) {
PaymentResult result = gateway.charge(orderId, amount);
OrderStatus status = result.approved() ? OrderStatus.PAID : OrderStatus.PAYMENT_FAILED;
Order order = new Order(orderId, amount, status, result.transactionId());
orders.save(order);
return order;
}
}
public class HandmadeDoubles {
public static void main(String[] args) {
// Stub: jawaban tetap, cukup lambda karena PaymentGateway hanya punya satu method
PaymentGateway alwaysDecline = (orderId, amount) -> new PaymentResult(false, null);
InMemoryOrderRepository repo = new InMemoryOrderRepository();
new CheckoutService(alwaysDecline, repo).checkout("ORD-1", 75_000);
System.out.println(repo.findById("ORD-1").orElseThrow().status());
RecordingGateway spy = new RecordingGateway();
CheckoutService service = new CheckoutService(spy, repo);
service.checkout("ORD-2", 120_000);
service.checkout("ORD-3", 30_000);
System.out.println(spy.calls);
System.out.println(repo.findById("ORD-3").orElseThrow());
}
}Output:
text
PAYMENT_FAILED
[ORD-2:120000, ORD-3:30000]
Order[id=ORD-3, total=30000, status=PAID, transactionId=TRX-2]Menulis tiruan sendiri itu sah dan sering kali terbaik. Mockito membantu ketika interface-nya besar, skenarionya banyak, atau kamu perlu memverifikasi interaksi dengan detail.
Mockito: @Mock, @InjectMocks, dan MockitoExtension
Dependency test yang dibutuhkan: org.mockito:mockito-core dan org.mockito:mockito-junit-jupiter (scope test). Bila kamu memakai spring-boot-starter-test, keduanya sudah ikut.
java
// Potongan — class test JUnit
@ExtendWith(MockitoExtension.class)
class CheckoutServiceTest {
@Mock
PaymentGateway gateway;
@Mock
OrderRepository orders;
@InjectMocks
CheckoutService service;
}@ExtendWith(MockitoExtension.class)— mengaktifkan Mockito untuk class test: membuat mock baru sebelum setiap test, lalu memeriksa penyalahgunaan setelahnya.@Mock— membuat objek tiruan dari interface/class. Setara denganMockito.mock(PaymentGateway.class).@InjectMocks— membuatCheckoutServicedan menyuntikkan mock yang cocok lewat constructor.
Method mock yang belum di-stub tidak melempar error; ia mengembalikan nilai default:
java
import static org.mockito.Mockito.mock;
import java.util.List;
import java.util.Optional;
interface ProductCatalog {
String nameOf(String sku);
long priceOf(String sku);
boolean isActive(String sku);
List<String> tagsOf(String sku);
Optional<String> findSupplier(String sku);
}
public class MockDefaults {
public static void main(String[] args) {
ProductCatalog catalog = mock(ProductCatalog.class);
System.out.println(catalog.nameOf("KOPI-250"));
System.out.println(catalog.priceOf("KOPI-250"));
System.out.println(catalog.isActive("KOPI-250"));
System.out.println(catalog.tagsOf("KOPI-250"));
System.out.println(catalog.findSupplier("KOPI-250"));
}
}Output:
text
null
0
false
[]
Optional.emptyStubbing: when(...).thenReturn dan thenThrow
Stubbing berarti "bila method ini dipanggil dengan argumen ini, jawab begini":
java
// Potongan — di dalam method @Test
when(gateway.charge("ORD-1", 150_000)).thenReturn(new PaymentResult(true, "TRX-77"));
when(gateway.charge(any(), anyLong())).thenThrow(new GatewayTimeoutException("timeout 5s"));thenThrow adalah cara termudah memaksa jalur error — skenario yang hampir mustahil direproduksi dengan gateway sungguhan. Untuk method void, bentuknya dibalik: doThrow(new IllegalStateException()).when(orders).save(any());.
Verifikasi: verify, times, never
Stub mengatur input ke service; verify memeriksa output tidak langsung — panggilan yang dibuat service ke dependency-nya:
java
// Potongan — di dalam method @Test
verify(orders).save(any()); // tepat sekali (default = times(1))
verify(orders, times(2)).save(any()); // tepat dua kali
verify(orders, never()).save(any()); // tidak pernah
verifyNoInteractions(gateway); // mock ini sama sekali tidak disentuhBila harapan tidak terpenuhi, pesan Mockito cukup jelas:
text
Wanted but not invoked:
orderRepository.save(<any>);
-> at VerifyFail.main(VerifyFail.java:12)
Actually, there were zero interactions with this mock.Argument matcher dan ArgumentCaptor
Matcher menggantikan nilai persis: any(), anyLong(), anyString(), eq(...). Aturan pentingnya: bila satu argumen memakai matcher, semua argumen harus matcher. when(gateway.charge("ORD-1", anyLong())) lolos kompilasi, tetapi saat jalan menghasilkan:
text
org.mockito.exceptions.misusing.InvalidUseOfMatchersException:
Invalid use of argument matchers!
2 matchers expected, 1 recorded:Perbaikannya: when(gateway.charge(eq("ORD-1"), anyLong())).
Untuk memeriksa isi object yang dikirim ke mock, pakai ArgumentCaptor, lalu periksa dengan AssertJ:
java
// Potongan — di dalam method @Test
ArgumentCaptor<Order> saved = ArgumentCaptor.forClass(Order.class);
verify(orders).save(saved.capture());
assertThat(saved.getValue().status()).isEqualTo(OrderStatus.PAYMENT_FAILED);BDDMockito sekilas
BDDMockito menyediakan alias yang cocok dengan struktur given–when–then dari bab TDD: given(...).willReturn(...) menggantikan when(...).thenReturn(...), dan then(mock).should().method() menggantikan verify. Perilakunya identik — pilih satu gaya dan konsisten dalam satu project.
java
// Potongan — di dalam method @Test
given(gateway.charge(any(), anyLong())).willReturn(new PaymentResult(true, "TRX-9"));
Order result = service.checkout("ORD-9", 25_000);
then(orders).should().save(result);Contoh di dunia backend
Versi CheckoutService yang lebih lengkap: menolak jumlah tidak valid sebelum menyentuh gateway, menyimpan order gagal bila pembayaran ditolak, dan tidak menyimpan apa pun bila gateway timeout.
java
public class CheckoutService {
private final PaymentGateway gateway;
private final OrderRepository orders;
public CheckoutService(PaymentGateway gateway, OrderRepository orders) {
this.gateway = gateway;
this.orders = orders;
}
public Order checkout(String orderId, long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Jumlah harus positif: " + amount);
}
PaymentResult result = gateway.charge(orderId, amount); // timeout → exception merambat, tidak ada save
if (!result.approved()) {
orders.save(new Order(orderId, amount, OrderStatus.PAYMENT_FAILED, null));
throw new PaymentDeclinedException(orderId);
}
Order paid = new Order(orderId, amount, OrderStatus.PAID, result.transactionId());
orders.save(paid);
return paid;
}
}Test-nya (dijalankan dengan JUnit 6, Mockito 5, dan AssertJ — keempatnya lulus):
java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CheckoutServiceTest {
@Mock
PaymentGateway gateway;
@Mock
OrderRepository orders;
@InjectMocks
CheckoutService service;
@Test
@DisplayName("pembayaran disetujui → order PAID disimpan sekali")
void savesPaidOrderWhenApproved() {
when(gateway.charge("ORD-1", 150_000)).thenReturn(new PaymentResult(true, "TRX-77"));
Order result = service.checkout("ORD-1", 150_000);
assertThat(result.status()).isEqualTo(OrderStatus.PAID);
ArgumentCaptor<Order> saved = ArgumentCaptor.forClass(Order.class);
verify(orders, times(1)).save(saved.capture());
assertThat(saved.getValue().transactionId()).isEqualTo("TRX-77");
}
@Test
@DisplayName("pembayaran ditolak → order PAYMENT_FAILED disimpan lalu exception")
void savesFailedOrderWhenDeclined() {
when(gateway.charge(eq("ORD-2"), anyLong())).thenReturn(new PaymentResult(false, null));
assertThatThrownBy(() -> service.checkout("ORD-2", 90_000))
.isInstanceOf(PaymentDeclinedException.class);
ArgumentCaptor<Order> saved = ArgumentCaptor.forClass(Order.class);
verify(orders).save(saved.capture());
assertThat(saved.getValue().status()).isEqualTo(OrderStatus.PAYMENT_FAILED);
}
@Test
@DisplayName("gateway timeout → tidak ada order yang disimpan")
void doesNotSaveWhenGatewayTimesOut() {
when(gateway.charge(any(), anyLong())).thenThrow(new GatewayTimeoutException("timeout 5s"));
assertThatThrownBy(() -> service.checkout("ORD-3", 50_000))
.isInstanceOf(GatewayTimeoutException.class);
verify(orders, never()).save(any());
}
@Test
@DisplayName("jumlah tidak valid → gateway tidak pernah dipanggil")
void rejectsInvalidAmountWithoutCallingGateway() {
assertThatThrownBy(() -> service.checkout("ORD-4", 0))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(gateway, orders);
}
}Perhatikan test terakhir tidak men-stub gateway. Bila kamu menambahkan when(gateway.charge("ORD-4", 0)).thenReturn(...), test gagal dengan UnnecessaryStubbingException — mode strict stubs milik MockitoExtension menganggap stub yang tidak terpakai sebagai tanda test yang menyesatkan.
Kapan fake buatan sendiri lebih baik
Pakai mock untuk memverifikasi interaksi penting: "email konfirmasi dikirim sekali", "gateway tidak dipanggil bila validasi gagal". Pakai fake bila yang kamu butuhkan adalah perilaku yang konsisten — repository yang bisa simpan lalu dicari, jam (Clock) yang bisa dimajukan, antrean di memori. Fake ditulis sekali dan dipakai ulang di puluhan test; mock dengan belasan when di setiap test cepat menjadi beban.
Peringatan agent saat test jalan di Java 21+
Mockito 5 memakai inline mock maker yang memasang Java agent saat test berjalan. Di JDK modern hal ini memicu peringatan "Mockito is currently self-attaching to enable the inline-mock-maker..." dan "A Java agent has been loaded dynamically". Test tetap lulus, tetapi JDK mendatang akan menolaknya. Dokumentasi Mockito (bagian 0.3) menyarankan mendaftarkan Mockito sebagai -javaagent di maven-surefire-plugin, dibantu goal properties dari maven-dependency-plugin:
xml
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>Pertanyaan interview
1. Apa beda stub dan mock?
Stub menyediakan jawaban yang sudah disiapkan agar kode yang diuji bisa berjalan di skenario tertentu; test tidak memeriksa stub itu sendiri. Mock dipakai untuk verifikasi perilaku: test memeriksa bahwa method tertentu dipanggil dengan argumen tertentu dan jumlah tertentu. Di Mockito, satu objek @Mock bisa berperan sebagai keduanya — when membuatnya stub, verify memakainya sebagai mock.
2. Kenapa sebaiknya tidak me-mock tipe yang bukan milikmu?
Mock dari class library pihak ketiga (HTTP client, SDK cloud) hanya mencerminkan asumsimu tentang perilaku library itu. Bila asumsinya salah atau library berubah, test tetap hijau padahal production rusak. Lebih baik bungkus library itu di balik interface milikmu (misalnya PaymentGateway), mock interface itu di unit test, lalu uji adapter-nya dengan integration test terhadap server tiruan atau container.
3. Apa tanda-tanda over-mocking?
Test berisi lebih banyak when/verify daripada assertion terhadap hasil; refactor internal tanpa mengubah perilaku membuat banyak test merah; value object seperti Order atau Money ikut di-mock; dan test pada dasarnya menulis ulang implementasi baris per baris. Solusinya: mock hanya batas sistem (I/O, jaringan, waktu), pakai objek asli untuk logika murni, dan pertimbangkan fake.
Jebakan umum
- Mencampur nilai mentah dan matcher —
charge("ORD-1", anyLong())melemparInvalidUseOfMatchersException. Bungkus denganeq(...). - Stub yang tidak terpakai — strict stubs melempar
UnnecessaryStubbingException. Hapus stubnya;lenient()hanya untuk kasus yang benar-benar beralasan. - Me-mock semuanya, termasuk record dan value object. Buat saja objek aslinya:
new Order(...)lebih jelas daripada mock dengan limawhen. - Me-mock tipe yang bukan milikmu (SDK, HTTP client). Bungkus di balik interface sendiri, mock interface itu.
verifyuntuk setiap panggilan kecil. Test jadi rapuh terhadap refactor. Verifikasi hanya efek samping yang memang bagian dari kontrak (simpan, kirim, tagih).- Membuat dependency dengan
newdi dalam service — mock tidak bisa disuntikkan. Terima dependency lewat constructor.
Ringkasan
- Test double menggantikan dependency yang lambat/tidak bisa dikendalikan: dummy, stub, fake, mock, spy.
- Desain dengan interface + constructor injection (polymorphism M02) adalah syarat agar test double mudah dipasang.
@ExtendWith(MockitoExtension.class)+@Mock+@InjectMocksmenyiapkan service dengan mock baru di setiap test.when(...).thenReturn/thenThrowmengatur jawaban;verifydengantimes,never, danArgumentCaptormemeriksa interaksi; semua argumen harus matcher bila satu memakai matcher.- Mock untuk interaksi penting di batas sistem; fake untuk perilaku yang dipakai ulang; objek asli untuk logika murni.