Lebih dari sekadar pengujian berbasis contoh. Panduan komprehensif ini menjelajahi pengujian berbasis properti di JavaScript menggunakan fast-check, membantu Anda menemukan lebih banyak bug dengan lebih sedikit kode.
Melampaui Contoh: Tinjauan Mendalam tentang Pengujian Berbasis Properti di JavaScript
Sebagai pengembang perangkat lunak, kita menghabiskan banyak waktu untuk menulis pengujian. Kita dengan cermat membuat pengujian unit, pengujian integrasi, dan pengujian end-to-end untuk memastikan aplikasi kita kuat, andal, dan bebas dari regresi. Paradigma dominan untuk ini adalah pengujian berbasis contoh. Kita memikirkan sebuah input spesifik, dan kita menegaskan sebuah output spesifik. Input `[1, 2, 3]` harus menghasilkan output `6`. Input `"hello"` harus menjadi `"HELLO"`. Namun, pendekatan ini memiliki kelemahan yang tersembunyi: imajinasi kita sendiri.
Bagaimana jika Anda lupa menguji dengan array kosong? Angka negatif? String yang mengandung karakter Unicode? Objek yang bersarang dalam? Setiap kasus tepi yang terlewat adalah potensi bug yang menunggu untuk terjadi. Di sinilah Pengujian Berbasis Properti (PBT) muncul, menawarkan pergeseran paradigma yang kuat yang membantu kita membangun perangkat lunak yang lebih meyakinkan dan tangguh.
Panduan komprehensif ini akan memandu Anda melalui dunia pengujian berbasis properti di JavaScript. Kita akan menjelajahi apa itu, mengapa sangat efektif, dan bagaimana Anda dapat mengimplementasikannya dalam proyek Anda hari ini menggunakan pustaka populer `fast-check`.
Keterbatasan Pengujian Berbasis Contoh Tradisional
Mari kita pertimbangkan sebuah fungsi sederhana yang mengurutkan array angka. Menggunakan kerangka kerja populer seperti Jest atau Vitest, pengujian kita mungkin terlihat seperti ini:
// Fungsi pengurutan yang sederhana (dan sedikit naif)
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// Pengujian berbasis contoh yang tipikal
test('sortNumbers harus mengurutkan array sederhana dengan benar', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Pengujian ini lulus. Kita mungkin menambahkan beberapa blok `it` atau `test` lagi:
- Array yang sudah diurutkan.
- Array dengan angka negatif.
- Array dengan angka nol.
- Array kosong.
- Array dengan angka duplikat (yang sudah kita cakup).
Kita merasa puas. Kita telah mencakup dasar-dasarnya. Tapi apa yang telah kita lewatkan? Bagaimana dengan `[-0, 0]`? Bagaimana dengan `[Infinity, -Infinity]`? Bagaimana dengan array yang sangat besar yang mungkin mencapai batas kinerja atau optimisasi mesin JavaScript yang aneh? Masalah mendasarnya adalah bahwa kita memilih data secara manual. Pengujian kita hanya sebagus contoh yang bisa kita bayangkan, dan manusia terkenal buruk dalam membayangkan semua cara data yang aneh dan menakjubkan dapat terstruktur.
Pengujian berbasis contoh memvalidasi bahwa kode Anda berfungsi untuk beberapa skenario yang dipilih secara manual. Pengujian berbasis properti memvalidasi bahwa kode Anda berfungsi untuk seluruh kelas input.
Apa itu Pengujian Berbasis Properti? Sebuah Pergeseran Paradigma
Pengujian berbasis properti membalikkan naskahnya. Alih-alih menegaskan bahwa input spesifik menghasilkan output spesifik, Anda mendefinisikan sebuah properti umum dari kode Anda yang harus berlaku untuk setiap input yang valid. Kerangka kerja pengujian kemudian menghasilkan ratusan atau ribuan input acak untuk mencoba membuktikan properti Anda salah.
Sebuah "properti" adalah sebuah invarian—aturan tingkat tinggi tentang perilaku fungsi Anda. Untuk fungsi `sortNumbers` kita, beberapa properti mungkin adalah:
- Idempotensi: Mengurutkan array yang sudah diurutkan tidak boleh mengubahnya. `sortNumbers(sortNumbers(arr))` harus sama dengan `sortNumbers(arr)`.
- Invariansi Panjang: Array yang diurutkan harus memiliki panjang yang sama dengan array asli.
- Invariansi Konten: Array yang diurutkan harus berisi elemen yang sama persis dengan array asli, hanya dalam urutan yang berbeda.
- Urutan: Untuk setiap dua elemen yang berdekatan dalam array yang diurutkan, `sorted[i] <= sorted[i+1]`.
Pendekatan ini mengalihkan Anda dari berpikir tentang contoh individual ke berpikir tentang kontrak fundamental dari kode Anda. Pergeseran pola pikir ini sangat berharga untuk merancang API yang lebih baik dan lebih dapat diprediksi.
Komponen Inti dari PBT
Sebuah kerangka kerja pengujian berbasis properti biasanya memiliki dua komponen utama:
- Generator (atau Arbitrari): Ini bertanggung jawab untuk menghasilkan berbagai macam data acak sesuai dengan tipe yang ditentukan (integer, string, array objek, dll.). Mereka cukup pintar untuk menghasilkan tidak hanya data "jalur bahagia" tetapi juga kasus tepi yang rumit seperti string kosong, `NaN`, `Infinity`, dan lainnya.
- Penyusutan (Shrinking): Ini adalah bahan ajaibnya. Ketika kerangka kerja menemukan input yang memalsukan properti Anda (yaitu, menyebabkan kegagalan tes), ia tidak hanya melaporkan input acak yang besar. Sebaliknya, ia secara sistematis mencoba menemukan input terkecil dan paling sederhana yang masih menyebabkan kegagalan. Ini membuat proses debugging menjadi jauh lebih mudah.
Memulai: Mengimplementasikan PBT dengan `fast-check`
Meskipun ada beberapa pustaka PBT di ekosistem JavaScript, `fast-check` adalah pilihan yang matang, kuat, dan terawat dengan baik. Ia terintegrasi dengan mulus dengan kerangka kerja pengujian populer seperti Jest, Vitest, Mocha, dan Jasmine.
Instalasi dan Pengaturan
Pertama, tambahkan `fast-check` ke dependensi pengembangan proyek Anda. Kita akan mengasumsikan Anda menggunakan test runner seperti Jest.
npm install --save-dev fast-check jest
# atau
yarn add --dev fast-check jest
# atau
pnpm add -D fast-check jest
Tes Berbasis Properti Pertama Anda
Mari kita tulis ulang tes `sortNumbers` kita menggunakan `fast-check`. Kita akan menguji properti "urutan" yang kita definisikan sebelumnya: setiap elemen harus lebih kecil dari atau sama dengan elemen yang mengikutinya.
import * as fc from 'fast-check';
// Fungsi yang sama dari sebelumnya
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('output dari sortNumbers harus berupa array yang terurut', () => {
// 1. Deskripsikan properti
fc.assert(
// 2. Definisikan arbitrari (generator input)
fc.property(fc.array(fc.integer()), (data) => {
// `data` adalah array integer yang dihasilkan secara acak
const sorted = sortNumbers(data);
// 3. Definisikan predikat (properti yang akan diperiksa)
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // Properti dipalsukan
}
}
return true; // Properti berlaku untuk input ini
})
);
});
test('pengurutan tidak boleh mengubah panjang array', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
Mari kita uraikan ini:
- `fc.assert()`: Ini adalah pelaksananya. Ini akan menjalankan pemeriksaan properti Anda berkali-kali (100 kali secara default).
- `fc.property()`: Ini mendefinisikan properti itu sendiri. Dibutuhkan satu atau lebih arbitrari sebagai argumen, diikuti oleh fungsi predikat.
- `fc.array(fc.integer())`: Ini adalah arbitrari kita. Ini memberitahu `fast-check` untuk menghasilkan array (`fc.array`) dari integer (`fc.integer()`). `fast-check` akan secara otomatis menghasilkan array dengan panjang yang berbeda, dengan nilai integer yang berbeda (positif, negatif, nol, dll.).
- Predikat: Fungsi anonim `(data) => { ... }` adalah tempat logika kita berada. Ia menerima data yang dihasilkan secara acak dan harus mengembalikan `true` jika properti berlaku atau `false` jika dilanggar. `fast-check` juga mendukung fungsi predikat yang melempar kesalahan saat gagal, yang terintegrasi dengan baik dengan asersi `expect` dari Jest.
Sekarang, alih-alih satu tes dengan satu array yang dipilih secara manual, kita memiliki tes yang memverifikasi logika pengurutan kita terhadap 100 array berbeda yang dihasilkan secara otomatis setiap kali kita menjalankan suite tes kita. Kita telah meningkatkan cakupan pengujian kita secara besar-besaran hanya dengan beberapa baris kode.
Menjelajahi Arbitrari: Menghasilkan Data yang Tepat
Kekuatan PBT terletak pada kemampuannya untuk menghasilkan data yang beragam dan menantang. `fast-check` menyediakan seperangkat arbitrari yang kaya untuk mencakup hampir semua struktur data yang dapat Anda bayangkan.
Arbitrari Dasar
Ini adalah blok bangunan untuk pembuatan data Anda.
- `fc.integer()`, `fc.float()`, `fc.bigInt()`: Untuk angka. Mereka dapat dibatasi, mis., `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()`: Untuk string dari berbagai set karakter.
- `fc.boolean()`: Untuk `true` atau `false`.
- `fc.constant(value)`: Selalu mengembalikan nilai yang sama. Berguna untuk dicampur dengan `fc.oneof`.
- `fc.constantFrom(val1, val2, ...)`: Mengembalikan salah satu dari nilai konstan yang disediakan.
Arbitrari Kompleks dan Gabungan
Anda dapat menggabungkan arbitrari dasar untuk membuat struktur data yang kompleks.
- `fc.array(arbitrary, constraints)`: Menghasilkan array elemen yang dibuat oleh arbitrari yang disediakan. Anda dapat membatasi `minLength` dan `maxLength`.
- `fc.tuple(arb1, arb2, ...)`: Menghasilkan array dengan panjang tetap di mana setiap elemen memiliki tipe yang spesifik dan berbeda.
- `fc.object(shape)`: Menghasilkan objek dengan struktur yang ditentukan. Contoh: `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)`: Menghasilkan nilai dari salah satu arbitrari yang disediakan. Ini sangat baik untuk menguji fungsi yang menangani beberapa tipe data (mis., `string | number`).
- `fc.record({ key: arb, value: arb })`: Menghasilkan objek untuk digunakan sebagai kamus atau peta, di mana kunci dan nilai dihasilkan dari arbitrari.
Membuat Arbitrari Kustom dengan `map` dan `chain`
Terkadang Anda membutuhkan data yang tidak sesuai dengan bentuk standar. `fast-check` memungkinkan Anda membuat arbitrari sendiri dengan mengubah yang sudah ada.
Menggunakan `.map()`
Metode `.map()` mengubah output dari sebuah arbitrari menjadi sesuatu yang lain. Sebagai contoh, mari kita buat arbitrari yang menghasilkan string yang tidak kosong.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// Atau, dengan mengubah array karakter
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
Menggunakan `.chain()`
Metode `.chain()` lebih kuat. Ini memungkinkan Anda membuat arbitrari baru berdasarkan nilai yang dihasilkan dari yang sebelumnya. Ini penting untuk membuat data yang saling berkorelasi.
Bayangkan Anda perlu menghasilkan sebuah array dan kemudian sebuah indeks yang valid untuk array yang sama. Anda tidak dapat melakukan ini dengan dua arbitrari terpisah, karena indeks mungkin di luar batas. `.chain()` memecahkan masalah ini dengan sempurna.
// Hasilkan sebuah array dan indeks yang valid di dalamnya
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Berdasarkan array yang dihasilkan `arr`, buat arbitrari baru untuk indeks
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Kembalikan tuple dari array dan indeks yang dihasilkan
return fc.tuple(fc.constant(arr), indexArb);
});
// Penggunaan dalam sebuah tes
test('mengiris pada indeks yang valid harus berhasil', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// `arr` dan `index` dijamin kompatibel
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
Kekuatan Penyusutan (Shrinking): Debugging Menjadi Mudah
Fitur paling menarik dari pengujian berbasis properti adalah penyusutan (shrinking). Untuk melihatnya beraksi, mari kita buat fungsi yang sengaja dibuat buggy.
// Fungsi ini gagal jika array input berisi angka 42
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('Angka ini tidak diizinkan!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug harus menjumlahkan angka', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Ketika Anda menjalankan tes ini, `fast-check` hampir pasti akan menemukan kasus yang gagal. Tapi ia tidak akan melaporkan array acak pertama yang ditemukannya, yang mungkin sesuatu seperti `[-1024, 500, 42, 987, -2000]`. Laporan kegagalan seperti itu tidak terlalu membantu. Anda harus memeriksanya secara manual untuk menemukan angka `42` yang bermasalah.
Sebaliknya, penyusut (shrinker) `fast-check` akan bekerja. Ia akan melihat kegagalan dan mulai menyederhanakan input:
- Bisakah saya menghapus satu elemen? Coba `[500, 42, 987, -2000]`. Masih gagal. Bagus.
- Bisakah saya menghapus yang lain? Coba `[42, 987, -2000]`. Masih gagal.
- ...dan seterusnya, sampai ia tidak bisa menghapus elemen lagi tanpa membuat tesnya lulus.
- Ia juga akan mencoba membuat angka-angkanya lebih kecil. Bisakah `42` menjadi `0`? Tidak, tesnya lulus. Bisakah menjadi `41`? Tesnya lulus. Ia mempersempitnya.
Laporan kesalahan akhir akan terlihat seperti ini:
Error: Property failed after 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Counterexample: [[42]]
Shrunk 5 time(s)
Got error: Angka ini tidak diizinkan!
Ini memberitahu Anda input yang persis dan minimal yang menyebabkan kegagalan: sebuah array yang hanya berisi angka `[42]`. Ini segera menunjuk Anda ke sumber bug, menghemat waktu dan usaha yang sangat besar dalam proses debugging.
Strategi PBT Praktis dan Contoh Dunia Nyata
PBT bukan hanya untuk fungsi matematika. Ini adalah alat serbaguna yang dapat diterapkan di banyak bidang pengembangan perangkat lunak.
Properti: Fungsi Invers
Jika Anda memiliki fungsi yang mengkodekan data dan fungsi lain yang mendekodekannya, mereka adalah invers satu sama lain. Properti yang bagus untuk diuji adalah bahwa mendekode nilai yang dikodekan harus selalu mengembalikan nilai asli.
// `encode` dan `decode` bisa untuk base64, komponen URI, atau serialisasi kustom
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) harus sama dengan x', () => {
// `fc.jsonValue()` menghasilkan nilai JSON valid apa pun: string, angka, objek, array
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Properti: Idempotensi
Sebuah operasi bersifat idempoten jika menerapkannya berkali-kali memiliki efek yang sama dengan menerapkannya sekali. `f(f(x)) === f(x)`. Ini adalah properti penting untuk hal-hal seperti fungsi pembersihan data atau endpoint `DELETE` di REST API.
// Fungsi yang menghapus spasi di awal/akhir dan menggabungkan spasi ganda
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace harus idempoten', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Properti: Pengujian Berbasis Keadaan (Model-Based)
Ini adalah teknik yang lebih canggih tetapi sangat kuat untuk menguji sistem dengan keadaan internal, seperti komponen UI, keranjang belanja, atau mesin keadaan (state machine). Idenya adalah membuat model perangkat lunak sederhana dari sistem Anda dan serangkaian perintah yang dapat dijalankan pada model Anda dan implementasi nyata. Propertinya adalah bahwa keadaan model dan keadaan sistem nyata harus selalu cocok.
`fast-check` menyediakan `fc.commands` untuk tujuan ini. Mari kita modelkan sebuah penghitung sederhana:
// Implementasi nyata
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// Perintah untuk fast-check
const incrementCmd = fc.command(
// check: fungsi untuk memeriksa apakah perintah dapat dijalankan pada model
(model) => true,
// run: fungsi untuk menjalankan perintah pada model dan sistem nyata
(model, real) => {
model.count++;
real.increment();
expect(real.get()).toBe(model.count);
}
);
const decrementCmd = fc.command(
(model) => true,
(model, real) => {
model.count--;
real.decrement();
expect(real.get()).toBe(model.count);
}
);
test('Counter harus berperilaku sesuai dengan model', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
Dalam tes ini, `fast-check` akan menghasilkan urutan acak dari perintah `increment` dan `decrement`, menjalankannya pada model objek sederhana kita dan kelas `Counter` yang nyata, dan memastikan keduanya tidak pernah berbeda. Ini dapat mengungkap bug halus dalam logika berbasis keadaan yang kompleks yang hampir tidak mungkin ditemukan dengan pengujian berbasis contoh.
Kapan TIDAK Menggunakan Pengujian Berbasis Properti
PBT adalah tambahan yang kuat untuk perangkat pengujian Anda, tetapi bukan pengganti untuk semua bentuk pengujian lainnya. Ini bukan peluru perak.
Pengujian berbasis contoh seringkali lebih baik ketika:
- Menguji aturan bisnis spesifik yang diketahui. Jika perhitungan pajak harus menghasilkan tepat `$10.53` untuk input tertentu, tes berbasis contoh yang sederhana lebih jelas dan lebih langsung. Ini adalah tes regresi untuk persyaratan yang diketahui.
- "Properti"-nya hanyalah "input X menghasilkan output Y". Jika tidak ada aturan tingkat tinggi yang dapat digeneralisasi tentang perilaku fungsi, memaksakan tes berbasis properti bisa lebih rumit daripada manfaatnya.
- Menguji antarmuka pengguna untuk kebenaran visual. Meskipun Anda dapat menguji logika keadaan komponen UI dengan PBT, memeriksa tata letak atau gaya visual tertentu lebih baik ditangani oleh pengujian snapshot atau alat regresi visual.
Strategi yang paling efektif adalah pendekatan hibrida. Gunakan tes berbasis properti untuk menguji stres algoritma, transformasi data, dan logika berbasis keadaan Anda terhadap berbagai kemungkinan. Gunakan tes berbasis contoh tradisional untuk menetapkan persyaratan bisnis spesifik yang kritis dan mencegah regresi pada bug yang diketahui.
Kesimpulan: Berpikir dalam Properti, Bukan Hanya Contoh
Pengujian berbasis properti mendorong pergeseran mendalam dalam cara kita berpikir tentang kebenaran. Ini memaksa kita untuk mundur dari contoh-contoh individual dan mempertimbangkan prinsip-prinsip dasar dan kontrak yang harus dijunjung oleh kode kita. Dengan melakukannya, kita dapat:
- Menemukan kasus tepi yang mengejutkan yang tidak akan pernah terpikirkan oleh kita untuk dituliskan tesnya.
- Mendapatkan kepercayaan yang jauh lebih tinggi pada ketahanan kode kita.
- Menulis tes yang lebih ekspresif yang mendokumentasikan perilaku sistem kita daripada hanya outputnya pada beberapa input.
- Mengurangi waktu debug secara drastis berkat kekuatan penyusutan (shrinking).
Mengadopsi pengujian berbasis properti mungkin terasa asing pada awalnya, tetapi investasinya sangat sepadan. Mulailah dari yang kecil. Pilih fungsi murni di basis kode Anda—satu yang menangani transformasi data atau perhitungan kompleks—dan coba definisikan properti untuknya. Tambahkan satu tes berbasis properti ke proyek Anda berikutnya. Saat Anda menyaksikannya menemukan bug non-trivial pertamanya, Anda akan yakin akan kekuatannya untuk membangun perangkat lunak yang lebih baik dan lebih andal untuk audiens global.
Sumber Daya Tambahan
- Dokumentasi Resmi fast-check
- Memahami Pengujian Berbasis Properti oleh Scott Wlaschin (pengantar klasik yang agnostik bahasa)