Strategi uji UI deklaratif berbeda dari testing UI tradisional karena sumber perilakunya tidak lagi hanya ada di komponen, tetapi juga di schema, evaluator runtime, aturan visibilitas, binding data, dan alur event. Jika logika rendering dipindah ke konfigurasi atau runtime, maka pengujian harus ikut bergeser: validasi kontrak schema, uji evaluator, integrasi state-event, lalu E2E hanya untuk alur bisnis paling kritis.

Masalah paling umum pada pendekatan ini adalah flaky test dan regression yang sulit dilacak. Penyebabnya biasanya bukan semata framework UI, tetapi kombinasi timing async, data nondeterministik, selector rapuh, state tersembunyi dalam runtime, dan ketergantungan antar-konfigurasi. Artikel ini membahas cara menyusun test pyramid yang relevan, contoh test case, strategi mock, dan workflow CI agar perubahan UI tetap aman.

Mengapa UI deklaratif lebih rawan regression yang tidak terlihat

Pada UI imperatif, perilaku biasanya tersebar di event handler dan komponen. Pada UI deklaratif, perilaku dapat berasal dari dokumen schema seperti JSON, aturan runtime seperti ekspresi visibilitas, peta event, resolver data, dan tema/layout engine. Dampaknya, satu perubahan kecil pada konfigurasi bisa memengaruhi banyak layar sekaligus.

Contoh sederhana:

  • Field promoCode hanya tampil jika user.isMember = true.
  • Tombol Submit nonaktif jika validator schema mendeteksi field wajib belum lengkap.
  • Perubahan mapping event onChange memicu fetch tambahan yang mengubah state form.

Kalau pengujian hanya mengandalkan E2E, regresi akan terlambat terdeteksi, sulit dilokalisasi, dan cenderung flaky. Karena itu, strategi terbaik adalah memecah risiko ke beberapa lapisan tes.

Test pyramid yang relevan untuk UI deklaratif

Untuk konteks runtime-driven UI, test pyramid yang praktis biasanya terdiri dari empat lapisan:

  1. Contract test untuk schema/config: memastikan bentuk, aturan dasar, dan kompatibilitas schema valid.
  2. Unit test evaluator/runtime: menguji mesin yang membaca schema dan menghasilkan perilaku UI.
  3. Integration test state-event: memverifikasi alur input pengguna, perubahan state, efek samping, dan rendering.
  4. E2E test untuk alur kritis: memastikan sistem utuh bekerja pada skenario bisnis utama.

Prinsip utamanya: semakin bawah lapisannya, semakin cepat dan deterministik tesnya. Semakin atas, semakin sedikit jumlah tesnya tetapi semakin tinggi nilai validasinya.

1) Contract test untuk schema dan konfigurasi

Jika UI dirender dari schema, maka schema adalah bagian dari API internal Anda. Ia perlu kontrak yang jelas, tervalidasi, dan kompatibel. Tanpa ini, runtime akan menerima konfigurasi ambigu yang baru gagal saat dirender di browser.

Apa yang perlu diuji

  • Field wajib tersedia, misalnya type, id, props, children bila diperlukan.
  • Enum valid, misalnya tipe komponen hanya boleh text, input, button, section.
  • Referensi event atau binding data tidak menunjuk ke path yang mustahil.
  • Ekspresi runtime mengikuti grammar yang diizinkan.
  • Perubahan schema baru tetap kompatibel dengan runtime saat ini, atau sebaliknya.

Mengapa contract test efektif

Lapisan ini murah dan cepat. Banyak regression dapat dicegah sebelum browser atau runtime dijalankan. Selain itu, jika schema dikirim dari backend atau CMS, contract test membantu menjaga kesepakatan lintas tim.

Contoh schema dan validasi

{
  "screen": "checkout",
  "components": [
    {
      "id": "email",
      "type": "input",
      "label": "Email",
      "bind": "form.email",
      "validators": ["required", "email"]
    },
    {
      "id": "promoCode",
      "type": "input",
      "label": "Kode Promo",
      "bind": "form.promoCode",
      "visibleWhen": "user.isMember == true"
    },
    {
      "id": "submit",
      "type": "button",
      "label": "Bayar",
      "onClick": "submitCheckout"
    }
  ]
}
describe('checkout schema contract', () => {
  it('harus memiliki komponen dengan id unik', () => {
    const ids = schema.components.map(c => c.id)
    expect(new Set(ids).size).toBe(ids.length)
  })

  it('bind input harus mengarah ke path form.*', () => {
    for (const c of schema.components) {
      if (c.type === 'input') {
        expect(c.bind.startsWith('form.')).toBe(true)
      }
    }
  })

  it('visibleWhen hanya boleh memakai field yang diizinkan', () => {
    const expr = schema.components.find(c => c.id === 'promoCode').visibleWhen
    expect(expr).toMatch(/^(user|form)\./)
  })
})

Praktik yang disarankan

  • Validasi schema saat build atau saat pull request, bukan hanya saat runtime.
  • Simpan contoh schema nyata sebagai fixture untuk regression test.
  • Jika schema punya versi, uji migrasi dan kompatibilitas antar-versi.
  • Pisahkan validasi struktur dari validasi perilaku. Struktur cocok untuk contract test; perilaku masuk ke evaluator/runtime test.

2) Unit test evaluator dan runtime

Inilah inti dari UI deklaratif: mesin yang membaca schema lalu memutuskan apa yang tampil, kapan event dipicu, bagaimana validator berjalan, dan data apa yang diikat ke komponen. Jika lapisan ini tidak diuji dengan baik, E2E akan menjadi tempat penemuan bug yang mahal.

Fungsi runtime yang layak diuji terpisah

  • Evaluator ekspresi seperti visibleWhen, disabledWhen, textTemplate.
  • Resolver binding seperti form.email atau user.profile.name.
  • Mesin event seperti onClick memetakan ke action runtime.
  • Validator yang menggabungkan aturan schema dan state saat ini.
  • Normalisasi schema, misalnya default value atau fallback prop.

Contoh unit test evaluator

describe('visibleWhen evaluator', () => {
  it('menampilkan promoCode untuk member', () => {
    const state = { user: { isMember: true }, form: {} }
    const result = evaluateExpression('user.isMember == true', state)
    expect(result).toBe(true)
  })

  it('menyembunyikan promoCode untuk non-member', () => {
    const state = { user: { isMember: false }, form: {} }
    const result = evaluateExpression('user.isMember == true', state)
    expect(result).toBe(false)
  })
})
describe('binding resolver', () => {
  it('membaca nilai nested path dengan aman', () => {
    const state = { form: { email: '[email protected]' } }
    expect(resolvePath(state, 'form.email')).toBe('[email protected]')
  })

  it('mengembalikan undefined untuk path yang tidak ada', () => {
    const state = { form: {} }
    expect(resolvePath(state, 'form.phone')).toBeUndefined()
  })
})

Mengapa unit test di sini sangat bernilai

Bug pada evaluator biasanya berdampak luas. Satu kesalahan parsing ekspresi dapat merusak banyak layar sekaligus. Unit test memberi umpan balik cepat, deterministik, dan mudah dilokalisasi. Ini juga tempat terbaik untuk menguji edge case yang sulit direplikasi di browser.

Trade-off

Terlalu banyak menguji detail implementasi runtime bisa membuat test rapuh saat refactor. Fokuskan assertion pada kontrak perilaku, bukan struktur internal evaluator. Misalnya, lebih baik menguji bahwa ekspresi menghasilkan true atau false daripada memeriksa langkah parser internal.

3) Integration test untuk state, event, dan rendering

Setelah contract dan evaluator aman, lapisan berikutnya adalah integrasi: apakah schema, runtime, state store, dan komponen renderer bekerja bersama dengan benar? Di sini Anda ingin mensimulasikan interaksi pengguna tanpa perlu seluruh browser end-to-end.

Yang diuji pada integration test

  • Input pengguna mengubah state yang benar.
  • Perubahan state memicu rerender sesuai aturan schema.
  • Event seperti klik atau submit memanggil action yang benar.
  • Validasi tampil saat kondisi terpenuhi.
  • Komponen kondisional muncul/hilang sesuai state.

Contoh integration test

it('menampilkan field promoCode setelah status member aktif', async () => {
  renderRuntimeScreen(checkoutSchema, {
    initialState: { user: { isMember: false }, form: {} }
  })

  expect(screen.queryByLabelText('Kode Promo')).toBeNull()

  await runtime.dispatch({ type: 'SET_USER_MEMBER', payload: true })

  expect(await screen.findByLabelText('Kode Promo')).toBeInTheDocument()
})
it('menonaktifkan tombol bayar jika email belum valid', async () => {
  renderRuntimeScreen(checkoutSchema, {
    initialState: { user: { isMember: true }, form: { email: '' } }
  })

  const email = screen.getByLabelText('Email')
  const submit = screen.getByRole('button', { name: 'Bayar' })

  await userEvent.type(email, 'bukan-email-valid')

  expect(submit).toBeDisabled()
  expect(screen.getByText('Format email tidak valid')).toBeInTheDocument()
})

Pilih assertion berbasis perilaku, bukan struktur DOM rapuh

Untuk mencegah flaky test, hindari assertion yang terlalu bergantung pada detail markup seperti urutan elemen, class CSS yang berubah, atau selector hasil implementasi styling. Lebih aman memakai kueri berbasis aksesibilitas atau teks yang memang bagian dari kontrak UI, seperti label, role, dan nama tombol.

Jika satu-satunya cara menemukan elemen adalah selector CSS panjang, kemungkinan besar test Anda mengunci detail implementasi, bukan perilaku produk.

4) E2E test hanya untuk alur kritis

E2E tetap penting, tetapi jumlahnya harus selektif. Pada UI deklaratif, godaan terbesar adalah memindahkan seluruh beban validasi ke E2E karena terasa paling nyata. Ini biasanya berujung pada suite lambat, sering gagal, dan mahal dirawat.

Alur yang cocok untuk E2E

  • Login dan otorisasi.
  • Checkout atau pembayaran.
  • Pengisian form penting dengan integrasi backend nyata atau semu.
  • Navigasi antar-halaman yang melibatkan guard atau preload data.
  • Alur yang sangat dipengaruhi runtime config dari server.

Yang tidak perlu diuji berulang di E2E

  • Semua kombinasi ekspresi visibilitas.
  • Semua validator kecil yang sudah tercakup di unit/integration.
  • Setiap variasi komponen jika mesin renderernya sama.

Contoh skenario E2E yang tepat

  1. Buka halaman checkout dengan schema aktif saat ini.
  2. Isi email valid.
  3. Aktifkan kondisi member melalui data stub atau akun fixture.
  4. Pastikan field kode promo muncul.
  5. Submit dan verifikasi request backend atau halaman sukses.

Tujuan E2E di sini bukan membuktikan semua detail logika lagi, melainkan memverifikasi bahwa integrasi utuh tetap berjalan.

Sumber flaky test pada UI deklaratif

1. Timing async dan rerender berantai

UI deklaratif sering memiliki rantai async: fetch config, normalisasi schema, resolusi data, evaluasi ekspresi, update state, lalu rerender. Jika test melakukan assertion terlalu cepat, hasilnya tidak stabil.

Pencegahan:

  • Tunggu kondisi yang benar-benar mencerminkan selesai, bukan sekadar timeout tetap.
  • Gunakan API findBy* atau mekanisme tunggu yang berbasis kondisi.
  • Kurangi side effect berantai dalam satu event jika memungkinkan.

2. Data nondeterministik

Data acak, waktu sistem, ID generator, dan respons API yang berubah membuat snapshot atau assertion mudah gagal.

Pencegahan:

  • Bekukan waktu pada test yang sensitif tanggal/jam.
  • Stub generator ID atau random value.
  • Gunakan fixture data tetap untuk schema dan API.

3. Selector rapuh

Komponen runtime kadang menghasilkan DOM yang berubah karena refactor renderer, perubahan wrapper, atau styling system. Selector berbasis class dan struktur DOM menjadi tidak stabil.

Pencegahan:

  • Utamakan role, label, placeholder, atau teks yang relevan secara produk.
  • Gunakan data-testid hanya untuk elemen yang memang sulit diakses dengan kueri semantik.
  • Tetapkan aturan penamaan test id yang stabil dan berbasis domain, bukan nama komponen internal.

4. State tersembunyi dalam runtime

Runtime sering menyimpan cache evaluasi, registry event, state turunan, atau hasil normalisasi. Jika test tidak mengisolasi state ini, urutan eksekusi bisa memengaruhi hasil.

Pencegahan:

  • Buat instance runtime baru untuk setiap test.
  • Bersihkan cache global dan mock setelah tiap kasus.
  • Hindari singleton jika tidak benar-benar diperlukan.

5. Ketergantungan jaringan yang tidak terkendali

Jika schema, data, dan action memanggil jaringan sungguhan, test menjadi lambat dan tidak stabil.

Pencegahan:

  • Mock di level yang tepat: network boundary, repository, atau action handler.
  • Untuk integration test, prioritaskan mock respons yang realistis tetapi deterministik.
  • Simpan fixture API versi sukses, error, dan data kosong.

Strategi mock yang masuk akal

Kesalahan umum dalam testing UI deklaratif adalah melakukan mock terlalu banyak sampai runtime tidak benar-benar diuji, atau terlalu sedikit sampai test rapuh. Prinsip yang lebih aman: mock batas sistem, bukan inti perilaku yang sedang Anda verifikasi.

Apa yang biasanya dimock

  • Permintaan network untuk mengambil schema atau data backend.
  • Waktu sistem dan random generator.
  • Action eksternal seperti analytics, logging, atau navigasi lintas aplikasi.

Apa yang sebaiknya tidak dimock pada integration test

  • Evaluator ekspresi jika itu inti runtime Anda.
  • Binding resolver jika perubahan state UI bergantung padanya.
  • Validator utama jika itu bagian dari kontrak perilaku layar.

Contoh pendekatan mock

beforeEach(() => {
  mockServer.use(
    http.get('/ui-schema/checkout', () => {
      return HttpResponse.json(checkoutSchemaFixture)
    }),
    http.post('/checkout', () => {
      return HttpResponse.json({ success: true, orderId: 'order-123' })
    })
  )
})

Dengan pola ini, test tetap memverifikasi runtime sungguhan, tetapi sumber data di luar aplikasi dibuat stabil.

Contoh daftar test case yang bernilai tinggi

Contract test schema

  • ID komponen unik dalam satu layar.
  • Semua action yang dirujuk oleh onClick terdaftar di registry.
  • Semua ekspresi memakai field state yang valid.
  • Default prop wajib tersedia setelah normalisasi.

Unit test evaluator/runtime

  • Ekspresi boolean bekerja untuk state lengkap dan state parsial.
  • Resolver path aman untuk field yang tidak ada.
  • Validator required gagal untuk string kosong dan lolos untuk input valid.
  • Event dispatch memetakan action ke handler yang tepat.

Integration test state-event

  • Perubahan checkbox memunculkan section tambahan.
  • Input invalid menampilkan pesan error dan menonaktifkan submit.
  • Submit sukses me-reset state tertentu atau menavigasi ke layar berikutnya.
  • Fetch data gagal menampilkan fallback UI yang benar.

E2E alur kritis

  • Pengguna berhasil menyelesaikan checkout dari awal sampai akhir.
  • Pengguna tanpa izin tidak bisa membuka layar tertentu meskipun schema tersedia.
  • Schema terbaru dari server tetap kompatibel dengan runtime yang ter-deploy.

Checklist pencegahan regression

  • Schema memiliki validasi kontrak otomatis di CI.
  • Setiap bug runtime yang ditemukan mendapat regression test di lapisan terendah yang relevan.
  • Fixture schema nyata disimpan dan ditinjau seperti kode.
  • Instance runtime diisolasi per test, tanpa state global bocor.
  • Network, waktu, dan random source dibuat deterministik saat test.
  • Selector berbasis role/label diprioritaskan daripada CSS atau struktur DOM.
  • E2E dibatasi ke alur bisnis kritis, bukan seluruh variasi perilaku.
  • Perubahan schema dan perubahan runtime ditinjau bersama jika ada coupling.
  • Snapshot dipakai hemat; gunakan assertion perilaku untuk logika penting.
  • Flaky test tidak dibiarkan menumpuk; tandai, analisis akar masalah, lalu perbaiki.

Workflow CI yang aman untuk perubahan UI deklaratif

CI untuk UI deklaratif sebaiknya memisahkan pemeriksaan cepat dari pemeriksaan mahal. Tujuannya agar developer mendapat umpan balik cepat tanpa mengorbankan keamanan rilis.

Urutan yang disarankan

  1. Lint dan static checks: validasi dasar, tipe, dan aturan schema jika ada.
  2. Contract tests: jalankan sangat awal karena cepat dan sering menangkap error murah.
  3. Unit tests runtime: evaluasi ekspresi, resolver, validator, event mapper.
  4. Integration tests: render screen dengan fixture penting dan mock network stabil.
  5. E2E smoke tests: hanya jalur utama pada pull request.
  6. E2E penuh: jalankan pada branch utama, nightly, atau sebelum rilis.

Praktik tambahan yang membantu

  • Jalankan test paralel untuk mempersingkat waktu, tetapi pastikan tidak ada resource bersama yang menyebabkan balapan state.
  • Simpan artifact saat gagal: screenshot, log runtime, payload schema, dan jejak request mock.
  • Kelompokkan suite berdasarkan lapisan agar sumber kegagalan cepat diketahui.
  • Jika ada visual regression test, pakai hanya untuk komponen atau layar yang memang sensitif layout, bukan sebagai pengganti test perilaku.

Gate yang masuk akal di pull request

Pada pull request, minimal pastikan perubahan tidak mematahkan kontrak schema, evaluator utama, dan 1-2 integration test untuk layar yang terdampak. E2E penuh sering lebih cocok dijalankan sesudah merge atau menjelang rilis agar pipeline harian tetap cepat.

Kesalahan umum yang perlu dihindari

  • Semua diuji lewat E2E: hasilnya lambat, mahal, dan susah didiagnosis.
  • Terlalu banyak snapshot: mudah lolos meski perilaku penting rusak, atau sering berubah karena noise.
  • Mock evaluator inti: test tampak hijau tetapi logika runtime sebenarnya tidak tervalidasi.
  • Schema dianggap data biasa: padahal ia adalah sumber perilaku dan perlu kontrak.
  • Tidak ada fixture realistis: test lulus di contoh kecil, gagal di layar nyata yang lebih kompleks.

Penutup

Strategi uji UI deklaratif untuk cegah flaky dan regression harus berangkat dari fakta bahwa perilaku UI kini hidup di lebih banyak tempat: schema, runtime, evaluator, state, dan integrasi. Karena itu, pengujiannya tidak cukup berhenti di level komponen atau E2E.

Pendekatan yang paling aman adalah membangun piramida tes yang jelas: validasi kontrak schema, uji unit untuk evaluator runtime, integration test untuk state dan event, lalu E2E secukupnya pada alur kritis. Dengan begitu, Anda bisa mendeteksi bug lebih dini, mengurangi test flaky, dan merilis perubahan UI berbasis konfigurasi dengan risiko yang jauh lebih terkendali.