En esta página

Testing con Vitest y Vue Test Utils

14 min lectura TextoCap. 5 — Producción

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 argumentos

Testing 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 --ui

Práctica

  1. 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.
  2. Tests del store de Pinia: Escribe un suite completo para useCarritoStore cubriendo agregar items, eliminar items, el cálculo del total, y la acción vaciar().
  3. Tests del composable useFetch: Mocka fetch globalmente y escribe tests para tu composable useFetch. 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>