Test komponen memasang komponennya di browser tiruan, melakukan apa yang dilakukan pengguna (klik, ketik), lalu memeriksa apa yang dilihat pengguna atau apa yang dikirim komponennya. Alatnya dua: Vitest yang menjalankan test, dan Vue Test Utils (@vue/test-utils) yang memasang dan mengendalikan komponen. Bayangin uji rasa di Warung Bu Sri sebelum buka. Bu Sri memesan dari dapurnya sendiri seperti pembeli, lalu mencicipi piringnya. Dia gak membuka panci untuk memeriksa resepnya. Kalau besok pancinya diganti tapi rasanya tetap sama, uji rasanya tetap lulus. Test yang baik mencicipi piring, bukan memeriksa panci.
🧪 Tebakan cepat: Sebuah test mengklik tombol Tambah dengan
wrapper.find("button").trigger("click")tanpaawait, lalu langsung memeriksa bahwa teks keranjang berbunyiKeranjang: 1 item, Rp15000. Lulus atau gagal? Tebak dulu, jawabannya ada di bagian "trigger dan await".
Menyiapkan Vitest
Saat membuat proyek dengan npm create vue@latest, pilih fitur Vitest. create-vue memasang vitest, @vue/test-utils, dan jsdom (browser tiruan di Node), menambahkan script "test:unit": "vitest", dan membuat contoh test di src/components/__tests__/. File test berakhiran .spec.js atau .test.js.
npm run test:unitmenjalankan test lalu terus menunggu perubahan file.npx vitest runmenjalankannya sekali, lalu selesai.
Test pertama: mount dan text
Komponen yang diuji, MenuCard.vue:
<script setup>
defineProps({ nama: String, harga: Number, stok: Number })
const emit = defineEmits(["tambah"])
</script>
<template>
<div class="kartu">
<h3>{{ nama }}</h3>
<p>Rp{{ harga }}</p>
<button class="btn-tambah" :disabled="stok === 0" @click="emit('tambah', nama, harga)">
{{ stok === 0 ? "Habis" : "Tambah" }}
</button>
</div>
</template>
// src/components/__tests__/MenuCard.spec.js
import { describe, it, expect } from "vitest"
import { mount } from "@vue/test-utils"
import MenuCard from "../MenuCard.vue"
describe("MenuCard", () => {
it("menampilkan nama dan harga", () => {
const wrapper = mount(MenuCard, {
props: { nama: "Nasi Goreng", harga: 15000, stok: 8 },
})
expect(wrapper.text()).toContain("Nasi Goreng")
expect(wrapper.text()).toContain("Rp15000")
})
})
describemengelompokkan test, dan setiapitadalah satu test dengan nama yang menjelaskan perilakunya.mount(MenuCard, { props })memasang komponen di jsdom dengan props itu, lalu mengembalikan wrapper, pegangan untuk mengendalikannya.wrapper.text()mengambil semua teks yang tampil.expect(...).toContain(...)lulus kalau teksnya memuat kata itu.shallowMountmiripmount, tapi setiap komponen child diganti tiruan kosong (stub). Cocok kalau child-nya berat dan yang diuji cuma komponen itu sendiri.
Event: trigger dan emitted
it("mengirim tambah dengan nama dan harga", async () => {
const wrapper = mount(MenuCard, {
props: { nama: "Nasi Goreng", harga: 15000, stok: 8 },
})
await wrapper.find("button").trigger("click")
expect(wrapper.emitted("tambah")).toEqual([["Nasi Goreng", 15000]])
})
it("menonaktifkan tombol saat stok habis", () => {
const wrapper = mount(MenuCard, {
props: { nama: "Mie Ayam", harga: 12000, stok: 0 },
})
expect(wrapper.find("button").text()).toBe("Habis")
expect(wrapper.find("button").attributes("disabled")).toBeDefined()
})
wrapper.find("button")mencari elemen pertama yang cocok dengan selector itu..trigger("click")mengirim event klik ke elemen itu, seperti jari pembeli.wrapper.emitted("tambah")adalah daftar setiap kali eventtambahdikirim, dan setiap kiriman adalah array payload-nya. Satu klik berarti[["Nasi Goreng", 15000]]: array luar untuk kiriman, array dalam untuk payload.
trigger dan await
Test untuk App.vue, yang memasang dua MenuCard dan menampilkan keranjang:
// src/__tests__/App.spec.js
import { it, expect } from "vitest"
import { mount } from "@vue/test-utils"
import App from "../App.vue"
it("klik Tambah menambah keranjang", async () => {
const wrapper = mount(App)
await wrapper.find("button").trigger("click")
expect(wrapper.find(".keranjang").text()).toBe("Keranjang: 1 item, Rp15000")
})
Vue gak mengubah DOM tepat saat data berubah. Dia mengumpulkan perubahannya, lalu memperbarui DOM sekaligus di tick berikutnya. trigger mengembalikan promise yang selesai setelah pembaruan itu, jadi await menunggu DOM-nya siap. Tanpa await, test-nya gagal. Ini keluaran Vitest sungguhan:
FAIL src/__tests__/App.spec.js > klik Tambah menambah keranjang
AssertionError: expected 'Keranjang: 0 item, Rp0' to be 'Keranjang: 1 item, Rp15000' // Object.is equality
Expected: "Keranjang: 1 item, Rp15000"
Received: "Keranjang: 0 item, Rp0"
Datanya sudah berubah, tapi DOM yang dibaca test masih yang lama. Itu jawaban tebakannya. emitted() gak butuh await karena emit terjadi saat itu juga; yang perlu ditunggu adalah DOM. Untuk perubahan yang bukan dari trigger, pakai await nextTick() dari "vue", dan untuk promise lain, misalnya fetch tiruan, await flushPromises() dari @vue/test-utils.
Dengan await, keempat test lulus:
RUN v4.1.11 /home/sri/warung-bu-sri
Test Files 2 passed (2)
Tests 4 passed (4)
Start at 00:13:32
Duration 897ms (transform 92ms, setup 0ms, import 237ms, tests 38ms, environment 1.30s)
Uji perilaku, bukan cara kerjanya
Dua percobaan sungguhan pada test di atas:
Perubahan di MenuCard.vue |
Test mencari find("button") |
Test mencari find(".btn-tambah") |
|---|---|---|
class tombol diganti dari btn-tambah jadi tombol, pembeli gak melihat bedanya |
4 lulus | 2 gagal: Error: Cannot call trigger on an empty DOMWrapper. |
bug: emit('tambah', nama), harganya gak ikut |
gagal: expected [ [ 'Nasi Goreng' ] ] to deeply equal [ [ 'Nasi Goreng', 15000 ] ] |
gagal dengan pesan yang sama |
- Test yang mencari tombol seperti pembeli mencarinya tetap lulus saat class-nya diganti, dan gagal saat ada bug sungguhan. Itu yang diharapkan.
- Test yang bergantung pada class untuk styling, nama fungsi di dalam komponen, atau isi
wrapper.vmikut gagal setiap kali kodenya dirapikan, walau perilakunya sama. Alarm palsu seperti itu membuat orang berhenti percaya pada test-nya. - Bug harga tadi juga membuat test
Appgagal denganReceived: "Keranjang: 1 item, RpNaN".
Di editor latihan
Editor latihan punya test runner kecil yang meniru Vitest dan Vue Test Utils. Isinya describe, it, expect dengan matcher yang umum, mount dengan find, text, trigger, setValue, emitted, dan flushPromises. shallowMount belum ada. Editornya berisi Counter.vue dan, mulai baris // Counter.test.js, file test-mu:
// Counter.test.js
import { describe, it, expect } from "vitest"
import { mount } from "@vue/test-utils"
import Counter from "./Counter.vue"
describe("Counter", () => {
it("jadi 2 setelah diklik dua kali", async () => {
const wrapper = mount(Counter)
await wrapper.find("#increment").trigger("click")
await wrapper.find("#increment").trigger("click")
expect(wrapper.find("#count").text()).toBe("2")
})
})
Pratinjau menampilkan laporan seperti Vitest: ✓ atau × per test, lalu Tests 2 passed (2). Setelah semua lulus, editor menjalankan test-mu lagi pada salinan Counter.vue yang sengaja dirusak sedikit, misalnya ref(0) jadi ref(1) atau count++ jadi count--. Cara ini namanya mutation testing, seperti alat Stryker. Test yang bagus gagal pada setiap salinan rusak (killed). Salinan yang lolos (survived) menunjukkan hal yang belum dicek test-mu.
⚠️ Jebakan yang sering ketemu
- Lupa
awaitpadatrigger. Test-nya membaca DOM sebelum Vue memperbaruinya. Tulisawait wrapper.find("button").trigger("click"). - Menguji detail implementasi. Class untuk styling, nama fungsi internal, atau
wrapper.vm.sesuatumembuat test gagal saat kodenya dirapikan. Uji teks yang tampil, atribut sepertidisabled, danemitted(). - Bentuk
emitted()yang salah. Hasilnya array kiriman berisi array payload:toEqual([["Nasi Goreng", 15000]]), bukantoEqual(["Nasi Goreng", 15000]). - Latihan: test yang cuma mengecek satu keadaan. Test yang cuma memeriksa angka awal
0meloloskan salinan dengancount--. Periksa keduanya:0di awal dan2setelah dua klik, supayaMutation score: 100%.
TL;DR: Vitest menjalankan test, Vue Test Utils memasang komponennya: mount(MenuCard, { props }) mengembalikan wrapper, wrapper.text() membaca teks, find(selector) mencari elemen, trigger("click") mengklik, dan emitted("tambah") berisi setiap kiriman event, misalnya [["Nasi Goreng", 15000]]. Selalu await pada trigger, karena Vue memperbarui DOM di tick berikutnya; untuk promise lain pakai nextTick atau flushPromises. shallowMount mengganti child dengan stub. Uji perilaku yang dilihat pengguna, bukan class atau fungsi internal, supaya test gagal karena bug, bukan karena kodenya dirapikan. Latihan di editor menulis test untuk counter, 0 di awal dan 2 setelah dua klik, lalu mutation testing memastikan test-nya menangkap salinan yang rusak.