Learning Tests & Boundaries — Clean Code

Ketika kamu memakai library atau API pihak ketiga — axios, Stripe, AWS SDK, Redis — kamu menyerahkan sebagian kontrol ke kode orang lain. Uncle Bob menyebut…

Ketika kamu memakai library atau API pihak ketiga — axios, Stripe, AWS SDK, Redis — kamu menyerahkan sebagian kontrol ke kode orang lain. Uncle Bob menyebut ini boundary: batas antara kodemu dan dunia luar.

Boundary yang tidak dikelola dengan baik = sumber bug yang menyakitkan. Library update → sintaks berubah → setengah aplikasimu error. Vendor ganti API → kamu terjebak rewrite massal.

Bagian 1: Learning Test

Cara belajar library baru yang aman: tulis test dulu untuk memverifikasi asumsimu tentang bagaimana library berperilaku.

// Learning test untuk axios
import axios from "axios";

test("axios returns data property, not the raw JSON", async () => {
  const res = await axios.get("https://api.example.com/users/1");

  // Asumsiku: res adalah objek dengan .data, bukan raw JSON
  expect(res).toHaveProperty("data");
  expect(res.data).toEqual(expect.objectContaining({ id: 1 }));
});

test("axios throws on 4xx by default", async () => {
  await expect(
    axios.get("https://api.example.com/does-not-exist")
  ).rejects.toThrow();
});

Mengapa ini berharga?

  1. Dokumentasi hidup — tim baru baca test ini dan paham cara pakai library.
  2. Regression detector — saat update versi library, jalankan test ini. Jika gagal, perilaku library berubah.
  3. Belajar terstruktur — daripada trial-and-error di index.js, kamu bangun pemahaman sistematis.

Kapan tulis learning test?

Bagian 2: Adapter Pattern untuk Boundary

Jangan panggil library pihak ketiga langsung dari seluruh kodemu. Bungkus dengan adapter.

// BURUK: axios menyebar di 50 file
// userService.js
import axios from "axios";
const res = await axios.get("/users");

// orderService.js
import axios from "axios";
const res = await axios.get("/orders");

// productService.js
import axios from "axios";
const res = await axios.get("/products");

// Kalau mau ganti axios → fetch native, edit 50 file!
// BERSIH: adapter tunggal
// lib/httpClient.js
import axios from "axios";

export const httpClient = {
  get: async (url) => {
    const res = await axios.get(url);
    return res.data; // sudah unwrap, konsumen tidak perlu tahu
  },
  post: async (url, body) => {
    const res = await axios.post(url, body);
    return res.data;
  },
};

// userService.js
import { httpClient } from "./lib/httpClient";
const users = await httpClient.get("/users");

Sekarang ganti axios → fetch hanya edit satu file: httpClient.js.

Bagian 3: Stripe / SDK besar — lebih penting lagi

// BURUK: seluruh aplikasi kenal Stripe SDK
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_KEY);

// checkoutService.js
const customer = await stripe.customers.create({ email, source });
const charge = await stripe.charges.create({ customer: customer.id, amount, currency });

// subscriptionService.js
const sub = await stripe.subscriptions.create({ customer, items });

// Kalau kita ganti ke Midtrans (provider lokal)? Rewrite massal.
// BERSIH: PaymentGateway adapter
// services/PaymentGateway.js
class StripeGateway {
  async charge({ email, amount, currency, source }) {
    const customer = await stripe.customers.create({ email, source });
    const ch = await stripe.charges.create({ customer: customer.id, amount, currency });
    return { id: ch.id, status: ch.status };
  }
}

// checkoutService.js — tidak kenal Stripe
import { paymentGateway } from "./services/PaymentGateway";
const result = await paymentGateway.charge({ email, amount: 100000, currency: "IDR", source });

// Ganti ke Midtrans? Buat MidtransGateway dengan interface sama.
class MidtransGateway {
  async charge({ email, amount, currency, source }) { /* ... */ return { id, status }; }
}

Interface domain, bukan interface vendor: Adapter kamu harus mengekspos bahasa domain bisnismu (charge, refund, subscribe), bukan bahasa vendor (customers.create, charges.create). Vendor berganti, bahasa domain tetap.

Bagian 4: Kapan NOT pakai adapter?

Adapter bukan selalu tepat. Tanda-tanda kamu butuh adapter:

Tanda-tanda adapter tidak perlu:

Bagian 5: Boundary Tests

Selain learning test saat belajar, tulis boundary test yang memastikan adaptermu benar:

describe("PaymentGateway", () => {
  it("returns { id, status } shape for successful charge", async () => {
    const result = await paymentGateway.charge({
      email: "[email protected]",
      amount: 100000,
      currency: "IDR",
      source: "tok_test",
    });

    expect(result).toMatchObject({
      id: expect.any(String),
      status: expect.stringMatching(/succeeded|pending/),
    });
  });
});

Test ini memakai sandbox Stripe (bukan mock), jadi jika Stripe ubah API, test langsung merah.

Rangkuman:

  1. Learning test: tulis test untuk memverifikasi asumsi sebelum integrasi production
  2. Adapter pattern: bungkus library pihak ketiga, ekspos bahasa domainmu
  3. Boundary test: verifikasi adapter masih berfungsi dengan API vendor saat upgrade
  4. Judgment call: tidak semua library butuh adapter — timbang switching cost

Kode yang menghormati boundary lebih mudah bertahan saat dunia luar berubah.

🎭 Analogi sehari-hari

Learning tests + boundaries itu kayak stop kontak rumah dengan adapter universal. Listrik di Indonesia 220V, di Jepang 100V, di USA 110V. Tanpa adapter, kabel hardware kena listrik salah voltase = rusak. Kabel di rumahmu kontak ke ADAPTER (boundary), adapter handle convert. Pas pindah negara (ganti library/vendor), cukup ganti ADAPTER, kabel-kabel rumah tetap. Plus learning test = colokin dulu adapter ke voltmeter sebelum kirim listrik ke laptop mahal — verify sebelum risk. Library pihak ketiga sama: bungkus dengan adapter (interface kamu), test perilaku adapter (learning test), pas vendor update / ganti = cukup update adapter, kode aplikasi tetap.

⚠️ Jebakan yang sering ditemui

Pattern Adapter

// ❌ TANPA adapter — tightly coupled ke axios
import axios from "axios"
const response = await axios.get("/api/users", { headers: {...} })
const users = response.data

// ✓ DENGAN adapter — bahasa domain
class UserApi {
  async fetchAll() {
    const response = await axios.get("/api/users", { ... })
    return response.data.map(u => new User(u))
  }
}

const userApi = new UserApi()
const users = await userApi.fetchAll()
// Code aplikasi gak tau axios. Pas pindah ke fetch / ky, cukup ubah UserApi.

Learning Test Example

// Sebelum integrate Stripe SDK ke production, verify behavior
describe("Stripe SDK behavior", () => {
  it("creates charge with correct response shape", async () => {
    const charge = await stripe.charges.create({
      amount: 100000,
      currency: "idr",
      source: "tok_test",
    })
    // Asumsi yang kamu cek: response punya field "id" dan "status"
    expect(charge).toHaveProperty("id")
    expect(charge.status).toMatch(/succeeded|pending|failed/)
  })
})

Pas Stripe update API, test ini langsung merah → kamu catch sebelum production.

🎯 Wrap library atau pakai langsung?

  • Library tidak stabil / sering breaking change → wrap dengan adapter ✓
  • Library di critical path (payment, auth) → wrap + boundary test ✓
  • Library kompleks dengan banyak konfigurasi → wrap untuk simplify
  • Library yang KEMUNGKINAN diganti → wrap (decouple)
  • Library standar industri yang stabil (lodash, date-fns) → pakai langsung
  • Library kecil 1-2 method dipake → pakai langsung
  • Library yang udah expose bahasa domain kamu → pakai langsung

Aturan: wrap saat ada RISK CHANGE atau COMPLEXITY yang perlu disembunyikan. Sederhana + stable = pakai langsung.

TL;DR: Boundary = batas antara kodemu vs library/vendor pihak ketiga. Learning test = verify perilaku library SEBELUM integrate ke production. Adapter pattern = wrap library, expose bahasa domain kamu — pas vendor update, cukup ubah adapter. Boundary test = verify adapter masih bener pas library update. JANGAN wrap semua library (overkill), wrap yang RISK + COMPLEXITY tinggi (payment, auth, vendor unstable). Pure data utility (lodash, date-fns) — pakai langsung.

Yang akan kamu pelajari