Introduccion a Vue Test Utils
Vue Test Utils es la libreria oficial para testing de componentes Vue. Proporciona una API para montar componentes de forma aislada, interactuar con ellos y verificar su comportamiento sin necesidad de un navegador real.
Instalacion
npm install -D @vue/test-utils
Vue Test Utils se usa junto con Vitest y un entorno DOM simulado como jsdom o happy-dom:
npm install -D vitest @vue/test-utils happy-dom
Configuracion en vite.config.ts:
export default defineConfig({
test: {
globals: true,
environment: 'happy-dom',
},
})
Montaje de componentes: mount vs shallowMount
Vue Test Utils ofrece dos formas de montar un componente:
mount(): renderiza el componente junto con todos sus componentes hijos. Adecuado para tests de integracion donde se necesita verificar la interaccion entre componentes.shallowMount(): renderiza el componente pero reemplaza los componentes hijos por stubs. Ideal para tests unitarios donde se quiere aislar el componente bajo test.
<!-- AlertBanner.vue -->
<script setup lang="ts">
defineProps<{
message: string
type?: 'info' | 'warning' | 'error'
}>()
</script>
<template>
<div :class="['alert', `alert--${type ?? 'info'}`]">
<span class="alert__message">{{ message }}</span>
</div>
</template>
import { describe, it, expect } from 'vitest'
import { mount, shallowMount } from '@vue/test-utils'
import AlertBanner from './AlertBanner.vue'
describe('AlertBanner', () => {
it('renderiza con mount completo', () => {
const wrapper = mount(AlertBanner, {
props: { message: 'Operacion exitosa' }
})
expect(wrapper.text()).toContain('Operacion exitosa')
})
it('renderiza con shallowMount', () => {
const wrapper = shallowMount(AlertBanner, {
props: { message: 'Aviso', type: 'warning' }
})
expect(wrapper.classes()).toContain('alert--warning')
})
})
Busqueda de elementos
Vue Test Utils proporciona metodos para localizar elementos y componentes dentro del wrapper:
// Buscar por selector CSS
const boton = wrapper.find('button')
const items = wrapper.findAll('li')
const input = wrapper.find('[data-testid="email-input"]')
// Buscar componentes hijos
const hijo = wrapper.findComponent(ChildComponent)
const hijos = wrapper.findAllComponents(ListItem)
La recomendacion es usar atributos data-testid para localizar elementos en tests, ya que son resistentes a cambios de estructura HTML y CSS:
<template>
<button data-testid="submit-btn" @click="handleSubmit">Enviar</button>
</template>
const boton = wrapper.find('[data-testid="submit-btn"]')
Verificacion del contenido renderizado
const wrapper = mount(AlertBanner, {
props: { message: 'Texto de prueba', type: 'error' }
})
// Verificar texto
expect(wrapper.text()).toContain('Texto de prueba')
// Verificar HTML
expect(wrapper.html()).toContain('<span class="alert__message">')
// Verificar existencia de elementos
expect(wrapper.find('.alert__message').exists()).toBe(true)
expect(wrapper.find('.no-existe').exists()).toBe(false)
// Verificar clases CSS
expect(wrapper.find('.alert').classes()).toContain('alert--error')
// Verificar atributos
expect(wrapper.find('input').attributes('type')).toBe('text')
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
Disparar eventos
Los eventos del usuario se simulan con trigger() y setValue():
<!-- Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<div>
<span data-testid="count">{{ count }}</span>
<button data-testid="increment" @click="count++">+</button>
<button data-testid="decrement" @click="count--">-</button>
</div>
</template>
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('incrementa el contador al hacer click', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="increment"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
})
it('decrementa el contador al hacer click', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="decrement"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('-1')
})
})
Para formularios con inputs:
// Escribir texto en un input
await wrapper.find('input[type="text"]').setValue('nuevo valor')
// Seleccionar una opcion en un select
await wrapper.find('select').setValue('opcion2')
// Marcar un checkbox
await wrapper.find('input[type="checkbox"]').setValue(true)
// Enviar un formulario
await wrapper.find('form').trigger('submit.prevent')
Testing de props
Las props se pasan en las opciones de montaje y se puede verificar su efecto en el renderizado:
<!-- UserCard.vue -->
<script setup lang="ts">
const props = defineProps<{
name: string
email: string
isAdmin?: boolean
}>()
</script>
<template>
<div class="user-card">
<h2>{{ name }}</h2>
<p>{{ email }}</p>
<span v-if="isAdmin" class="badge-admin">Administrador</span>
</div>
</template>
describe('UserCard', () => {
it('muestra nombre y email', () => {
const wrapper = mount(UserCard, {
props: {
name: 'Ana Garcia',
email: 'ana@example.com'
}
})
expect(wrapper.find('h2').text()).toBe('Ana Garcia')
expect(wrapper.find('p').text()).toBe('ana@example.com')
})
it('muestra badge de admin cuando isAdmin es true', () => {
const wrapper = mount(UserCard, {
props: { name: 'Ana', email: 'ana@example.com', isAdmin: true }
})
expect(wrapper.find('.badge-admin').exists()).toBe(true)
})
it('no muestra badge de admin por defecto', () => {
const wrapper = mount(UserCard, {
props: { name: 'Ana', email: 'ana@example.com' }
})
expect(wrapper.find('.badge-admin').exists()).toBe(false)
})
})
Testing de eventos emitidos
Los eventos emitidos se capturan con emitted():
<!-- SearchInput.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
search: [query: string]
clear: []
}>()
const query = ref('')
function handleSearch() {
emit('search', query.value)
}
function handleClear() {
query.value = ''
emit('clear')
}
</script>
<template>
<div>
<input v-model="query" data-testid="search-input" />
<button data-testid="search-btn" @click="handleSearch">Buscar</button>
<button data-testid="clear-btn" @click="handleClear">Limpiar</button>
</div>
</template>
describe('SearchInput', () => {
it('emite evento search con el texto introducido', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('[data-testid="search-input"]').setValue('vue testing')
await wrapper.find('[data-testid="search-btn"]').trigger('click')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')![0]).toEqual(['vue testing'])
})
it('emite evento clear al limpiar', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('[data-testid="clear-btn"]').trigger('click')
expect(wrapper.emitted('clear')).toBeTruthy()
expect(wrapper.emitted('clear')).toHaveLength(1)
})
it('no emite search si no se ha hecho click', () => {
const wrapper = mount(SearchInput)
expect(wrapper.emitted('search')).toBeUndefined()
})
})
Testing de slots
Vue Test Utils permite probar slots default, named y scoped:
<!-- Card.vue -->
<script setup lang="ts">
defineProps<{
title: string
}>()
</script>
<template>
<div class="card">
<header class="card__header">
<slot name="header">{{ title }}</slot>
</header>
<div class="card__body">
<slot />
</div>
<footer class="card__footer">
<slot name="footer" :timestamp="Date.now()" />
</footer>
</div>
</template>
describe('Card', () => {
it('renderiza el slot default', () => {
const wrapper = mount(Card, {
props: { title: 'Mi tarjeta' },
slots: {
default: '<p>Contenido del cuerpo</p>'
}
})
expect(wrapper.find('.card__body').text()).toBe('Contenido del cuerpo')
})
it('renderiza el slot named header', () => {
const wrapper = mount(Card, {
props: { title: 'Titulo por defecto' },
slots: {
header: '<h1>Titulo personalizado</h1>'
}
})
expect(wrapper.find('.card__header').text()).toBe('Titulo personalizado')
})
it('usa el titulo como fallback del header si no se proporciona slot', () => {
const wrapper = mount(Card, {
props: { title: 'Titulo fallback' }
})
expect(wrapper.find('.card__header').text()).toBe('Titulo fallback')
})
it('renderiza scoped slot con acceso a las props del slot', () => {
const wrapper = mount(Card, {
props: { title: 'Test' },
slots: {
footer: `<template #footer="{ timestamp }">
<span>Generado en: {{ timestamp }}</span>
</template>`
}
})
expect(wrapper.find('.card__footer').text()).toContain('Generado en:')
})
})
Testing de v-model
Para componentes que exponen v-model, se verifica el evento update:modelValue:
<!-- RatingInput.vue -->
<script setup lang="ts">
const props = defineProps<{
modelValue: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
function setRating(value: number) {
emit('update:modelValue', value)
}
</script>
<template>
<div class="rating">
<button
v-for="star in 5"
:key="star"
:class="{ active: star <= modelValue }"
:data-testid="`star-${star}`"
@click="setRating(star)"
>
★
</button>
</div>
</template>
describe('RatingInput', () => {
it('emite update:modelValue al hacer click en una estrella', async () => {
const wrapper = mount(RatingInput, {
props: { modelValue: 0 }
})
await wrapper.find('[data-testid="star-3"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![0]).toEqual([3])
})
it('marca como activas las estrellas hasta el valor actual', () => {
const wrapper = mount(RatingInput, {
props: { modelValue: 3 }
})
expect(wrapper.find('[data-testid="star-1"]').classes()).toContain('active')
expect(wrapper.find('[data-testid="star-3"]').classes()).toContain('active')
expect(wrapper.find('[data-testid="star-4"]').classes()).not.toContain('active')
})
})
Testing asincrono: nextTick y flushPromises
Cuando el DOM se actualiza de forma asincrona, se necesita esperar antes de hacer aserciones:
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
it('actualiza el DOM despues de un cambio reactivo', async () => {
const wrapper = mount(MiComponente)
// trigger ya incluye un nextTick, pero si se modifica estado directamente:
wrapper.vm.someRef = 'nuevo valor'
await nextTick()
expect(wrapper.text()).toContain('nuevo valor')
})
it('espera a que se resuelvan las promesas', async () => {
const wrapper = mount(ComponenteConFetch)
// flushPromises resuelve todas las promesas pendientes
await flushPromises()
expect(wrapper.find('.datos').exists()).toBe(true)
})
Proveer dependencias: plugins, provide y stubs
Al montar un componente, se pueden configurar dependencias globales:
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { createI18n } from 'vue-i18n'
const wrapper = mount(MiComponente, {
global: {
// Registrar plugins
plugins: [createPinia(), createI18n({ locale: 'es' })],
// Proveer valores para inject
provide: {
apiUrl: 'https://api.test.com',
currentUser: { id: 1, name: 'Test User' },
},
// Reemplazar componentes hijos con stubs
stubs: {
RouterLink: true, // stub generico
IconComponent: { // stub personalizado
template: '<span class="icon-stub" />'
},
},
// Registrar directivas globales
directives: {
focus: {} // stub de directiva v-focus
},
},
})
Este enfoque permite aislar el componente de sus dependencias externas, controlando exactamente lo que recibe durante el test.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Vuejs es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Vuejs
Explora más contenido relacionado con Vuejs y continúa aprendiendo con nuestros tutoriales gratuitos.