Function totalKeranjang di aplikasi toko Budi sudah dua tahun dipakai dan tidak pernah salah. Tetapi setiap kali Siti membacanya, ia butuh waktu lama untuk mengingat arti angka 500000, 0.1, 300000, dan 20000 yang berdiri tanpa penjelasan. Mengubahnya terasa berisiko: kode yang berjalan terasa seperti benda yang tidak boleh disentuh. Kondisi ini biasa disebut takut-refactor, dan akibatnya kode rapi hanya dimiliki proyek baru.

Refactoring adalah kegiatan mengubah struktur kode tanpa mengubah perilakunya — keluarannya untuk masukan yang sama harus identik, sementara bentuk internalnya menjadi lebih mudah dibaca dan dikembangkan. Berbeda dengan menulis fitur, refactoring punya aturan main yang ketat dan urutan langkah yang aman. Chapter ini mempraktikkannya dengan tiga langkah kecil pada totalKeranjang: mengunci perilaku dengan uji, mengganti magic number dengan konstanta bernama, dan mengekstrak function — semuanya dipantau oleh uji yang tetap hijau.

Prinsip: Perilaku Tetap, Struktur Berubah #

Dua kata kunci membedakan refactoring dari perubahan kode lainnya:

  • Perilaku tetap — untuk setiap masukan yang sama, keluaran harus sama persis. Uji lama tidak boleh perlu diubah.
  • Struktur berubah — nama yang lebih jelas, angka yang bernama, function yang terpecah sesuai tugasnya.

Bila keluaran berubah, itu perbaikan fitur — pekerjaan lain yang punya jadwal sendiri. Campur keduanya dalam satu perubahan adalah jebakan paling umum: bila uji gagal setelahnya, kita tidak tahu apakah strukturnya yang salah atau perilakunya yang sengaja diganti. Karena itu satu perubahan hanya boleh punya satu tujuan.

Urutan amannya bisa dipegang sebagai empat langkah: (1) kunci perilaku dengan uji terhadap kode lama, (2) lakukan satu perubahan struktur yang kecil, (3) jalankan uji — harus tetap hijau, (4) baru lanjut ke perubahan kecil berikutnya. Kegagalan apa pun di langkah 3 berarti langkah 2 harus dibatalkan, bukan diperbaiki sambil menyesuaikan uji.

Langkah 1: Kunci Perilaku dengan Uji #

Sebelum menyentuh kode, kita menetapkan terlebih dahulu apa yang “benar”. Ini versi awal yang akan direfaktor:

// keranjang-awal.mjs — versi awal: seluruh aturan harga dalam satu function.
// Logikanya benar, tetapi angka-angkanya tanpa penjelasan dan tak bisa dipecah.
export function totalKeranjang(items, ongkirAktif) {
  let subtotal = 0;
  for (const item of items) {
    subtotal += item.harga * item.jumlah;
  }

  let diskon = 0;
  if (subtotal >= 500000) {
    diskon = Math.round(subtotal * 0.1);
  }

  let total = subtotal - diskon;
  if (ongkirAktif && total < 300000) {
    total += 20000;
  }
  return total;
}

Logikanya jelas untuk mesin: subtotal dihitung, diskon diberikan bila subtotal mencapai 500000, dan ongkir 20000 ditambahkan bila diminta dan total masih di bawah 300000. Tapi bagi pembaca manusia, keempat angka itu adalah magic number: nilainya benar tetapi maksudnya tertutup. Aturan “diskon 10 persen” hanya terbaca bila pembaca menghitung sendiri bahwa 0.1 adalah seperseratus.

Pengunciannya dengan uji nilai absolut — hasil nyata yang dihitung dan disetujui manusia, misalnya keranjang Rp25.000 dua buah dengan ongkir aktif harus menghasilkan 70000:

js
test("hasil aktual keranjang kecil dengan ongkir adalah 70000", () => {
  assert.strictEqual(totalAwal([{ harga: 25000, jumlah: 2 }], true), 70000);
});

Uji semacam ini adalah kontrak: apa pun yang kita lakukan pada struktur di belakangnya, hasil 70000 untuk masukan itu tidak boleh berubah.

Langkah 2 dan 3: Konstanta Bernama dan Ekstrak Function #

Sekarang struktur diubah, dua langkah kecil sekaligus ditampilkan — keduanya mengubah bentuk, keduanya tidak menyentuh hitungan:

// keranjang-refaktor.mjs — versi rapi: konstanta bernama dan function terpisah.
// Perilakunya sama persis dengan keranjang-awal.mjs, strukturnya yang berubah.

const AMBANG_DISKON = 500000; // subtotal minimal untuk dapat diskon
const PERSENTASE_DISKON = 10; // diskon 10 persen
const AMBANG_GRATIS_ONGKIR = 300000; // belanja minimal agar ongkir gratis
const BIAYA_ONGKIR = 20000;

export function hitungSubtotal(items) {
  let subtotal = 0;
  for (const item of items) {
    subtotal += item.harga * item.jumlah;
  }
  return subtotal;
}

export function hitungDiskon(subtotal) {
  if (subtotal < AMBANG_DISKON) {
    return 0;
  }
  return Math.round((subtotal * PERSENTASE_DISKON) / 100);
}

export function hitungOngkir(totalSetelahDiskon) {
  if (totalSetelahDiskon >= AMBANG_GRATIS_ONGKIR) {
    return 0;
  }
  return BIAYA_ONGKIR;
}

export function totalKeranjang(items, ongkirAktif) {
  const subtotal = hitungSubtotal(items);
  const diskon = hitungDiskon(subtotal);
  let total = subtotal - diskon;
  if (ongkirAktif) {
    total += hitungOngkir(total);
  }
  return total;
}

Mari kita telaah perubahannya:

  1. Baris 4–7 (const AMBANG_DISKON = 500000; dst.): keempat magic number kini punya nama dan komentar. Perhatikan juga perubahan cara tulis diskon: versi lama menghitung subtotal * 0.1, versi baru menulis (subtotal * PERSENTASE_DISKON) / 100 — kalimatnya sedikit lebih panjang, tetapi angkanya kini menyebut “10 persen” secara eksplisit, dan menyetel diskon baru cukup mengubah satu konstanta.
  2. Baris 9–15 (hitungSubtotal): logika pertama diekstrak menjadi function sendiri dengan tugas satu kalimat: menjumlahkan harga kali jumlah. Bagian yang sama persis dari versi lama, hanya dipindahkan.
  3. Baris 17–22 (hitungDiskon): aturan diskon berdiri sendiri dan kini dapat diuji terpisah — hitungDiskon(0), hitungDiskon(499999), hitungDiskon(500000) — tanpa menyiapkan keranjang apa pun.
  4. Baris 24–29 (hitungOngkir): aturan ongkir ikut terpisah. Syaratnya kini terbaca sebagai kalimat: “di bawah ambang gratis ongkir, kembalikan biaya ongkir”.
  5. Baris 31–39 (totalKeranjang): function utama yang dulu berisi semua aturan kini membaca seperti urutan keputusan: hitung subtotal, hitung diskon, kurangi, tambahkan ongkir bila diminta. Tidak ada angka, tidak ada perulangan — hanya alur.

Langkah 4: Uji Tetap Hijau #

Terakhir, bukti bahwa perilaku tidak bergeser. Berkas uji membandingkan kedua versi pada empat kasus yang mewakili daerah rawan — bawah ambang, tepat ambang, dan atas ambang — lalu mengunci satu nilai absolut dari Langkah 1:

// uji-refaktor.test.mjs — membuktikan versi baru berperilaku sama dengan versi awal.
// Jalankan di terminal: node --test uji-refaktor.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { totalKeranjang as totalAwal } from "./keranjang-awal.mjs";
import { totalKeranjang as totalBaru } from "./keranjang-refaktor.mjs";

const kasus = [
  { nama: "keranjang kecil tanpa ongkir", items: [{ harga: 25000, jumlah: 2 }], ongkirAktif: false },
  { nama: "keranjang kecil dengan ongkir", items: [{ harga: 25000, jumlah: 2 }], ongkirAktif: true },
  { nama: "tepat di ambang diskon", items: [{ harga: 500000, jumlah: 1 }], ongkirAktif: true },
  { nama: "belanja besar gratis ongkir", items: [{ harga: 150000, jumlah: 3 }], ongkirAktif: true },
];

for (const k of kasus) {
  test(`perilaku sama: ${k.nama}`, () => {
    assert.strictEqual(totalBaru(k.items, k.ongkirAktif), totalAwal(k.items, k.ongkirAktif));
  });
}

// Uji nilai absolut: mengunci satu hasil nyata agar kedua versi
// tidak bersepakat memberikan jawaban yang sama tetapi salah.
test("hasil aktual keranjang kecil dengan ongkir adalah 70000", () => {
  assert.strictEqual(totalAwal([{ harga: 25000, jumlah: 2 }], true), 70000);
});

Jalankan dengan test runner bawaan Node:

bash
node --test uji-refaktor.test.mjs

Output yang Dihasilkan:

text
✔ perilaku sama: keranjang kecil tanpa ongkir (0.768748ms)
✔ perilaku sama: keranjang kecil dengan ongkir (0.123313ms)
✔ perilaku sama: tepat di ambang diskon (0.094784ms)
✔ perilaku sama: belanja besar gratis ongkir (0.086996ms)
✔ hasil aktual keranjang kecil dengan ongkir adalah 70000 (0.217978ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 65.826103

Angka durasi seperti 0.768748ms akan berbeda di setiap mesin karena mengukur waktu eksekusi, tetapi kelima tanda ✔ dan ringkasan pass 5 harus sama. Mari kita telaah ujinya:

  1. Baris 5–6 (import ... as totalAwal / totalBaru): kedua versi diimpor dengan alias berbeda. Keduanya tetap tersedia sehingga lama dan baru bisa dibandingkan langsung — inilah yang tidak mungkin bila versi lama ditimpa.
  2. Baris 8–13 (const kasus = [...]): kasus dipilih menutup daerah rawan, terutama tepat di ambang diskon — batas adalah tempat kesalahan pemindahan syarat (< vs <=) paling sering terlihat.
  3. Baris 16–18 (assert.strictEqual(totalBaru(...), totalAwal(...))): pembandingannya bukan terhadap angka yang kita tebak, melainkan terhadap perilaku asli. Bila kelima kasus sama, perbedaan perilaku (bila ada) pasti ada di luar kasus yang dipilih — dan kasus bisa ditambah kapan pun.
  4. Baris 23–25 (uji nilai absolut): pengaman kedua. Bila suatu hari kedua file salah berdua dengan cara yang sama, perbandingan tetap hijau; nilai absolut 70000 yang sudah disetujui manusia-lah yang mencegah kesepakatan itu.

Dengan kelima tanda centang hijau, refactoring selesai: struktur totalKeranjang berubah total, perilakunya terkunci di tempatnya. Pola kecil ini — uji dulu, ubah sedikit, uji lagi — berskala ke mana pun, dari satu function sampai migrasi berkas antar folder. Ia juga menjadi bekal yang tepat sebelum melangkah lebih jauh: section berikutnya, TypeScript dan Ecosystem, menambahkan tipe pada kode yang sudah tertata ini sehingga kesalahan bentuk data tertangkap bahkan sebelum uji berjalan.