En esta página
Testing con Vitest y Vue Test Utils
Por qué testear componentes Vue
Las pruebas automatizadas son una inversión que protege tu aplicación de regresiones. En Vue, los tests verifican que:
- Los componentes renderizan correctamente con distintos props
- Los eventos del usuario (clics, inputs) producen los efectos correctos
- Los composables calculan y devuelven los valores esperados
- Los stores de Pinia gestionan el estado correctamente
Configuración del entorno de testing
Si usaste create-vue y seleccionaste Vitest, ya tienes todo configurado. De lo contrario:
npm install -D vitest @vue/test-utils @vue/vue3-jest happy-dom// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom', // o 'jsdom'
globals: true,
},
})mount() — montar componentes
import { mount } from '@vue/test-utils'
import MiComponente from './MiComponente.vue'
const wrapper = mount(MiComponente, {
props: { titulo: 'Hola', activo: true },
slots: {
default: '<p>Contenido del slot</p>',
cabecera: '<h2>Encabezado</h2>',
},
global: {
// Proporcionar plugins (router, pinia) para el árbol del componente
plugins: [router, pinia],
// Proporcionar valores de inject
provide: { TEMA: 'oscuro' },
// Stubs — reemplazar componentes con versiones simplificadas
stubs: { RouterLink: true, IconoCargando: true },
},
})Encontrar elementos
// .get() — lanza error si no existe
wrapper.get('button')
wrapper.get('[data-testid="submit"]')
wrapper.get('h1')
// .find() — devuelve undefined si no existe
const boton = wrapper.find('.boton-primario')
if (boton.exists()) {
// ...
}
// .findAll() — devuelve array
const items = wrapper.findAll('li')
expect(items).toHaveLength(3)
// .getComponent() — encontrar componente hijo
const subComponente = wrapper.getComponent(TarjetaUsuario)Verificar el contenido renderizado
// Texto
expect(wrapper.text()).toContain('Hola Mundo')
expect(wrapper.get('h1').text()).toBe('Título exacto')
// HTML
expect(wrapper.html()).toContain('<strong>')
// Atributos
expect(wrapper.get('input').attributes('type')).toBe('email')
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
expect(wrapper.get('img').attributes('alt')).toBe('Descripción')
// Clases
expect(wrapper.get('.tarjeta').classes()).toContain('activa')
expect(wrapper.classes('error')).toBe(false)
// Props del componente
expect(wrapper.props('activo')).toBe(true)Disparar eventos
// Clic
await wrapper.get('button').trigger('click')
// Input
await wrapper.get('input').setValue('nuevo valor')
// setValue actualiza el valor Y dispara el evento input
// Teclas
await wrapper.get('input').trigger('keyup.enter')
// Eventos personalizados
await wrapper.get('input').trigger('blur')Verificar emits
// Después de disparar un evento que causa un emit:
await wrapper.get('button').trigger('click')
// Verificar que se emitió
expect(wrapper.emitted('cambio')).toBeTruthy()
expect(wrapper.emitted('cambio')).toHaveLength(1)
expect(wrapper.emitted('cambio')?.[0]).toEqual([42]) // array de argumentosTesting de composables
Los composables se pueden testear directamente sin montar un componente:
// composables/useContador.test.ts
import { describe, it, expect } from 'vitest'
import { useContador } from './useContador'
describe('useContador', () => {
it('inicia con el valor proporcionado', () => {
const { contador } = useContador(5)
expect(contador.value).toBe(5)
})
it('incrementa correctamente', () => {
const { contador, incrementar } = useContador(0)
incrementar()
expect(contador.value).toBe(1)
})
it('no excede el máximo', () => {
const { contador, incrementar } = useContador(9, { max: 10 })
incrementar()
incrementar() // Intenta ir a 11
expect(contador.value).toBe(10)
})
it('el computed estaEnMaximo es correcto', () => {
const { estaEnMaximo, incrementar } = useContador(9, { max: 10 })
expect(estaEnMaximo.value).toBe(false)
incrementar()
expect(estaEnMaximo.value).toBe(true)
})
})Testing de stores de Pinia
// stores/carrito.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCarritoStore } from './carrito'
describe('useCarritoStore', () => {
beforeEach(() => {
// Crear una instancia fresca de Pinia antes de cada test
setActivePinia(createPinia())
})
it('inicia con carrito vacío', () => {
const carrito = useCarritoStore()
expect(carrito.items).toHaveLength(0)
expect(carrito.total).toBe(0)
})
it('agrega items correctamente', () => {
const carrito = useCarritoStore()
carrito.agregarItem({ id: 1, nombre: 'Teclado', precio: 45 })
expect(carrito.items).toHaveLength(1)
expect(carrito.items[0].cantidad).toBe(1)
})
it('incrementa la cantidad si el item ya existe', () => {
const carrito = useCarritoStore()
carrito.agregarItem({ id: 1, nombre: 'Teclado', precio: 45 })
carrito.agregarItem({ id: 1, nombre: 'Teclado', precio: 45 })
expect(carrito.items).toHaveLength(1)
expect(carrito.items[0].cantidad).toBe(2)
})
it('calcula el total correctamente', () => {
const carrito = useCarritoStore()
carrito.agregarItem({ id: 1, nombre: 'Teclado', precio: 45 })
carrito.agregarItem({ id: 2, nombre: 'Ratón', precio: 25 })
expect(carrito.total).toBe(70)
})
})shallowMount y stubs
// Test de componente padre sin testear los hijos
import { shallowMount } from '@vue/test-utils'
import ListaProductos from './ListaProductos.vue'
it('renderiza el número correcto de tarjetas', () => {
const wrapper = shallowMount(ListaProductos, {
props: {
productos: [
{ id: 1, nombre: 'A', precio: 10 },
{ id: 2, nombre: 'B', precio: 20 },
]
}
})
// TarjetaProducto es un stub en shallowMount
const tarjetas = wrapper.findAll('tarjeta-producto-stub')
expect(tarjetas).toHaveLength(2)
})Mocking de fetch
import { beforeEach, vi } from 'vitest'
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{ id: 1, nombre: 'Producto test' }
]),
}))
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('carga y muestra productos desde la API', async () => {
const wrapper = mount(ListaProductos)
await flushPromises() // Esperar a que todas las promesas resuelvan
expect(wrapper.text()).toContain('Producto test')
})Ejecutar los tests
# Modo watch (re-ejecuta en cada cambio)
npx vitest
# Una sola ejecución
npx vitest run
# Con cobertura
npx vitest run --coverage
# Interfaz visual en el navegador
npx vitest --uiPráctica
- Tests del componente TarjetaUsuario: Escribe tests para un componente que muestre nombre, email y rol. Verifica que el botón "Editar" emite el evento correcto, que las clases cambian según el rol, y que el email se muestra solo cuando está expandido.
- Tests del store de Pinia: Escribe un suite completo para
useCarritoStorecubriendo agregar items, eliminar items, el cálculo del total, y la acciónvaciar(). - Tests del composable useFetch: Mocka
fetchglobalmente y escribe tests para tu composableuseFetch. Verifica los estados de carga, datos recibidos y manejo de errores HTTP.
En la siguiente lección construirás un gestor de contactos completo aplicando todo lo aprendido en el curso.
data-testid — selectores estables para tests
Usa atributos data-testid en los elementos que necesitas encontrar en los tests. A diferencia de las clases CSS o el texto visible, los data-testid no cambian al hacer refactoring del diseño o traducciones. Son la forma más estable de seleccionar elementos para tests.
mount vs shallowMount — elige según el caso
mount() renderiza el componente con todos sus hijos reales. shallowMount() reemplaza los componentes hijos con stubs. Usa mount() para tests de integración donde los hijos son importantes, y shallowMount() para tests unitarios donde quieres aislar el componente padre.
await en acciones del DOM — siempre espera
Después de trigger('click') o trigger('input'), siempre usa await para esperar a que Vue procese el cambio reactivo y actualice el DOM. Sin await, el test puede verificar el DOM antes de que Vue haya actualizado, causando falsos negativos.
<script setup lang="ts">
import { ref, computed } from 'vue'
const { inicial = 0, paso = 1 } = defineProps<{
inicial?: number
paso?: number
}>()
const emit = defineEmits<{
cambio: [valor: number]
}>()
const contador = ref(inicial)
const esCero = computed(() => contador.value === 0)
function incrementar(): void {
contador.value += paso
emit('cambio', contador.value)
}
function decrementar(): void {
contador.value -= paso
emit('cambio', contador.value)
}
function reiniciar(): void {
contador.value = inicial
emit('cambio', contador.value)
}
</script>
<template>
<div class="contador" data-testid="contador">
<button
type="button"
data-testid="btn-decrementar"
@click="decrementar"
>
-
</button>
<span data-testid="valor">{{ contador }}</span>
<button
type="button"
data-testid="btn-incrementar"
@click="incrementar"
>
+
</button>
<button
v-if="!esCero"
type="button"
data-testid="btn-reiniciar"
@click="reiniciar"
>
Reiniciar
</button>
</div>
</template>
Inicia sesión para guardar tu progreso