Testing de componentes Vue 3: qué merece un test y qué no
Cómo testear componentes Vue 3 con Vitest y Vue Test Utils sin acabar con una suite frágil: qué cubrir, qué ignorar y por qué los composables se testean aparte.
Testing de componentes Vue 3: qué merece un test y qué no
Casi todas las suites de tests frágiles que he heredado tienen el mismo síntoma: un cambio de CSS rompe veinte tests. Renombras una clase, mueves un div, y de repente la CI está en rojo sin que ninguna funcionalidad se haya roto.
La causa siempre es la misma. Tests como este:
// ❌ Antipatrón
it('renderiza el botón', () => {
const wrapper = mount(ContactForm)
expect(wrapper.find('.btn.btn--primary.form__submit').exists()).toBe(true)
expect(wrapper.vm.isLoading).toBe(false)
})
Ese test no comprueba que el formulario funcione. Comprueba que existe un elemento con tres clases concretas y que una variable interna se llama isLoading. Ambas cosas son decisiones que puedes cambiar mañana sin romper nada para el usuario — y el test se romperá igual.
Un test que falla cuando el código sigue funcionando no te está protegiendo. Te está cobrando un impuesto.
La regla que decide todo
Antes de escribir un test, la pregunta es una sola: ¿esto lo notaría el usuario si se rompiera?
Si la respuesta es sí, merece un test. Si es no, estás testeando implementación.
| Testea esto | No testees esto |
|---|---|
| Lo que se renderiza ante unas props | Nombres de clases CSS |
| Qué emite el componente al interactuar | Estado interno (wrapper.vm.loading) |
| Estados de carga, error y vacío | La estructura del DOM |
| Comportamiento accesible (roles, labels) | Que un método se llamó |
| Lógica de negocio en composables | Que Vue funciona |
Ese último punto es el que más tests innecesarios genera. No hace falta testear que v-if oculta un elemento, ni que un computed recalcula. Eso lo testea el equipo de Vue.
El setup mínimo
Vitest más happy-dom es suficiente para componentes. happy-dom es bastante más rápido que jsdom y cubre lo que necesita un componente típico:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
},
resolve: {
alias: { '~': resolve(__dirname, '.') },
},
})
El alias importa más de lo que parece. Sin él, cada import de ~/utils/algo dentro de un componente falla en el test aunque funcione en la app, y acabas cambiando imports para contentar al runner. Eso es dejar que el test dicte el código de producción.
Testear comportamiento, no estructura
El mismo formulario del principio, testeado por lo que hace:
// tests/ContactForm.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ContactForm from '~/components/ContactForm.vue'
describe('ContactForm', () => {
it('emite el payload al enviar con datos válidos', async () => {
const wrapper = mount(ContactForm)
await wrapper.find('input[name="name"]').setValue('Ana García')
await wrapper.find('input[name="email"]').setValue('ana@ejemplo.com')
await wrapper.find('textarea[name="message"]').setValue('Hola')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('submit')).toHaveLength(1)
expect(wrapper.emitted('submit')![0][0]).toEqual({
name: 'Ana García',
email: 'ana@ejemplo.com',
message: 'Hola',
})
})
it('no emite si el email es inválido', async () => {
const wrapper = mount(ContactForm)
await wrapper.find('input[name="email"]').setValue('no-es-un-email')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('submit')).toBeUndefined()
})
})
Fíjate en qué se selecciona: input[name="email"], form. Atributos que forman parte del contrato del formulario, no de su apariencia. Puedes reescribir todo el CSS y rehacer el layout — estos tests siguen pasando, porque lo que verifican no ha cambiado.
Cuando un elemento no tiene un selector semántico natural, la salida es data-testid, no una clase:
<button data-testid="submit" class="btn btn--primary">Enviar</button>
await wrapper.find('[data-testid="submit"]').trigger('click')
Un data-testid declara "esto lo usa un test" de forma explícita. Una clase CSS no dice nada, y el siguiente que refactorice estilos la borrará sin saber que algo dependía de ella.
Los composables se testean aparte
Este es el cambio que más reduce el número de tests de componente que necesitas.
Si la lógica vive dentro del componente, la única forma de testearla es montar el componente, simular interacciones y observar el DOM. Eso es lento y da mensajes de fallo malos: cuando rompe, no sabes si falló el cálculo o el renderizado.
Sacada a un composable, la lógica se testea directamente:
// composables/useContactForm.ts
import { contactSchema } from '~/utils/contactSchema'
export function useContactForm() {
const form = reactive({ name: '', email: '', message: '' })
const errors = ref<Record<string, string>>({})
function validate(): boolean {
const result = contactSchema.safeParse(form)
errors.value = result.success
? {}
: Object.fromEntries(
result.error.issues.map(issue => [issue.path[0], issue.message]),
)
return result.success
}
return { form, errors, validate }
}
// tests/useContactForm.test.ts
import { it, expect } from 'vitest'
import { useContactForm } from '~/composables/useContactForm'
it('recoge un error por cada campo inválido', () => {
const { form, errors, validate } = useContactForm()
form.email = 'no-es-un-email'
expect(validate()).toBe(false)
expect(errors.value.email).toBe('Invalid email address')
expect(errors.value.name).toBe('Missing name')
})
Sin montar nada, sin DOM, en milisegundos. Al componente le queda un único test que verifica que pinta los errores que el composable produce — y esa es toda su responsabilidad.
Nota: los composables que usan
onMounted,provideoinjectnecesitan una instancia de componente viva. Para esos,withSetup—montar un componente mínimo que solo llama al composable— es más honesto que forzar la API de Vue fuera de contexto.
Esquemas de validación: el mejor ratio del proyecto
Si usas Zod, los esquemas son lo más rentable que puedes testear. Son puros, sin dependencias, y cada test cubre una regla que de otro modo se verifica a mano en el navegador.
// tests/contactSchema.test.ts
import { it, expect } from 'vitest'
import { contactSchema } from '~/utils/contactSchema'
const VALID = {
name: 'Ana García',
email: 'ana@ejemplo.com',
message: 'Hola, me gustaría hablar sobre un proyecto.',
}
it('rechaza emails mal formados', () => {
for (const bad of ['notanemail', 'missing@', '@nodomain.com', 'a @b.com']) {
const result = contactSchema.safeParse({ ...VALID, email: bad })
expect(result.success, `se esperaba que "${bad}" fallara`).toBe(false)
}
})
Dos detalles que uso siempre. El objeto VALID con spread deja explícito qué cambia en cada caso: el test dice "esto es válido excepto el email". Y el segundo argumento de expect es el mensaje de fallo — con un bucle, sin él lees expected true to be false y no sabes cuál de los cuatro casos rompió.
Lo asíncrono, sin setTimeout
El error habitual al testear estados de carga es esperar con temporizadores:
// ❌ Antipatrón
await new Promise(resolve => setTimeout(resolve, 100))
expect(wrapper.text()).toContain('Enviado')
Eso es lento cuando pasa y frágil cuando la CI va cargada. flushPromises vacía la cola de microtareas y espera el ciclo de actualización de Vue, sin adivinar tiempos:
// tests/ContactForm.test.ts
import { it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
it('muestra la confirmación tras un envío correcto', async () => {
const send = vi.fn().mockResolvedValue({ ok: true })
const wrapper = mount(ContactForm, { props: { send } })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Enviado')
})
Aquí el formulario recibe la función de envío como prop send, en lugar de emitir el evento y esperar a que un padre lo resuelva. Inyectarla no es un truco de testing: es lo que hace el componente testeable sin mockear el módulo entero de red. Si un componente es difícil de testear, casi siempre es que tiene una dependencia que debería recibir en lugar de importar.
¿Cuánto testear?
| Escenario | Enfoque |
|---|---|
| Componente de presentación | Un test de render con props; nada más |
| Formulario o interacción | Comportamiento: eventos emitidos y estados visibles |
| Lógica de negocio | Composable testeado aparte, sin montar |
| Esquemas de validación | Cobertura de cada regla — barato y de alto valor |
| Layouts y wrappers | Normalmente ninguno |
Dónde acaba el test de componente
Nada de lo anterior cubre el recorrido real. Un test de componente monta el formulario aislado: no hay servidor, el router no navega y la red está mockeada. Verifica que el componente reacciona bien a lo que le llega — no que lo que le llega sea correcto.
De eso se encarga un E2E, con Playwright o Cypress: abrir la página, rellenar, enviar y comprobar que la confirmación aparece con la API respondiendo de verdad.
| Qué verificar | Dónde |
|---|---|
| Un campo inválido muestra su error | Componente |
| El esquema rechaza un email mal formado | Unitario |
| El envío llega a la API y vuelve | E2E |
| La página se sirve y el formulario funciona en el navegador | E2E |
La proporción importa. Los E2E son lentos y se rompen por causas ajenas al código —red, datos, timing—, así que uno o dos flujos por producto: el que genera ingresos y el que te dejaría en evidencia si se rompiera. Todo lo demás baja de nivel.
Y al revés: tener E2E es lo que permite que los tests de componente sean pocos. Si el flujo completo ya está cubierto arriba, montar el mismo formulario cinco veces para repasar variantes de validación es trabajo duplicado — esas variantes son un test de esquema de tres líneas.
Cómo montar esos flujos sin que acaben siendo el cuello de botella de la CI da para su propio artículo. Es el siguiente que tengo en la lista.
Conclusión
Lo que sigo en cada proyecto:
- Un test que falla sin que nada se haya roto es deuda, no cobertura. Bórralo.
- Selecciona por rol, atributo o
data-testid. Nunca por clase CSS. - Saca la lógica a composables y téstala ahí. Los tests de componente se reducen solos.
- Los esquemas de validación son el mejor ratio esfuerzo/valor de cualquier suite.
flushPromises, nosetTimeout.- Si algo es difícil de testear, el problema es el diseño, no el test.
La cobertura no es el objetivo. Un 90 % lleno de tests que verifican nombres de clase es peor que un 40 % que cubre lo que el usuario toca — porque el primero además te frena cada vez que refactorizas.
¿Preguntas o quieres ver algún caso concreto? Escríbeme a hola@miguel-jimenez.dev.