Membangun piramida testing tokokita: unit test untuk handler dan use-case, contract testing dengan Pact agar service bisa deploy independen aman, integration dan end-to-end lewat Compose penuh, Testcontainers untuk database saat CI, dan quality gate per service

Setelah semua arsitektur, keamanan, dan observability berdiri, satu hal yang membuat semua itu bisa dirawat dalam jangka panjang adalah testing. Di monolith, satu test-suite cukup; di microservices, tantangannya beda: layanan bisa di-deploy pada waktu berbeda oleh tim berbeda — bagaimana kita tahu layanan A tidak merusak layanan B?
Mengapa episode ini penting? Karena microservices tanpa testing yang terstruktur adalah kartu domino: satu perubahan kontrak di product-service bisa mem-putus order-service secara diam-diam. Piramida unit → contract → e2e menjawab dengan biaya test yang naik bertahap dan kecepatan umpan balik yang tertata.
Level tercepat dan paling banyak. Fokus: business logic murni dengan dependency di-mock. Di Bun, test runner bawaan siap dipakai:
import { describe, test, expect } from 'bun:test'
import { canTransition } from '../domain/order-state'
import { createOrder } from '../use-cases/create-order'
describe('order state machine', () => {
test('CONFIRMED hanya bisa ke PAID atau CANCELLED', () => {
expect(canTransition('CONFIRMED', 'PAID')).toBe(true)
expect(canTransition('CONFIRMED', 'SHIPPED')).toBe(false)
})
})
describe('createOrder use-case', () => {
test('menolak qty melebihi batas', async () => {
// repositori fiktif
const result = await createOrder(
{ cart: fakeCart(), idempotencyKey: 'k1' },
{ productsApi: fakeProductsApi() }
)
expect(result.ok).toBe(false)
expect(result.reason).toBe('qty_exceeded')
})
})Aturan emas unit: jangan boot database. Mock repository, mock API client; tepat satu unit logika yang diuji. Posisi canTransition dan createOrder sebagai "use-case murni" (tanpa I/O) adalah desain yang membuat unit test ini ringan dan berharga.
Masalah yang dipecahkan contract test: product-service dan order-service masing-masing di-deploy oleh tim berbeda. order-service berasumsi GET /products/:id/reserve punya bentuk respons tertentu — bagaimana menjamin asumsi itu bertahan?
Pact menjawab lewat dua sisi:
const orderPact = new Pact({
consumer: 'order-service',
provider: 'product-service',
})
describe('reserve endpoint contract', () => {
const expectedResponse = { ok: true }
test('order-service mengharapkan reserve ok', async () => {
await orderPact.addInteraction({
uponReceiving: 'a valid reserve request',
withRequest: {
method: 'POST',
path: '/products/aaa-bbb/reserve',
body: { qty: 2, orderId: 'order-1' },
},
willRespondWith: { status: 200, body: expectedResponse },
})
// …jalankan consumer (mock provider pact), verifikasi perilaku order-service
})
})Profesi praktik: hasil contract di-version (folder atau broker Pact), CI provider menjalankan verifikasi tiap commit. Hasilnya: deploy independen yang aman — kalian tahu sebelum merilis bahwa kontrak masih nyambung.
Tip
Kontrak Pact menangkap kontrak API yang benar-benar dipakai, bukan dokumentasi OpenAPI yang bisa basi. Namun perhatikan: Pact menguji kompatibilitas kontrak, bukan kebenaran data. Ia tidak menggantikan integration test — ia melengkapi: contract untuk silaturahmi antar service, integration untuk kebenaran end-to-end.
Level paling dekat dengan pengguna: semua service + Kafka hidup, alur checkout penuh dijalankan otomatis.
test('full checkout flow', async () => {
const token = await registerAndLogin()
const products = await api('/api/products')
await api('/api/cart/items', { method: 'POST', body: { productId: products[0].id } })
const order = await api('/api/orders', {
method: 'POST',
headers: { 'Idempotency-Key': 'e2e-' + Date.now() },
})
expect(order.status).toBe('CONFIRMED') // payment langsung mock → sukses
const paid = await poll(() => api(`/api/orders/${order.id}`))
expect(paid.status).toBe('PAID')
// Mailpit menerima email bukti bayar
})Saat CI ingin integration test yang lebih dekat (service + DB nyata), jangan andalkan database yang sudah duduk di runner. Testcontainers mem-boot PostgreSQL/Kafka/Redis sebagai container sekali pakai per test suite:
import { PostgreSqlContainer } from '@testcontainers/postgresql'
const pg = await new PostgreSqlContainer('postgres:16').start()
const db = createDb(pg.getConnectionUri())
// jalankan migration → test → stop otomatis
await pg.stop()Kelebihan: environment deterministik, menghindari "database basi di shared runner". Di CI GitHub Actions (episode 23) runner mampu menjalankan container Docker — mulus integrasinya.
Terakhir — testing tanpa gate tidak bernilai. Setiap service punya quality gate di pipeline-nya sendiri:
lint (eslint + tsc) → unit test (coverage ≥ 80%) → contract test →
integration test (Testcontainers) → build image → (e2e di environment staging)Gate dijalankan per service yang berubah, sehingga monorepo tidak melambat (path-filtering dijelaskan di episode 23). Threshold coverage realistis, jangan 100% yang justru membuat tim mengelak — 80-85% garis tengah yang sehat, fokus pada logika domain & state machine.
Warning
Mitos yang mahal: "contract test sudah cukup, tidak perlu integration." Padahal contract hanya menjamin kontrak, bukan alur. Sebuah contract bisa lolos tapi saga checkout tetap patah karena event tidak ter-consume (Kafka tidak dimulai, consumer group salah). Piramida lengkap: unit murah banyak → contract sedang → integration/e2e sedikit tapi wajib di alur kritis.
Episode 22 menyusun strategi testing tokokita:
Di episode 23 selanjutnya, kita akan membangun CI/CD & GitOps per layanan — pipeline GitHub Actions dengan path-filtering di monorepo, strategi deploy blue-green/canary, Argo CD untuk GitOps, secrets via External Secrets, serta alur environment dev → staging → production. Sampai jumpa di episode 23!