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.
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'sv-model: reading gives the prop, writing emitsupdate: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
// 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
awaittrigger()orsetValue(), 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
defineModeldoes 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.