Test pyramid masih menjadi strategi paling masuk akal untuk backend dan web app ketika tujuan utamanya adalah dua hal: rilis stabil dan feedback cepat. Masalah yang sering muncul bukan kurangnya jumlah test, melainkan distribusi test yang salah: terlalu banyak end-to-end test, terlalu sedikit unit/integration test, lingkungan test tidak terisolasi, dan hasil suite yang akhirnya lambat serta tidak dipercaya.

Topik ini tetap relevan dalam kurasi tulisan developer mingguan seperti yang sering muncul di DEV, termasuk pembahasan seputar praktik engineering yang tidak lekang oleh tren. Konteksnya bukan berita, melainkan pengingat bahwa kualitas rilis tetap ditentukan oleh fondasi teknik yang konsisten. Dalam artikel ini, kita fokus pada cara menyusun test pyramid yang praktis: memetakan jenis test, memilih mana yang masuk CI cepat vs verifikasi pra-rilis, mengurangi flaky test, dan membangun suite yang benar-benar membantu mencegah regresi.

Apa itu test pyramid dan mengapa masih relevan

Test pyramid adalah pendekatan untuk membagi test berdasarkan level, biaya eksekusi, dan kecepatan feedback. Bentuk “piramida” menekankan bahwa jumlah test di level bawah biasanya lebih banyak karena lebih murah, lebih cepat, dan lebih stabil. Semakin ke atas, test makin realistis tetapi juga lebih lambat, lebih mahal dirawat, dan lebih rentan flaky.

  • Unit test: memverifikasi logika kecil secara terisolasi.
  • Integration test: memverifikasi interaksi antarkomponen nyata, misalnya service ke database atau API layer ke message broker lokal/stub.
  • Contract test: memverifikasi kesepakatan antarsistem, misalnya schema request/response API atau event payload.
  • End-to-end (E2E) test: memverifikasi alur utama dari perspektif pengguna atau sistem pemanggil.

Tujuannya bukan membuat semua level memiliki porsi sama, tetapi menempatkan verifikasi di level termurah yang masih bisa menangkap risiko yang dimaksud. Jika bug dapat dicegah dengan unit test, tidak perlu menunggu E2E. Jika risiko ada di integrasi nyata dengan database atau service lain, unit test saja tidak cukup.

Pemetaan test: unit, integration, contract, dan end-to-end

1) Unit test: fondasi tercepat untuk logika bisnis

Unit test cocok untuk aturan bisnis, transformasi data, validasi, perhitungan, strategi fallback, dan cabang logika yang tidak membutuhkan network, filesystem, database nyata, atau clock sistem yang bergerak.

Ciri unit test yang baik:

  • Satu fokus perilaku yang jelas.
  • Tidak bergantung pada urutan eksekusi test lain.
  • Tidak menyentuh dependency eksternal secara langsung.
  • Deterministik: input sama, hasil sama.
  • Cepat dieksekusi dalam jumlah besar.

Contoh kasus yang cocok:

  • Perhitungan total harga setelah diskon dan pajak.
  • Validasi status transisi order.
  • Pemilihan strategi retry berdasarkan tipe error.
  • Normalisasi payload sebelum dikirim ke sistem lain.

2) Integration test: verifikasi batas sistem yang nyata

Integration test diperlukan ketika risiko utama ada pada interaksi nyata, bukan hanya logika internal. Misalnya query database yang kompleks, transaksi, repository, cache, serialisasi JSON, atau publish/consume event pada broker lokal atau test double yang mendekati perilaku asli.

Hal yang sebaiknya diuji dengan integration test:

  • Repository dan query ke database.
  • API handler/controller ke service dan persistence.
  • Integrasi dengan cache layer.
  • Publish event ke antrian, lalu verifikasi payload atau side effect.
  • Migration dan kompatibilitas schema dasar.

Integration test umumnya lebih lambat dari unit test, tetapi jauh lebih murah dibanding membiarkan bug integrasi hanya terdeteksi di E2E atau produksi.

3) Contract test: pagar terhadap regresi antarlayanan

Pada arsitektur yang melibatkan frontend-backend, microservices, atau integrasi dengan vendor, contract test penting untuk menjaga kompatibilitas. Fokusnya bukan seluruh alur bisnis, melainkan kesesuaian kontrak: field wajib, tipe data, struktur response, header penting, atau format event.

Contract test bermanfaat ketika:

  • Frontend bergantung pada shape response API.
  • Satu service mengonsumsi event dari service lain.
  • Tim terpisah merilis komponen secara independen.
  • Perubahan kecil pada schema sering memicu regresi lintas tim.

Tanpa contract test, bug sering muncul bukan karena logika salah, tetapi karena nama field berubah, enum bertambah tanpa penanganan default, atau nilai null muncul di tempat yang tidak diantisipasi.

4) End-to-end test: sedikit, strategis, dan fokus pada jalur kritis

E2E test sebaiknya menguji alur yang benar-benar kritis bagi bisnis, bukan semua cabang perilaku. Semakin banyak E2E test, semakin tinggi biaya maintenance, waktu eksekusi, dan peluang flaky akibat UI, network, timing, atau state environment.

Contoh alur yang layak masuk E2E:

  • Login atau autentikasi utama.
  • Checkout/pembayaran atau pembuatan order.
  • Registrasi pengguna baru.
  • Alur submit form penting yang memicu side effect lintas sistem.

Jangan gunakan E2E untuk menguji semua aturan validasi kecil yang lebih murah dibuktikan lewat unit atau integration test.

Cara membagi test antara CI cepat dan verifikasi pra-rilis

Kesalahan umum adalah menjalankan semua test di setiap commit tanpa mempertimbangkan biaya dan nilai feedback. Akibatnya CI lambat, developer sering menunda perbaikan, dan tim mulai menganggap pipeline sebagai hambatan.

Test yang ideal untuk CI cepat

Masukkan test yang memberikan sinyal regresi paling cepat dan stabil:

  • Semua atau hampir semua unit test.
  • Integration test yang cepat dan lokal, misalnya database ephemeral/container.
  • Contract test yang memverifikasi kompatibilitas API/event utama.
  • Sedikit smoke E2E untuk alur paling kritis.

Target praktisnya bukan angka tertentu, tetapi prinsip berikut:

  • Hasil keluar cukup cepat untuk membantu review PR.
  • Jika gagal, developer mudah melokalisasi akar masalah.
  • Failure rate palsu sangat rendah.

Test yang lebih cocok untuk pra-rilis atau jadwal terpisah

  • Full E2E suite di banyak browser/device.
  • Test skenario panjang dengan integrasi pihak ketiga.
  • Test migrasi data skala besar.
  • Test performa dasar dan load test ringan.
  • Chaos/failure injection yang memerlukan environment khusus.

Pisahkan jalur eksekusi berdasarkan kebutuhan keputusan. PR membutuhkan feedback cepat dan tepercaya. Pra-rilis membutuhkan verifikasi lebih luas meski lebih lambat.

Contoh workflow PR-to-release

  1. Local development: jalankan unit test terkait area perubahan, ditambah subset integration test penting.
  2. Pull Request: linting, type check bila ada, unit test penuh, integration test inti, contract test, dan smoke E2E.
  3. Merge ke main: suite CI yang sama, plus integration tambahan jika diperlukan.
  4. Nightly atau scheduled: full E2E, test kompatibilitas lintas browser, skenario panjang, dan pemeriksaan environment.
  5. Pra-rilis: smoke test pada staging yang menyerupai produksi, verifikasi migration, observability check, dan rollback readiness.
  6. Pasca-rilis: canary/smoke production-safe, monitor error rate, latensi, queue backlog, dan alarm.

Pola ini menjaga CI tetap cepat tanpa mengorbankan lapisan verifikasi yang lebih mahal.

Penyebab flaky test dan cara mencegahnya

Flaky test adalah test yang kadang gagal, kadang lolos, tanpa perubahan kode yang relevan. Ini berbahaya karena merusak kepercayaan tim terhadap suite. Begitu developer terbiasa melihat test gagal “karena memang sering begitu”, nilai test turun drastis.

1) Ketergantungan pada waktu

Sumber flaky paling sering adalah clock nyata: timeout, sleep, timestamp, timezone, dan job asynchronous yang diasumsikan selesai dalam durasi tertentu.

Solusi:

  • Gunakan fake clock atau kontrol waktu bila tool mendukung.
  • Hindari sleep tetap untuk menunggu proses selesai.
  • Gunakan polling dengan batas waktu yang jelas untuk kondisi yang benar-benar asynchronous.
  • Pastikan timezone test konsisten.
// Pseudocode: hindari sleep tetap, tunggu kondisi dengan timeout jelas
await waitUntil(async () => {
  const job = await queue.findById(jobId)
  return job.status === 'completed'
}, { timeoutMs: 5000, intervalMs: 100 })

2) Shared state antar-test

Test yang saling berbagi database, cache, file, atau data user statis mudah saling mengganggu. Test bisa lolos sendiri tetapi gagal saat dijalankan paralel atau setelah test lain.

Solusi:

  • Reset state per test atau per suite.
  • Gunakan transaksi rollback jika cocok.
  • Pakai data unik untuk setiap test run.
  • Hindari mengandalkan urutan eksekusi.

3) Dependency eksternal yang tidak stabil

Memanggil API pihak ketiga, service internal yang sedang berubah, atau environment staging bersama sering memicu flaky test.

Solusi:

  • Untuk unit test, gunakan test double/mock/stub.
  • Untuk integration test, jalankan dependency lokal atau container ephemeral bila memungkinkan.
  • Untuk contract test, verifikasi schema dan contoh payload, bukan availability vendor.
  • Batasi E2E terhadap dependency eksternal yang benar-benar perlu.

4) Asynchrony dan race condition

Queue, event, background job, debounce UI, dan eventual consistency sering memunculkan race condition. Test gagal bukan karena fitur rusak, tetapi karena observasi dilakukan terlalu cepat atau dari sisi yang salah.

Solusi:

  • Verifikasi dari titik sinkron jika ada.
  • Gunakan hook/event untuk mengetahui kapan proses selesai.
  • Uji side effect yang relevan, bukan asumsi timing internal.
  • Jika sistem eventual consistent, tulis assertion yang sesuai model itu.

5) Retry yang salah digunakan

Retry bisa membantu pada operasi yang memang rentan gangguan sementara, tetapi retry bukan solusi utama untuk flaky test. Jika setiap kegagalan diulang otomatis tanpa diagnosis, akar masalah tertutup.

Gunakan retry dengan tepat:

  • Boleh untuk langkah yang benar-benar dipengaruhi faktor transien, misalnya sinkronisasi environment tertentu.
  • Jangan menjadikan retry sebagai standar semua test.
  • Catat metrik retry: test mana yang sering membutuhkan percobaan ulang.
  • Jika retry sering menyelamatkan test yang sama, karantina dan perbaiki desain test tersebut.

Prinsip praktis: retry boleh mengurangi noise sementara, tetapi flaky test tetap dianggap defect pada test atau sistem.

Teknik implementasi: isolasi dependency, data test, time control, dan observability

Isolasi dependency

Setiap level test perlu strategi isolasi yang berbeda.

  • Unit test: mock atau stub untuk network, database, filesystem, queue, dan clock.
  • Integration test: gunakan dependency nyata yang dekat dengan produksi, tetapi dikelola secara lokal dan terkontrol.
  • Contract test: fokus pada representasi payload dan behavior kontrak, bukan seluruh environment.
  • E2E: gunakan dependency nyata seperlunya, tetapi minimalkan ketergantungan ke service eksternal yang tidak Anda kontrol.

Kesalahan umum adalah memakai mock terlalu dalam pada integration test sehingga test tampak hijau tetapi tidak benar-benar memverifikasi integrasi.

Manajemen data test

Data test yang buruk sering membuat suite sulit dipahami dan sulit dipercaya. Gunakan data yang:

  • Dibuat eksplisit oleh test, bukan bergantung pada seed global misterius.
  • Kecil dan fokus pada perilaku yang diuji.
  • Unik bila ada risiko bentrok.
  • Mudah dibersihkan.
// Pseudocode factory sederhana
const user = await createUser({
  email: uniqueEmail(),
  role: 'customer',
  status: 'active'
})

const order = await createOrder({
  userId: user.id,
  items: [
    { sku: 'SKU-001', qty: 2, price: 50000 }
  ]
})

Factory atau builder biasanya lebih mudah dirawat dibanding fixture besar yang berisi terlalu banyak detail tidak relevan.

Time control

Waktu harus diperlakukan sebagai dependency. Jika aplikasi punya logika token expiry, scheduling, billing period, atau timeout, injeksikan clock bila arsitektur memungkinkan.

// Pseudocode injeksi clock
class SubscriptionService {
  constructor(repository, clock) {
    this.repository = repository
    this.clock = clock
  }

  isExpired(subscription) {
    return subscription.expiresAt <= this.clock.now()
  }
}

Pendekatan ini membuat skenario sulit seperti pergantian hari, masa berlaku token, dan grace period jauh lebih mudah diuji tanpa menunggu waktu nyata.

Observability untuk test

Test yang gagal harus mudah didiagnosis. Tambahkan observability secukupnya agar failure tidak berhenti di “assertion mismatch”.

  • Log request/response penting pada integration atau E2E test.
  • Simpan screenshot/video bila menguji UI.
  • Catat query, event, atau job yang dipublish untuk skenario tertentu.
  • Lampirkan correlation ID atau test run ID pada log.
  • Tampilkan diff payload/schema saat contract test gagal.

Observability yang baik mengurangi waktu investigasi dan membantu membedakan bug aplikasi dari bug test.

Contoh pemetaan praktis untuk backend/web app

Misalkan aplikasi memiliki alur: pengguna membuat order, sistem menyimpan ke database, menghitung total, mempublikasikan event, lalu frontend menampilkan status order.

Yang sebaiknya diuji di unit test

  • Perhitungan total, diskon, pajak, dan pembulatan.
  • Validasi transisi status order.
  • Pemetaan domain object ke DTO internal.
  • Fallback logika bila field opsional tidak tersedia.

Yang sebaiknya diuji di integration test

  • Endpoint create order menyimpan data dengan benar.
  • Repository mengembalikan order beserta relasinya.
  • Transaksi rollback saat validasi persistence gagal.
  • Event order-created dipublish dengan payload yang benar.

Yang sebaiknya diuji di contract test

  • Response API order detail memiliki field wajib yang diharapkan frontend.
  • Event order-created mempertahankan struktur dan tipe data.
  • Perubahan field baru tidak memutus consumer lama.

Yang sebaiknya diuji di E2E

  • Pengguna login, membuat order, dan melihat status berhasil.
  • Alur gagal validasi penting yang memengaruhi pengalaman pengguna inti.

Dengan pemetaan ini, kebanyakan regresi logika dan integrasi tertangkap sebelum perlu menjalankan E2E dalam jumlah besar.

Checklist anti-regresi berbasis test pyramid

  • Apakah setiap bug penting yang pernah lolos sudah punya test di level termurah yang masuk akal?
  • Apakah aturan bisnis utama ditutup oleh unit test, bukan hanya E2E?
  • Apakah integrasi database, cache, queue, atau serialization punya integration test nyata?
  • Apakah kontrak API/event utama diverifikasi agar perubahan lintas tim tidak memicu regresi diam-diam?
  • Apakah E2E hanya mencakup jalur kritis bisnis?
  • Apakah test independen dan aman dijalankan paralel?
  • Apakah waktu, random, dan network dikontrol dengan baik?
  • Apakah data test mudah dipahami dan tidak bergantung pada seed global yang rapuh?
  • Apakah CI PR menjalankan suite cepat yang tepercaya?
  • Apakah suite lambat dipindah ke nightly atau pra-rilis?
  • Apakah test failure menghasilkan log, payload, atau artefak yang cukup untuk debugging?
  • Apakah flaky test dilacak, dikarantina bila perlu, dan diperbaiki, bukan diabaikan?

Trade-off biaya vs kecepatan

Tidak ada strategi testing yang gratis. Masing-masing level punya biaya dan manfaat berbeda.

  • Unit test: paling murah dan cepat, tetapi tidak membuktikan wiring nyata.
  • Integration test: lebih realistis, tetapi setup dan maintenance lebih mahal.
  • Contract test: sangat berguna di sistem terdistribusi, tetapi perlu disiplin versioning dan ownership antartim.
  • E2E test: paling representatif dari perspektif pengguna, tetapi paling lambat dan paling rentan flaky.

Trade-off yang sehat biasanya seperti ini: dorong sebanyak mungkin verifikasi ke unit/integration/contract test, lalu gunakan E2E sebagai lapisan validasi akhir yang tipis tetapi strategis. Jika semua dibebankan ke E2E, biaya pipeline dan maintenance akan cepat naik.

Kesalahan umum yang membuat suite test lambat dan tidak dipercaya

  • Terlalu banyak test UI/E2E untuk memverifikasi hal yang seharusnya cukup di unit test.
  • Mock berlebihan sampai integration bug nyata tidak pernah tertangkap.
  • Shared environment tanpa isolasi data, menyebabkan test saling mengganggu.
  • Sleep tetap untuk menunggu proses asynchronous.
  • Retry membabi buta yang menutupi akar masalah.
  • Fixture besar dan rapuh yang sulit dipahami dan sering rusak saat schema berubah.
  • Tidak ada observability sehingga kegagalan sulit dianalisis.
  • Tidak menghapus atau menandai flaky test sehingga noise menumpuk.
  • Semua test dipaksa masuk PR CI meski tidak semuanya bernilai untuk feedback cepat.

Penutup

Menyusun test pyramid untuk cegah flaky test dan regresi bukan soal mengejar jumlah test, melainkan menempatkan tiap jenis verifikasi di level yang tepat. Unit test menjaga logika tetap aman, integration test menangkap wiring nyata, contract test melindungi batas antarsistem, dan E2E memvalidasi jalur bisnis kritis. Jika distribusinya benar, CI tetap cepat, rilis lebih stabil, dan tim kembali percaya pada hasil suite.

Mulailah dari inventaris sederhana: bug apa yang paling sering lolos, level test mana yang seharusnya menangkapnya, dan bagian mana dari suite Anda yang paling sering flaky. Dari sana, perbaikan test strategy biasanya jauh lebih efektif daripada sekadar menambah test baru tanpa arah.