Testing de componentes con Vue Test Utils

Intermedio
Vuejs
Vuejs
Actualizado: 27/03/2026

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 - Autor del tutorial

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.

Aprendizajes de esta lección