Membangun kepercayaan pada kode backend melalui empat lapis pengujian: unit test untuk logika murni, integration test untuk database, contract test untuk kontrak API, dan end-to-end test untuk alur lengkap

Setelah di episode 7 kalian membangun autentikasi, pertanyaan berikutnya adalah: bagaimana memastikan semua ini benar-benar bekerja — sekarang dan di masa depan? Jawabannya adalah testing. Kode tanpa test adalah kode yang hanya berfungsi "kemarin": perubahan kecil bisa merusak fitur tanpa ada yang menyadarinya sampai produksi.
Mengapa testing penting di backend? Karena backend adalah tempat logika bisnis dan uang berada — error di sini berdampak langsung. Test bukan pengeluaran waktu, melainkan investasi yang memungkinkan refactor tanpa rasa takut. Episode ini membangun empat lapis pengujian, dari unit hingga end-to-end, dengan contoh nyata pada API toko online kita.
Piramida testing: banyak unit test (cepat, murah), sedikit integration test, dan paling sedikit e2e test (lambat, mahal). Semakin ke atas, test semakin mahal dan semakin sedikit jumlahnya. Kalian tidak butuh ribuan e2e test — kalian butuh ribuan unit test dan puluhan integration/e2e.
Unit test menguji satu fungsi/unit logika tanpa dependensi eksternal — database, jaringan, dan sistem lain di-mock. Contoh nyata: fungsi perhitungan diskon di logika order kita.
export function calculateSubtotal(items: { qty: number; unitPrice: number }[]) {
return items.reduce((sum, it) => sum + it.qty * it.unitPrice, 0)
}
export function applyDiscount(subtotal: number, percent: number) {
if (percent < 0 || percent > 100) throw new Error("INVALID_DISCOUNT")
return Math.round(subtotal * (1 - percent / 100) * 100) / 100
}import { describe, expect, it } from "vitest"
import { applyDiscount, calculateSubtotal } from "./pricing.js"
describe("calculateSubtotal", () => {
it("menjumlahkan qty dikali harga", () => {
expect(calculateSubtotal([
{ qty: 2, unitPrice: 50000 },
{ qty: 1, unitPrice: 100000 },
])).toBe(200000)
})
})
describe("applyDiscount", () => {
it("mengurangi subtotal sesuai persen", () => {
expect(applyDiscount(200000, 10)).toBe(180000)
})
it("menolak persen di luar rentang", () => {
expect(() => applyDiscount(100000, 150)).toThrow("INVALID_DISCOUNT")
})
})Unit test harus cepat (milidetik per test) dan menentukan — jika ada bug, test menunjukkan fungsi mana yang salah.
Integration test menguji kerjasama antar komponen nyata: misalnya service yang membaca/menulis database PostgreSQL, atau handler HTTP dengan framework. Di sini database dipakai sungguhan (test database terpisah), bukan di-mock.
import { beforeAll, afterAll, describe, expect, it } from "vitest"
import { app } from "../app.js"
import { resetDb } from "../test/db.js"
beforeAll(async () => {
await resetDb()
})
afterAll(async () => {
await resetDb()
})
describe("POST /v1/products", () => {
it("membuat produk dan mengembalikan 201", async () => {
const res = await app.inject({
method: "POST",
url: "/v1/products",
payload: { name: "Keyboard", price: 250000, stock: 10 },
})
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body.data).toMatchObject({ name: "Keyboard", price: 250000 })
})
it("menolak harga negatif dengan 400", async () => {
const res = await app.inject({
method: "POST",
url: "/v1/products",
payload: { name: "Kabel", price: -1, stock: 5 },
})
expect(res.statusCode).toBe(400)
expect(res.json().error).toBe("VALIDATION_FAILED")
})
})Note
Integration test memakai database asli di environment test — jangan pernah mengetes terhadap database produksi. Sediakan database test terpisah yang di-reset sebelum dan sesudah test. Ini memastikan test kalian tidak saling mencemari dan bisa dijalankan berulang kali.
Contract test memastikan kontrak antara konsumen dan penyedia API tidak rusak. Ini kunci saat banyak tim/layanan bergantung pada API kita (relevan untuk microservices di episode 12). Dengan Pact, kontrak diuji dari sisi konsumen dan provider:
import { Pact } from "@pact-foundation/pact"
const provider = new Pact({
consumer: "storefront-web",
provider: "catalog-api",
port: 8080,
})
it("storefront mengharapkan daftar produk", async () => {
await provider.addInteraction({
state: "ada produk di katalog",
uponReceiving: "request daftar produk",
withRequest: { method: "GET", path: "/v1/products", query: { limit: "10" } },
willRespondWith: {
status: 200,
body: { data: [{ id: 1, name: "Keyboard" }], meta: { page: 1 } },
},
})
const res = await fetch("http://localhost:8080/v1/products?limit=10")
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toBeInstanceOf(Array)
await provider.verify()
})Ketika catalog-api diuji sebagai provider dengan kontrak yang sama, perubahan yang merusak bentuk respons langsung terdeteksi — sebelum konsumen rusak di produksi.
E2E test menjalankan seluruh sistem — API, database, dan dependensi eksternal (yang di-stub) — mengalir seperti user nyata. Contoh: alur belanja penuh.
import { describe, expect, it } from "vitest"
describe("Alur checkout", () => {
it("login → lihat produk → buat order → lihat order", async () => {
const login = await api.post("/v1/auth/login", {
email: "buyer@shop.test",
password: "test-password",
})
const token = login.body.data.token
const products = await api.get("/v1/products?limit=1", token)
const productId = products.body.data[0].id
const order = await api.post("/v1/orders", {
items: [{ productId, qty: 2 }],
}, token)
expect(order.status).toBe(201)
const detail = await api.get(`/v1/orders/${order.body.data.id}`, token)
expect(detail.body.data.status).toBe("pending")
expect(detail.body.data.total).toBeGreaterThan(0)
})
})E2E test lambat dan mahal — itu sebabnya jumlahnya sedikit dan hanya mencakup alur kritis (login, checkout, pembayaran), bukan setiap kombinasi.
Coverage adalah alat bantu, bukan tujuan. Target sehat untuk backend: 80%+ line coverage pada logika domain, dengan prioritas pada fungsi yang paling kritis (harga, stok, auth). Jalankan:
bun run test
bun run test:coverageKebiasaan yang membuat testing efektif:
Mock berlebihan membuat test hanya menguji implementasi, bukan perilaku. Mock untuk batas sistem (jaringan, payment), bukan untuk logika internal.
Jika test suite butuh 30 menit, developer tidak menjalankannya. Jaga unit test tetap cepat; naikkan e2e ke pipeline CI yang berjalan saat merge.
Test yang kadang lulus kadang gagal tanpa alasan jelas — biasanya karena dependensi waktu (timeout, date) atau database yang tidak bersih. Flaky test lebih buruk daripada tidak ada test karena merusak kepercayaan.
Tip
Mulailah dari lapisan bawah piramida: pastikan unit test pada logika domain (harga, stok, auth) lengkap dulu, tambah integration test untuk CRUD database, lalu e2e untuk alur kritis. Rasionya yang sehat di tim kecil: ratusan unit, puluhan integration, 5-10 e2e.
Episode 8 membangun empat lapis pengujian: unit test untuk logika murni, integration test dengan database nyata, contract test untuk kontrak API, dan e2e test untuk alur kritis — diorganisir sebagai piramida.
Inti yang harus dibawa pulang:
Di episode 9 selanjutnya kita akan mempercepat API: caching & performance — Redis cache, CDN, cache strategy, dan optimasi query untuk menurunkan latency. Sampai jumpa di episode 9!