Flaky test adalah pengujian perangkat lunak yang menghasilkan status lolos (pass) atau gagal (fail) secara bergantian tanpa adanya perubahan pada basis kode. Masalah ini memperlambat pipeline CI/CD, menurunkan kepercayaan pengembang pada test suite, dan sering kali diselesaikan secara keliru dengan menambahkan retry logic.
Akar masalah utama flaky test pada backend umumnya bukan berasal dari test runner, melainkan arsitektur kode yang mengandalkan mutable shared state, I/O tersembunyi (seperti pemanggilan database atau jam sistem langsung), serta dependensi urutan eksekusi pengujian.
Akar Masalah: Stateful Impure Logic
Kode yang mencampurkan logika bisnis dengan operasi I/O (impure function) menuntut isolasi berbasis mocking. Ketika mock tidak disetel dengan identik, atau ketika ada variabel global/singleton yang termutasi di tengah eksekusi paralel, tes menjadi tidak deterministik.
Perhatikan contoh implementasi layanan diskon berikut yang rentan menghasilkan flaky test:
// checkoutService.ts (Stateful & Impure)
import { db } from './database';
class CheckoutService {
private appliedDiscounts: string[] = [];
async calculateTotal(orderId: string): Promise<number> {
const order = await db.getOrder(orderId);
const now = new Date(); // Hidden side-effect: system clock
// Bergantung pada state instance internal
if (now.getDay() === 5 && !this.appliedDiscounts.includes('FRIDAY')) {
order.total *= 0.9;
this.appliedDiscounts.push('FRIDAY');
}
await db.updateOrder(orderId, order);
return order.total;
}
}Pengujian untuk kode di atas rentan gagal karena:
- Hasil bergantung pada hari saat tes dieksekusi (
new Date()). - State
appliedDiscountstersimpan pada instance class. Jika instance digunakan lintas kasus uji atau dieksekusi paralel, urutan eksekusi memengaruhi hasil. - Membutuhkan mock database yang kompleks untuk memverifikasi logika matematika sederhana.
Pola Solusi: Functional Core, Imperative Shell
Pendekatan functional programming membagi aplikasi menjadi dua bagian tegas:
- Functional Core (Pure Logic): Berisi seluruh aturan bisnis, kalkulasi diskon, validasi, dan transformasi data. Komponen ini murni menggunakan pure functions dan immutable data.
- Imperative Shell (Boundary I/O): Bertanggung jawab membaca data dari database, network, clock, atau environment, lalu meneruskannya ke core, dan menyimpan kembali hasilnya.
Karakteristik Pure Function
Sebuah fungsi dikatakan murni jika memenuhi dua syarat:
- Deterministik: Input yang sama selalu menghasilkan output yang persis sama.
- Tanpa Side Effect: Tidak mengubah variabel di luar scope fungsi, tidak melakukan mutasi argumen yang diterima, dan tidak melakukan operasi I/O langsung.
Refactoring ke Pure Function dan Immutability
Pisahkan kalkulasi harga menjadi fungsi murni tanpa interaksi I/O, database, maupun mutasi state global. Data waktu dan order dilewatkan secara eksplisit sebagai argumen yang bersifat read-only.
// orderLogic.ts (Functional Core - Pure & Immutable)
export interface Order {
readonly id: string;
readonly total: number;
}
export interface PricingContext {
readonly dayOfWeek: number; // 0 = Minggu, 5 = Jumat
readonly existingDiscounts: readonly string[];
}
export interface PricingResult {
readonly updatedTotal: number;
readonly appliedDiscounts: readonly string[];
}
export function applyDiscounts(order: Order, context: PricingContext): PricingResult {
const isFriday = context.dayOfWeek === 5;
const discountAlreadyApplied = context.existingDiscounts.includes('FRIDAY');
if (isFriday && !discountAlreadyApplied) {
return {
updatedTotal: order.total * 0.9,
appliedDiscounts: [...context.existingDiscounts, 'FRIDAY'],
};
}
return {
updatedTotal: order.total,
appliedDiscounts: context.existingDiscounts,
};
}Logika I/O dipindahkan sepenuhnya ke boundary (Imperative Shell):
// checkoutHandler.ts (Imperative Shell)
import { db } from './database';
import { applyDiscounts, Order, PricingContext } from './orderLogic';
export async function processCheckout(orderId: string): Promise<number> {
const rawOrder = await db.getOrder(orderId);
const rawDiscounts = await db.getAppliedDiscounts(orderId);
const context: PricingContext = {
dayOfWeek: new Date().getDay(),
existingDiscounts: Object.freeze([...rawDiscounts]),
};
const result = applyDiscounts(rawOrder, context);
await db.updateOrder(rawOrder.id, { total: result.updatedTotal });
await db.saveDiscounts(rawOrder.id, result.appliedDiscounts);
return result.updatedTotal;
}Unit Test Deterministik Tanpa Mocking
Dengan memindahkan logika ke pure function, unit test tidak membutuhkan jest.mock(), library monkey-patching, atau manipulasi jam global seperti jest.useFakeTimers(). Tes dieksekusi secara instan dan sepenuhnya deterministik di thread mana pun.
// orderLogic.test.ts
import { applyDiscounts, Order, PricingContext } from './orderLogic';
describe('applyDiscounts', () => {
const baseOrder: Order = Object.freeze({
id: 'ord-123',
total: 100000,
});
it('harus memotong harga 10% jika hari Jumat dan diskon belum pernah dipakai', () => {
const context: PricingContext = {
dayOfWeek: 5,
existingDiscounts: [],
};
const result = applyDiscounts(baseOrder, context);
expect(result.updatedTotal).toBe(90000);
expect(result.appliedDiscounts).toContain('FRIDAY');
});
it('tidak boleh memberikan diskon jika bukan hari Jumat', () => {
const context: PricingContext = {
dayOfWeek: 1, // Senin
existingDiscounts: [],
};
const result = applyDiscounts(baseOrder, context);
expect(result.updatedTotal).toBe(100000);
expect(result.appliedDiscounts).toEqual([]);
});
it('tidak boleh menerapkan diskon ganda jika FRIDAY discount sudah ada', () => {
const context: PricingContext = {
dayOfWeek: 5,
existingDiscounts: ['FRIDAY'],
};
const result = applyDiscounts(baseOrder, context);
expect(result.updatedTotal).toBe(100000);
expect(result.appliedDiscounts).toEqual(['FRIDAY']);
});
});Trade-off dan Pertimbangan Desain
Meskipun arsitektur pure function dan immutability secara signifikan menurunkan tingkat kelemahan test suite, terdapat trade-off teknis yang perlu dipertimbangkan:
- Alokasi Memori: Immutability menghasilkan objek baru pada setiap perubahan state. Pada aplikasi pemrosesan throughput ekstrem dengan payload masif, alokasi objek sementara dapat membebani garbage collector jika tidak dioptimalkan.
- Duplikasi Skema Data: Pemisahan ketat antara entitas persistensi (ORM) dan representasi domain murni memerlukan lapisan pemetaan (mapping) eksplisit di boundary.
- Cakupan Integrasi: Pure functions menjamin unit test deterministik, tetapi tetap memerlukan integration test berbobot ringan di Imperative Shell untuk memvalidasi interaksi database riil.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!