Test Doubles: Memahami Mocks, Stubs, dan Spies untuk Unit Testing yang Efektif
1. Pendahuluan
Sebagai developer, kita semua tahu pentingnya testing. Unit testing, khususnya, adalah fondasi untuk membangun aplikasi yang kokoh dan bebas bug. Namun, seringkali kita menghadapi tantangan ketika unit kode yang ingin kita uji memiliki banyak ketergantungan (dependencies) pada bagian lain dari sistem, seperti database, API eksternal, atau layanan mikro lainnya.
Menguji unit kode secara terisolasi menjadi sulit jika kita harus menyiapkan seluruh lingkungan atau memanggil layanan eksternal yang lambat atau tidak stabil. Di sinilah Test Doubles berperan. Mirip dengan “stunt double” dalam film yang menggantikan aktor utama untuk adegan berbahaya, Test Doubles adalah objek pengganti yang kita gunakan dalam unit test untuk mengisolasi unit kode yang sedang diuji dari ketergantungan nyatanya.
Artikel ini akan membawa Anda menyelami dunia Test Doubles: Mocks, Stubs, dan Spies. Kita akan memahami perbedaan fundamental di antara ketiganya, kapan dan mengapa kita harus menggunakannya, serta bagaimana mengimplementasikannya dengan contoh praktis menggunakan JavaScript dan Jest. Tujuannya? Agar Anda bisa menulis unit test yang lebih bersih, cepat, dan lebih andal! 🚀
2. Apa Itu Test Doubles?
Secara umum, Test Doubles adalah istilah payung untuk semua jenis objek pengganti yang digunakan dalam testing. Tujuannya adalah untuk:
- Mengisolasi Unit of Work: Memastikan bahwa unit yang diuji hanya menguji logikanya sendiri, bukan perilaku dari ketergantungannya.
- Mengontrol Lingkungan Tes: Memungkinkan kita untuk mensimulasikan skenario tertentu (misalnya, kegagalan jaringan, data kosong) yang sulit atau tidak mungkin direplikasi dengan objek nyata.
- Mempercepat Tes: Menghindari panggilan ke database atau API eksternal yang lambat.
- Membuat Tes Lebih Andal: Menghilangkan flakiness yang disebabkan oleh ketergantungan eksternal yang tidak stabil.
Ada beberapa jenis Test Doubles, tetapi yang paling umum dan sering membingungkan adalah Stub, Mock, dan Spy. Mari kita bedah satu per satu.
3. Stub: Memberikan Jawaban yang Sudah Ditetapkan
📌 Definisi: Stub adalah objek pengganti yang menyediakan respons yang sudah ditentukan sebelumnya (pre-programmed responses) ketika metode tertentu dipanggil. Stub tidak peduli bagaimana ia dipanggil atau berapa kali, ia hanya memberikan data yang diminta.
🎯 Kapan Menggunakan Stub? Anda menggunakan stub ketika unit kode yang sedang Anda uji membutuhkan data dari ketergantungannya, tetapi Anda tidak ingin menggunakan objek nyata yang mungkin lambat atau tidak stabil.
Contoh Kasus Nyata:
Misalnya, Anda memiliki sebuah fungsi getUsersReport yang mengambil daftar pengguna dari UserService dan kemudian memprosesnya. Anda ingin menguji getUsersReport tanpa benar-benar memanggil database atau API pengguna.
// user-service.js
class UserService {
async fetchAllUsers() {
// Bayangkan ini memanggil database atau API eksternal
console.log("Memanggil database untuk fetchAllUsers...");
return [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
];
}
}
// report-generator.js
class ReportGenerator {
constructor(userService) {
this.userService = userService;
}
async getUsersReport() {
const users = await this.userService.fetchAllUsers();
const adminCount = users.filter(user => user.role === 'admin').length;
const userCount = users.filter(user => user.role === 'user').length;
return `Total users: ${users.length}, Admins: ${adminCount}, Regular Users: ${userCount}`;
}
}
Sekarang, mari kita uji getUsersReport menggunakan stub untuk UserService:
// report-generator.test.js
const { ReportGenerator } = require('./report-generator');
const { UserService } = require('./user-service');
describe('ReportGenerator', () => {
it('should generate a correct user report', async () => {
// 💡 Membuat stub untuk UserService
const mockUserService = {
fetchAllUsers: jest.fn(() => Promise.resolve([
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
{ id: 3, name: 'Charlie', role: 'user' },
])),
};
const generator = new ReportGenerator(mockUserService);
const report = await generator.getUsersReport();
expect(report).toBe('Total users: 3, Admins: 1, Regular Users: 2');
// Opsional: Memastikan stub dipanggil (meskipun ini lebih mirip mock)
expect(mockUserService.fetchAllUsers).toHaveBeenCalledTimes(1);
});
});
Pada contoh di atas, mockUserService bertindak sebagai stub. Kita “memprogram” metode fetchAllUsers untuk selalu mengembalikan array pengguna tertentu. Dengan begitu, kita bisa menguji logika getUsersReport tanpa khawatir tentang performa atau ketersediaan database.
4. Mock: Memverifikasi Interaksi
📌 Definisi: Mock adalah objek pengganti yang tidak hanya menyediakan respons yang ditentukan sebelumnya, tetapi juga memverifikasi bahwa metode tertentu dipanggil dengan argumen yang benar, berapa kali, atau dalam urutan tertentu. Mock peduli tentang bagaimana dan kapan metode dipanggil.
🎯 Kapan Menggunakan Mock? Anda menggunakan mock ketika Anda ingin menguji interaksi antara unit yang sedang diuji dengan ketergantungannya. Anda ingin memastikan bahwa unit Anda memicu panggilan yang benar pada objek lain.
Contoh Kasus Nyata:
Bayangkan Anda memiliki fungsi processOrder yang, setelah berhasil memproses pesanan, harus mengirim notifikasi email. Anda ingin memastikan bahwa fungsi sendEmail dari NotificationService dipanggil ketika processOrder berhasil.
// notification-service.js
class NotificationService {
async sendEmail(to, subject, body) {
// Bayangkan ini memanggil API email eksternal
console.log(`Mengirim email ke ${to}: ${subject}`);
// ... logika pengiriman email
return true;
}
}
// order-processor.js
class OrderProcessor {
constructor(notificationService) {
this.notificationService = notificationService;
}
async processOrder(orderId, customerEmail) {
// ... logika pemrosesan pesanan
const isOrderProcessed = true; // Anggap pesanan berhasil diproses
if (isOrderProcessed) {
await this.notificationService.sendEmail(
customerEmail,
`Pesanan #${orderId} Berhasil Diproses`,
`Halo, pesanan Anda dengan ID ${orderId} telah berhasil diproses.`
);
return true;
}
return false;
}
}
Sekarang, mari kita uji processOrder menggunakan mock untuk NotificationService:
// order-processor.test.js
const { OrderProcessor } = require('./order-processor');
const { NotificationService } = require('./notification-service');
describe('OrderProcessor', () => {
it('should send an email notification after processing an order', async () => {
// 💡 Membuat mock untuk NotificationService
const mockNotificationService = {
sendEmail: jest.fn(() => Promise.resolve(true)), // Stubbing the return value
};
const processor = new OrderProcessor(mockNotificationService);
const orderId = 'ORD123';
const customerEmail = 'customer@example.com';
await processor.processOrder(orderId, customerEmail);
// ✅ Memverifikasi bahwa sendEmail dipanggil dengan argumen yang benar
expect(mockNotificationService.sendEmail).toHaveBeenCalledTimes(1);
expect(mockNotificationService.sendEmail).toHaveBeenCalledWith(
customerEmail,
`Pesanan #${orderId} Berhasil Diproses`,
expect.stringContaining(`ID ${orderId} telah berhasil diproses.`)
);
});
it('should not send an email if order processing fails', async () => {
const mockNotificationService = {
sendEmail: jest.fn(() => Promise.resolve(true)),
};
// ⚠️ Dalam skenario ini, kita harus memodifikasi processOrder
// atau membuat mock yang lebih canggih untuk mensimulasikan kegagalan
// Untuk contoh ini, kita asumsikan ada kondisi kegagalan
// Mari kita buat skenario sederhana di mana kita tidak memanggilnya
const processor = new OrderProcessor(mockNotificationService);
// Anggap proses gagal, jadi tidak perlu memanggil sendEmail
// (Dalam kode nyata, Anda akan memicu kegagalan di processOrder)
// await processor.processOrder('FAIL123', 'fail@example.com'); // Ini akan memanggil sendEmail jika isOrderProcessed selalu true
expect(mockNotificationService.sendEmail).not.toHaveBeenCalled();
});
});
Di sini, mockNotificationService adalah mock. Kita tidak hanya memberinya respons (mengembalikan true), tetapi yang lebih penting, kita memverifikasi bahwa sendEmail dipanggil dengan parameter yang diharapkan.
5. Spy: Mengamati Tanpa Mengganggu
📌 Definisi: Spy adalah objek pengganti yang mengamati (memata-matai) panggilan ke metode objek nyata tanpa mengubah perilakunya. Namun, Anda juga bisa mengubah perilaku metode yang di-spy jika diperlukan. Spy memungkinkan Anda untuk tetap menggunakan implementasi asli dari suatu metode, tetapi tetap dapat memeriksa apakah metode tersebut dipanggil, berapa kali, dan dengan argumen apa.
🎯 Kapan Menggunakan Spy? Anda menggunakan spy ketika Anda ingin menguji bagian dari sistem yang memanggil metode pada objek yang sudah ada, dan Anda ingin memastikan panggilan tersebut terjadi, tetapi Anda juga ingin metode asli tetap dieksekusi. Ini berguna ketika Anda tidak ingin sepenuhnya mengganti objek, tetapi hanya ingin “mengintip” interaksinya.
Contoh Kasus Nyata:
Anda memiliki sebuah utilitas Logger yang mencatat pesan ke konsol. Anda ingin menguji sebuah fungsi doSomething yang memanggil Logger.log, dan Anda ingin memastikan Logger.log dipanggil, tetapi Anda juga ingin melihat pesan lognya di konsol saat menjalankan tes secara manual.
// logger.js
class Logger {
log(message) {
console.log(`[LOG]: ${message}`);
}
error(message) {
console.error(`[ERROR]: ${message}`);
}
}
// some-module.js
class SomeModule {
constructor(logger) {
this.logger = logger;
}
doSomething(value) {
this.logger.log(`Melakukan sesuatu dengan ${value}`);
// ... logika lainnya
return value * 2;
}
}
Sekarang, mari kita uji doSomething menggunakan spy untuk Logger:
// some-module.test.js
const { SomeModule } = require('./some-module');
const { Logger } = require('./logger');
describe('SomeModule', () => {
it('should log a message when doSomething is called', () => {
const realLogger = new Logger();
// 💡 Membuat spy pada metode 'log' dari objek realLogger
const logSpy = jest.spyOn(realLogger, 'log');
const module = new SomeModule(realLogger);
const result = module.doSomething(5);
expect(result).toBe(10);
// ✅ Memverifikasi bahwa metode 'log' dipanggil
expect(logSpy).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith('Melakukan sesuatu dengan 5');
// Membersihkan spy setelah tes selesai
logSpy.mockRestore();
});
it('should be able to mock the implementation of a spied method', () => {
const realLogger = new Logger();
const logSpy = jest.spyOn(realLogger, 'log');
// 🎯 Mengubah implementasi metode 'log' yang di-spy
logSpy.mockImplementation((message) => {
console.log(`[SPY LOG]: ${message.toUpperCase()}`);
});
const module = new SomeModule(realLogger);
module.doSomething(10);
expect(logSpy).toHaveBeenCalledWith('Melakukan sesuatu dengan 10');
// Anda akan melihat '[SPY LOG]: MELAKUKAN SESUATU DENGAN 10' di konsol
// bukan '[LOG]: Melakukan sesuatu dengan 10'
logSpy.mockRestore();
});
});
Dalam contoh ini, jest.spyOn membuat spy pada metode log dari realLogger. doSomething akan memanggil realLogger.log seperti biasa, tetapi kita juga bisa memverifikasi panggilannya. Keunggulan spy adalah kita bisa tetap mempertahankan perilaku asli metode jika kita mau, atau kita bisa menggantinya (mocking the implementation) jika diperlukan untuk skenario tes tertentu.
6. Memilih Test Double yang Tepat: Stub, Mock, atau Spy?
Memilih Test Double yang tepat adalah kunci untuk menulis unit test yang efektif dan mudah dipelihara. Berikut adalah panduan singkat:
-
Gunakan Stub ketika:
- Unit yang Anda uji membutuhkan data dari ketergantungan.
- Anda hanya peduli dengan input yang diberikan oleh ketergantungan.
- Anda tidak peduli apakah ketergantungan dipanggil atau bagaimana cara kerjanya, hanya ingin ia mengembalikan nilai tertentu.
- Contoh: Mengembalikan data dari database, hasil panggilan API.
-
Gunakan Mock ketika:
- Anda ingin memverifikasi interaksi antara unit yang diuji dengan ketergantungan.
- Anda peduli bahwa metode tertentu dipanggil pada ketergantungan, dengan argumen yang benar, atau sejumlah kali tertentu.
- Mock bertindak sebagai “polisi” yang memastikan unit Anda “berperilaku” benar terhadap ketergantungannya.
- Contoh: Memastikan email dikirim, log ditulis, event dipublikasikan.
-
Gunakan Spy ketika:
- Anda ingin mengamati panggilan ke metode pada objek nyata tanpa mengubah perilakunya (secara default).
- Anda ingin tetap menggunakan implementasi asli dari suatu metode tetapi juga ingin memverifikasi panggilannya.
- Anda ingin sesekali mengubah perilaku metode yang di-spy untuk skenario tes tertentu, tetapi objek dasarnya tetap nyata.
- Contoh: Memverifikasi panggilan ke metode utilitas yang sudah ada, melacak penggunaan fungsi pihak ketiga.
💡 Tips Praktis:
- “Mockist” vs “Classicist”: Ini adalah dua filosofi testing. “Mockist” cenderung menggunakan mock untuk semua ketergantungan dan memverifikasi interaksi. “Classicist” (atau “Detroit School”) lebih suka menggunakan objek nyata sebisa mungkin dan hanya menggunakan stub untuk ketergantungan yang tidak bisa dikontrol. Keduanya memiliki pro dan kontra, dan Anda mungkin akan menemukan diri Anda di tengah-tengah.
- Jangan Over-Mock: Terlalu banyak mocking atau mocking objek yang tidak perlu dapat membuat tes Anda rapuh (brittle) dan sulit dipelihara. Mock hanya ketergantungan yang benar-benar perlu diisolasi atau diverifikasi interaksinya.
- Fokus pada Satu Hal: Setiap unit test sebaiknya fokus pada pengujian satu hal. Jika Anda menemukan diri Anda mengatur banyak mock dan stub untuk satu tes, mungkin unit yang Anda uji terlalu besar atau memiliki terlalu banyak tanggung jawab.
Kesimpulan
Memahami dan menggunakan Test Doubles seperti Mocks, Stubs, dan Spies adalah keterampilan fundamental bagi setiap developer yang ingin menulis unit test yang efektif. Ini memungkinkan kita untuk mengisolasi unit kode, mengontrol lingkungan tes, dan memastikan bahwa logika bisnis kita bekerja dengan benar, terlepas dari kompleksitas atau ketersediaan ketergantungan eksternal.
Meskipun ketiga istilah ini sering digunakan secara bergantian, mengingat perbedaan utama mereka—Stub untuk memberikan data, Mock untuk memverifikasi interaksi, dan Spy untuk mengamati panggilan—akan membantu Anda memilih alat yang tepat untuk pekerjaan yang tepat. Dengan praktik yang tepat, Anda akan membangun test suite yang tidak hanya menangkap bug, tetapi juga berfungsi sebagai dokumentasi hidup dari kode Anda. Selamat menguji! 🎉
🔗 Baca Juga
- Strategi Testing untuk Aplikasi Web Modern: Dari Unit Hingga E2E
- Test-Driven Development (TDD): Membangun Aplikasi yang Lebih Robust dan Bebas Bug dengan Pendekatan Uji Dulu
- Membangun Aplikasi React yang Tangguh: Panduan Unit dan Integration Testing dengan React Testing Library
- Code Review yang Efektif: Meningkatkan Kualitas Kode dan Kolaborasi Tim