Test E2E Hijau Tapi Salah: Pelajaran Uji Adversarial
26 route dilaporkan MISS sementara test bilang hijau. Assertion Playwright yang salah bentuk tidak pernah mengecek apa pun, dan adversarial run yang mengungkapnya.
Ringkasan
Test routing-crawl PortalKota full hijau padahal 26 route MISS, dan biang keroknya cuma satu baris: toBeAttached() dipanggil tanpa expect(), jadi cuma promise yang langsung resolve tanpa ngecek apa-apa. Akibatnya test selalu hijau walau selector salah atau halaman blank. Solusinya tiap spec harus bisa merah pas disabotase, kalau nggak, hijau itu cuma hiasan.
Routing-crawl test di PortalKota terakhir kali jalan full hijau. Puluhan langkah, nol kegagalan. Saya buka ringkasan coverage-nya: 26 route bertuliskan MISS. Dua angka itu tidak mungkin benar berduaan, dan yang salah ternyata bukan aplikasinya.
Tebakan pertama saya: DOM admin CMS-nya berubah, jadi selektor Playwright tidak nemu elemen. Saya tambahin log di blok catch, cetak err.message, berharap dapat nama selector yang gagal. Yang keluar malah tidak ada apa-apa. Blok catch itu tidak pernah ke-trigger sekali pun, padahal semua route dilaporkan MISS dalam hitungan milidetik.
Masalahnya ada di satu baris. Saya memanggil locator.toBeAttached() langsung sebagai method, tanpa membungkusnya di dalam expect(). Locator object punya method itu sebagai method yang mengembalikan promise. Dipanggil begitu saja, dia resolve seketika, tidak mengecek apa pun, tidak pernah melempar error. Test tetap hijau karena memang tidak ada assertion yang jalan. Laporan MISS itu justru datang dari kode pelaporan kita sendiri, bukan dari kegagalan test.
Baris yang salah itu bentuknya begitu saja begini:
// salah: promise doang, tidak pernah assert
await page.getByTestId('page-shell').toBeAttached();Bentuk yang benar, satu wrapper plus budget waktu eksplisit:
await expect(page.getByTestId('page-shell').first())
.toBeAttached({ timeout: 45_000 });Bedanya bukan kosmetik. Web-first assertion di Playwright itu menarik ulang elemennya dan mengecek ulang kondisinya terus-menerus sampai terpenuhi atau timeout, default-nya 5 detik [2]. toBeAttached memastikan elemennya benar-benar nempel di DOM. Locator memang jantungnya auto-waiting dan retry-ability Playwright [1], tapi retry itu cuma jalan lewat assertion yang benar bentuk. Tanpa expect(), tidak ada yang menarik ulang, tidak ada yang mengecek, tidak ada yang bisa gagal. Selector salah, halaman blank, server 500 — semuanya tetap hijau.
Salahnya bukan cuma di assertion
Begitu satu lubang ketahuan, adversarial run berikutnya menemukan lubang lain yang lebih dangkal. Filter jenis di PortalKota pakai dua macam input yang cara kerjanya kebalikan.
Yang pertama native. Mengklik option-nya tidak melakukan apa-apa di test, karena yang dibutuhkan memang selectOption({ label: 'Jenis' }) [3].
Yang kedua Radix UI Select. Item-itemnya dirender di portal dan SelectItem baru ada di DOM saat dropdown terbuka [4]. Kalau test langsung mencari itemnya di keadaan tertutup, hasilnya nihil. Anchor yang tahan banting adalah trigger button-nya, tombol yang men-toggle select itu, yang punya aria-label "Filter jenis" [4]. Buka lewat trigger, baru pilih itemnya selama masih terbuka.
await page.getByLabel('Filter jenis').selectOption({ label: 'Cagar alam' });Sisanya kumpulan fakta UI kecil yang dulu saya kira otomatis, dan ternyata tidak. MediaPicker mode single langsung mengonfirmasi saat file dipilih, tanpa langkah konfirmasi tambahan. Copy toast harus di-assert persis: "Wisata dibuat", "Halaman dibuat", "Slide ditambahkan". Nilai enum status di satu service itu "aktif", bukan "active". Satu field harus dikasih nilai status eksplisit sebelum save. Dan satu endpoint POST butuh boolean is_active eksplisit, bukan default diam-diam.
Semua ini terdengar remeh sampai sadar bahwa test kita meng-assert sesuatu yang tidak pernah dicek.
Ujiannya adalah merusaknya sendiri
Pelajaran paling mahal dari putaran ini bukan soal Playwright. Test yang hijau hanya membuktikan selektornya cocok, bukan bahwa halamannya bekerja. Assertion yang salah bentuk tetap lulus, dan laporan hijau itu lebih berbahaya daripada tidak punya test sama sekali, karena bikin semua orang berhenti bertanya.
Jadi rutinitasnya berubah. Sebelum sebuah spec saya anggap selesai, saya sabotase asumsinya sendiri dulu. Ganti labelnya jadi salah, paksa toast-nya beda satu huruf, hapus is_active dari payload, matikan servernya kalau perlu. Kalau testnya tidak merah, testnya belum menguji apa pun. Kalau sabotase bikin merah dengan pesan error yang jelas, barulah hijaunya punya arti.
Keputusan yang saya pegang sejak itu: satu spec satu sabotase check yang harus bisa gagal. Tanpa itu, hijau cuma hiasan.