Vue 3 and Nuxt · Vue 3 Fundamentals

Events, v-model and Component Tests

Emit typed events, implement v-model with defineModel, and test components with Vitest and Vue Test Utils.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A QuantityInput component that supports v-model through defineModel, respects min and max props, and emits a typed limit event; a parent CartLine that uses it; and Vitest tests that click buttons and assert on rendered text and emitted events.

The idea

  • defineEmits<{ name: [args] }>() declares typed events; the parent listens with @name.
  • defineModel() creates a ref bound to the parent's v-model: reading gives the prop, writing emits update:modelValue.
  • Vue Test Utils mount() renders a component in a DOM (happy-dom here); trigger() fires events and must be awaited for the DOM to update; emitted() records events.
  • Test behaviour through the rendered output and events, not internal state.

The code

defineModel, typed emits and Vitest tests
// file: QuantityInput.vue
<script setup lang="ts">
const { min = 1, max = 10 } = defineProps<{ min?: number; max?: number }>();
const quantity = defineModel<number>({ required: true });
const emit = defineEmits<{ limit: [which: "min" | "max"] }>();

function change(step: number) {
  const next = quantity.value + step;
  if (next < min) return emit("limit", "min");
  if (next > max) return emit("limit", "max");
  quantity.value = next;
}
</script>

<template>
  <div>
    <button aria-label="Decrease" :disabled="quantity <= min" @click="change(-1)">-</button>
    <output>{{ quantity }}</output>
    <button aria-label="Increase" :disabled="quantity >= max" @click="change(1)">+</button>
  </div>
</template>

// file: CartLine.vue
<script setup lang="ts">
import { ref } from "vue";
import QuantityInput from "./QuantityInput.vue";

const quantity = ref(1);
const message = ref("");
</script>

<template>
  <p>Teapot x {{ quantity }} = ${{ (quantity * 24).toFixed(2) }}</p>
  <QuantityInput v-model="quantity" :max="3" @limit="(which) => (message = `Reached the ${which} quantity`)" />
  <p v-if="message" role="status">{{ message }}</p>
</template>

// file: QuantityInput.test.ts
import { mount } from "@vue/test-utils";
import { describe, expect, test } from "vitest";
import QuantityInput from "./QuantityInput.vue";
import CartLine from "./CartLine.vue";

describe("QuantityInput", () => {
  test("emits update:modelValue when increased", async () => {
    const wrapper = mount(QuantityInput, { props: { modelValue: 2 } });
    await wrapper.get('[aria-label="Increase"]').trigger("click");
    expect(wrapper.emitted("update:modelValue")).toEqual([[3]]);
  });

  test("disables decrease at the minimum", () => {
    const wrapper = mount(QuantityInput, { props: { modelValue: 1 } });
    expect(wrapper.get('[aria-label="Decrease"]').attributes()).toHaveProperty("disabled");
  });
});

describe("CartLine", () => {
  test("updates the total through v-model", async () => {
    const wrapper = mount(CartLine);
    await wrapper.get('[aria-label="Increase"]').trigger("click");
    await wrapper.get('[aria-label="Increase"]').trigger("click");
    expect(wrapper.text()).toContain("Teapot x 3 = $72.00");
  });

  test("can't go below the minimum", async () => {
    const wrapper = mount(CartLine);
    await wrapper.get('[aria-label="Decrease"]').trigger("click");    // disabled: nothing happens
    expect(wrapper.find('[role="status"]').exists()).toBe(false);
    expect(wrapper.text()).toContain("Teapot x 1");
  });
});

Output

✓ QuantityInput.test.ts > QuantityInput > emits update:modelValue when increased
✓ QuantityInput.test.ts > QuantityInput > disables decrease at the minimum
✓ QuantityInput.test.ts > CartLine > updates the total through v-model
✓ QuantityInput.test.ts > CartLine > can't go below the minimum
Tests 4 passed (4)

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Forgetting to await trigger() or setValue(), then asserting before the DOM updates.
  • Testing internal refs (wrapper.vm.count) instead of output and events, making tests break on refactors.
  • Implementing v-model manually with a prop plus an emit when defineModel does it.
  • Emitting events with untyped payloads; type them with defineEmits<{...}>().

Key points

  • defineModel implements v-model; defineEmits declares typed events.
  • Test with mount, trigger and emitted, asserting on what users see.
  • Await DOM-changing actions in tests.

Further reading: Component v-model · Component events · Vue Test Utils · Testing in Vue

Practice questions

What does defineModel do?

Answer

It declares a modelValue prop and returns a ref: reading it gives the prop's value and assigning it emits update:modelValue, which is what v-model on the parent listens to.

Why await trigger() in Vue Test Utils?

Answer

Vue updates the DOM asynchronously; awaiting trigger waits for the next tick so assertions see the updated output.

What should component tests assert on?

Answer

On rendered output and emitted events, the component's public interface, rather than internal state, so refactors don't break tests.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?