Testing Vue Components — Vue

Testing memastikan komponen Vue bekerja sesuai harapan. Tools utama: 1. Vitest — test runner cepat (pengganti Jest): import { describe, it, expect } from…

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") tanpa await, lalu langsung memeriksa bahwa teks keranjang berbunyi Keranjang: 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.

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")
  })
})

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()
})

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

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

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.

Yang akan kamu pelajari