Skip to content

Guía de Tests — ecom-components

Introducción

Esta guía explica cómo escribir tests en ecom-components usando Vitest y @vue/test-utils con entorno happy-dom.

Lo importante: cada capa del proyecto se testea diferente. Los tests siguen la misma estructura espejo que app/, así que el test de app/components/atoms/ui/TheTitle.vue vive en tests/components/atoms/ui/TheTitle.spec.ts.

Requisitos Previos

  • Node.js y pnpm instalados
  • Dependencias del proyecto instaladas (pnpm install)
  • Familiaridad básica con Vue 3 y TypeScript

Paso 1: Configuración del entorno

El entorno ya está configurado. No necesitas hacer nada salvo añadir nuevos alias de rutas.

vitest.config.ts
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
css: {
postcss: {
plugins: [], // evita warnings de Tailwind
},
},
test: {
environment: "happy-dom",
globals: true, // describe, it, expect, vi disponibles sin importar
include: ["tests/**/*.spec.ts"],
},
});

Qué significa globals: true: no necesitas importar describe, it, expect ni vi. Están disponibles automáticamente en todos los tests.

Comandos disponibles

Terminal window
pnpm test # Ejecutar todos los tests una sola vez
pnpm test:watch # Modo watch: re-ejecuta al guardar
pnpm test:types # Verificar tipos con vue-tsc
# Filtrar tests
pnpm test MultiSelect # Solo archivos con "MultiSelect" en el nombre
pnpm test composables/ # Solo carpeta "composables"
pnpm test -- -t "emite" # Solo tests cuyo nombre contiene "emite"

Paso 2: Estructura de carpetas y convenciones

Los tests espejean la estructura de app/:

app/components/atoms/ui/TheTitle.vue
tests/components/atoms/ui/TheTitle.spec.ts

Estructura de carpetas

tests/
├── components/
│ ├── atoms/
│ │ ├── forms/
│ │ │ └── MultiSelect.spec.ts
│ │ └── ui/
│ │ └── TheTitle.spec.ts
│ ├── molecules/
│ │ └── ui/
│ │ ├── MainCard.spec.ts
│ │ └── PlanCard.spec.ts
│ └── organisms/
│ └── ui/
│ └── Footer.spec.ts
├── composables/
│ └── useCardBehavior.spec.ts
└── directives/
└── outside-click.spec.ts

Plantilla base de un test

tests/components/atoms/ui/ComponentName.spec.ts
import { mount } from "@vue/test-utils";
import ComponentName from "../../../../app/components/atoms/ui/ComponentName.vue";
describe("ComponentName.vue", () => {
const defaultProps = {
title: "Título de prueba",
};
describe("Renderizado", () => {
it("renderiza correctamente con props por defecto", () => {
const wrapper = mount(ComponentName, { props: defaultProps });
expect(wrapper.exists()).toBe(true);
});
});
describe("Props", () => {
it("muestra el title recibido por prop", () => {
const wrapper = mount(ComponentName, { props: defaultProps });
expect(wrapper.text()).toContain("Título de prueba");
});
});
describe("Interacciones", () => {
it("emite el evento correcto al hacer click", async () => {
const wrapper = mount(ComponentName, { props: defaultProps });
await wrapper.find("button").trigger("click");
expect(wrapper.emitted("eventName")).toBeTruthy();
});
});
describe("Edge cases", () => {
it("no rompe con título vacío", () => {
const wrapper = mount(ComponentName, {
props: { ...defaultProps, title: "" },
});
expect(wrapper.exists()).toBe(true);
});
});
});

Reglas del proyecto

  • ✅ Importar mount / shallowMount explícitamente de @vue/test-utils
  • ✅ Usar rutas relativas para imports
  • ❌ Prohibido ~ y @/ en imports
  • ❌ No testear clases CSS ni estructura HTML interna
  • ✅ Usar ! en accesos por índice: items[0]!.text() (porque noUncheckedIndexedAccess: true)

Paso 3: Tests de Composables

Los composables son lógica pura sin HTML. Son los más fáciles de testear.

Qué testear:

  • Estado inicial de cada ref
  • Que cada función cambia el estado correctamente
  • Casos límite (valores vacíos, null, undefined)

Qué NO testear:

  • Cómo se ve en el DOM (eso va en el test del componente que lo usa)

Ejemplo básico

tests/composables/useCardBehavior.spec.ts
import { ref } from "vue";
import {
useCardToggle,
useCardFlip,
} from "../../app/composables/useCardBehavior";
describe("useCardToggle", () => {
it("showDetails empieza en false", () => {
const { showDetails } = useCardToggle(ref(true));
expect(showDetails.value).toBe(false);
});
it("openCard activa showDetails si isDropdownActivated es true", () => {
const { showDetails, openCard } = useCardToggle(ref(true));
openCard();
expect(showDetails.value).toBe(true);
});
it("openCard no hace nada si isDropdownActivated es false", () => {
const { showDetails, openCard } = useCardToggle(ref(false));
openCard();
expect(showDetails.value).toBe(false);
});
});

Con timers falsos (para setTimeout/setInterval)

Si el composable usa setTimeout o setInterval, controla el tiempo con vi.useFakeTimers():

describe("useCountdown", () => {
// Antes de CADA test: activa timers falsos
beforeEach(() => vi.useFakeTimers());
// Después de CADA test: restaura timers reales
afterEach(() => vi.useRealTimers());
it("decrementa el contador cada segundo", () => {
const { count, start } = useCountdown(10);
start();
// Avanza el tiempo 3 segundos (instantáneamente en el test)
vi.advanceTimersByTime(3000);
expect(count.value).toBe(7);
});
it("stop() cancela el intervalo", () => {
const { count, start, stop } = useCountdown(10);
start();
vi.advanceTimersByTime(2000);
stop(); // pausa el countdown
vi.advanceTimersByTime(3000); // avanza más tiempo
// El contador se quedó congelado en 8 (no siguió contando)
expect(count.value).toBe(8);
});
});

Con elementos del DOM

Si el composable necesita un Ref<HTMLElement>, créalo con document.createElement():

it("arranca sin errores con un elemento real", () => {
// 1. Crea elemento
const el = document.createElement("div");
document.body.appendChild(el);
// 2. Envuelve en ref
const elRef = ref(el);
// 3. Llama al composable
const { width } = useResizeObserver(elRef);
expect(width.value).toBe(0);
// 4. LIMPIEZA: saca el elemento (importante para no contaminar otros tests)
document.body.removeChild(el);
});

Funciones útiles para tests de Composables

FunciónDescripciónEjemplo
vi.useFakeTimers()Activa timers simulados para controlar setTimeout/setIntervalbeforeEach(() => vi.useFakeTimers())
vi.advanceTimersByTime(ms)Avanza el tiempo simulado instantáneamentevi.advanceTimersByTime(1000) para 1 segundo
vi.useRealTimers()Restaura los timers reales después de los testsafterEach(() => vi.useRealTimers())
vi.spyOn(obj, 'method')Espía una función sin reemplazarlavi.spyOn(composable, 'useCard')
expect(fn).toHaveBeenCalled()Verifica que la función se ejecutóexpect(spy).toHaveBeenCalled()
expect(fn).toHaveBeenCalledWith(args)Verifica argumentos específicosexpect(fn).toHaveBeenCalledWith('val')
expect(fn).toHaveBeenCalledTimes(n)Verifica cuántas veces se ejecutóexpect(fn).toHaveBeenCalledTimes(2)
beforeEach() / afterEach()Setup/cleanup antes/después de cada testbeforeEach(() => vi.clearAllMocks())

Paso 4: Tests de Átomos

Los átomos son componentes pequeños y reutilizables. Se testean de forma simple y directa.

Qué testear:

  • Que el slot o texto se renderiza
  • Que las props cambian lo que se ve (v-if, atributos)
  • Que emiten eventos correctamente
  • Estados especiales (disabled, isLoading)

Qué NO testear:

  • Clases CSS de diseño
  • HTML interno exacto
  • element.style.* (propiedades CSS)

Ejemplo con slot

import { mount } from "@vue/test-utils";
import TheTitle from "../../../../app/components/atoms/ui/TheTitle.vue";
describe("TheTitle.vue", () => {
it("renderiza el contenido del slot (texto plano)", () => {
const wrapper = mount(TheTitle, { slots: { default: "Hola mundo" } });
expect(wrapper.find("h2").text()).toBe("Hola mundo");
});
it("renderiza contenido HTML en el slot", () => {
const wrapper = mount(TheTitle, {
slots: { default: "<span>Título</span>" },
});
expect(wrapper.find("span").exists()).toBe(true);
});
});

Ejemplo completo con props, v-model y eventos

import { mount } from "@vue/test-utils";
import MultiSelect from "../../../../app/components/atoms/forms/MultiSelect.vue";
describe("MultiSelect.vue", () => {
const defaultProps = {
title: "Selecciona tus preferencias",
options: ["Opción 1", "Opción 2", "Opción 3"],
};
// Helper para no repetir findComponent cada vez
const multiselect = (wrapper: ReturnType<typeof mount>) =>
wrapper.findComponent({ name: "Multiselect" });
describe("Renderizado", () => {
it("muestra el título recibido por prop", () => {
const wrapper = mount(MultiSelect, { props: defaultProps });
expect(wrapper.find("p").text()).toBe("Selecciona tus preferencias");
});
it("monta el componente Multiselect interno", () => {
const wrapper = mount(MultiSelect, { props: defaultProps });
expect(multiselect(wrapper).exists()).toBe(true);
});
});
describe("Props → options", () => {
it("pasa las opciones al Multiselect", () => {
const wrapper = mount(MultiSelect, { props: defaultProps });
expect(multiselect(wrapper).props("options")).toEqual(
defaultProps.options,
);
});
it("pasa una lista vacía cuando options es []", () => {
const wrapper = mount(MultiSelect, {
props: { ...defaultProps, options: [] },
});
expect(multiselect(wrapper).props("options")).toEqual([]);
});
});
describe("v-model", () => {
it("inicializa el Multiselect con array vacío por defecto", () => {
const wrapper = mount(MultiSelect, { props: defaultProps });
expect(multiselect(wrapper).props("modelValue")).toEqual([]);
});
it("emite update:modelValue cuando el Multiselect cambia", async () => {
const wrapper = mount(MultiSelect, { props: defaultProps });
await multiselect(wrapper).vm.$emit("update:modelValue", ["Opción 2"]);
expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([["Opción 2"]]);
});
});
describe("Reactividad", () => {
it("actualiza el título cuando la prop cambia", async () => {
const wrapper = mount(MultiSelect, { props: defaultProps });
await wrapper.setProps({ title: "Nuevo título" });
expect(wrapper.find("p").text()).toBe("Nuevo título");
});
});
describe("Edge cases", () => {
it("renderiza con título vacío sin errores", () => {
const wrapper = mount(MultiSelect, {
props: { ...defaultProps, title: "" },
});
expect(wrapper.find("p").text()).toBe("");
});
it("no ejecuta el título como HTML (protección XSS)", () => {
const wrapper = mount(MultiSelect, {
props: { ...defaultProps, title: '<script>alert("xss")</script>' },
});
expect(wrapper.find("p").html()).not.toContain("<script>");
expect(wrapper.find("p").text()).toContain("alert");
});
});
});

Nota sobre withDefaults

Si el componente usa withDefaults, debes definir explícitamente TODOS los valores por defecto, incluso si parecen obvios:

// ❌ INCOMPLETO - isLoading será undefined
const props = withDefaults(defineProps<Props>(), {
showIcon: true,
});
// ✅ CORRECTO - todos los defaults definidos
const props = withDefaults(defineProps<Props>(), {
showIcon: true,
isLoading: false, // necesario, aunque sea el "default natural"
});

Funciones útiles para tests de Átomos

FunciónDescripciónEjemplo
mount(Component, options)Monta un componente completo para testearmount(MyButton, { props: { label: 'Click' } })
wrapper.find(selector)Busca un elemento en el DOMwrapper.find('button')
wrapper.findAll(selector)Busca múltiples elementoswrapper.findAll('.item')
wrapper.findComponent(Component)Busca un componente Vue hijowrapper.findComponent(Icon)
wrapper.text()Obtiene el texto renderizadoexpect(wrapper.text()).toContain('Hola')
wrapper.html()Obtiene el HTML renderizadoexpect(wrapper.html()).toContain('<span>')
wrapper.emitted('eventName')Obtiene los eventos emitidosexpect(wrapper.emitted('click')).toBeTruthy()
wrapper.props('propName')Obtiene una propwrapper.props('disabled')
wrapper.setProps(props)Cambia props reactivamenteawait wrapper.setProps({ title: 'Nuevo' })
wrapper.trigger('event')Dispara un evento en el elementoawait wrapper.find('button').trigger('click')
wrapper.exists()Verifica si el elemento existeexpect(wrapper.exists()).toBe(true)

Paso 5: Tests de Moléculas

Las moléculas son combinaciones de átomos. Se testea cómo se coordinan entre ellas.

Qué testear:

  • El flujo completo: trigger → emit → cambio de estado
  • Que las props llegan a los hijos correctos
  • Comportamiento condicional (v-if)
  • Componentes hijo complejos (stubearlos)

Qué NO testear:

  • Lo que ya cubre el test del átomo hijo
  • Detalles de estilos

Flujo completo de interacción

import { mount } from "@vue/test-utils";
import MainCard from "../../../../app/components/molecules/ui/MainCard.vue";
const defaultProps = { id: 1, title: "Vue Avanzado", cardImage: {} as any };
// Helper: reutiliza el setup en cada test
const mountCard = (props = {}) =>
mount(MainCard, {
props: { ...defaultProps, ...props },
global: { stubs: { Icon: true } }, // Stubea Icon (componente externo)
});
describe("MainCard.vue", () => {
describe("Renderizado", () => {
it("muestra el título", () => {
const wrapper = mountCard();
expect(wrapper.find("strong").text()).toBe("Vue Avanzado");
});
it("no renderiza el secondaryTitle cuando no se pasa", () => {
const wrapper = mountCard();
expect(wrapper.find("p").exists()).toBe(false);
});
});
describe("Interacciones", () => {
it("emite buttonClicked al hacer click", async () => {
const wrapper = mountCard({ cardType: "curso" });
await wrapper.find("section").trigger("click");
expect(wrapper.emitted("buttonClicked")?.[0]).toEqual([1, "curso"]);
});
it("no emite cuando isLoading es true", async () => {
const wrapper = mountCard({ isLoading: true });
await wrapper.find("section").trigger("click");
expect(wrapper.emitted("buttonClicked")).toBeFalsy();
});
});
describe("Aside (panel lateral condicional)", () => {
it("no se muestra inicialmente", () => {
const wrapper = mountCard();
expect(wrapper.find("aside").exists()).toBe(false);
});
it("aparece al hacer mouseenter", async () => {
const wrapper = mountCard({ isDropdownActivated: true });
await wrapper.find("article").trigger("mouseenter");
expect(wrapper.find("aside").exists()).toBe(true);
});
it("desaparece al hacer mouseleave", async () => {
const wrapper = mountCard({ isDropdownActivated: true });
await wrapper.find("article").trigger("mouseenter");
await wrapper.find("article").trigger("mouseleave");
expect(wrapper.find("aside").exists()).toBe(false);
});
});
});

Con inject / provide

Si el componente usa inject, pásalo con global.provide:

import { mount } from "@vue/test-utils";
import PlanCard from "../../../../app/components/molecules/ui/PlanCard.vue";
describe("PlanCard.vue", () => {
const defaultProps = {
id: "plan-1",
name: "Plan Básico",
description: "...",
};
// Helper que proporciona la función inyectada
const mountCard = (provide = {}) =>
mount(PlanCard, {
props: defaultProps,
global: {
stubs: { Icon: true },
provide: { injectFunction: vi.fn(), ...provide },
},
});
it("muestra el nombre del plan", () => {
expect(mountCard().text()).toContain("Plan Básico");
});
it("llama a injectFunction al hacer click", async () => {
const injectFunction = vi.fn(); // Tu mock personalizado
const wrapper = mountCard({ injectFunction });
await wrapper.find("button").trigger("click");
expect(injectFunction).toHaveBeenCalledWith("plan-1");
});
});

Funciones útiles para tests de Moléculas

FunciónDescripciónEjemplo
global.stubsReemplaza componentes hijo complejos con stubsglobal: { stubs: { Icon: true, Swiper: true } }
global.provideProporciona valores inyectados (inject)global: { provide: { myFunc: vi.fn() } }
wrapper.emitted('event')Obtiene todos los eventos emitidoswrapper.emitted('buttonClicked')
wrapper.emitted('event')?.[0]Obtiene los argumentos del 1er eventowrapper.emitted('click')?.[0]
vi.fn()Crea una función mock que se puede espiarconst mockFn = vi.fn()
wrapper.find(selector).exists()Verifica que un elemento existeexpect(wrapper.find('aside').exists()).toBe(true)
nextTick()Espera a que Vue actualice el DOMawait nextTick(); expect(wrapper.find('div').exists()).toBe(true)
wrapper.vm.$emit('event', args)Emite un evento desde el wrapperawait wrapper.vm.$emit('update', value)

Paso 6: Tests de Organismos

Los organismos son compuestos de moléculas. Se hacen tests de integración ligeros.

Qué testear:

  • Que los hijos correctos se montan (o no) según las props
  • Que el texto que el organismo renderiza directamente es correcto

Qué NO testear:

  • Cada prop de cada hijo (ya está cubierto en sus propios tests)
  • Detalles de estilos

Herramienta clave: shallowMount

shallowMount reemplaza automáticamente todos los componentes hijo con stubs vacíos. Así:

  • Testas solo la lógica del padre
  • Evitas dependencias raras de los hijos (NuxtLink, Swiper, etc.)
  • El test es rápido

Ejemplo

import { shallowMount } from "@vue/test-utils";
import Footer from "../../../../app/components/organisms/ui/Footer.vue";
import AEAtomsFooterLinkList from "../../../../app/components/atoms/footer/LinkList.vue";
import AEMoleculesUiAppButtons from "../../../../app/components/molecules/ui/AppButtons.vue";
const defaultProps = {
linkLists: [
{ title: "Empresa", links: [{ text: "Sobre nosotros", href: "/about" }] },
{ title: "Legal", links: [{ text: "Privacidad", href: "/privacy" }] },
],
socials: { socials: [] },
verticales: {
title: "Verticales",
links: [{ text: "Medicina", href: "/medicina" }],
},
footerText: "Copyright 2024",
};
describe("Footer.vue", () => {
it("muestra el footerText", () => {
const wrapper = shallowMount(Footer, { props: defaultProps });
expect(wrapper.find("p").text()).toContain("Copyright 2024");
});
it("no muestra footerText si no se pasa", () => {
const { footerText: _, ...propsWithoutText } = defaultProps;
const wrapper = shallowMount(Footer, { props: propsWithoutText });
expect(wrapper.text()).not.toContain("Copyright 2024");
});
it("renderiza una LinkList por cada elemento + la de verticales", () => {
const wrapper = shallowMount(Footer, { props: defaultProps });
const lists = wrapper.findAllComponents(AEAtomsFooterLinkList);
// 2 linkLists (Empresa, Legal) + 1 verticales = 3
expect(lists).toHaveLength(3);
});
it("no muestra AppButtons si no se pasa la prop", () => {
const wrapper = shallowMount(Footer, { props: defaultProps });
expect(wrapper.findComponent(AEMoleculesUiAppButtons).exists()).toBe(false);
});
it("muestra AppButtons cuando se pasa la prop", () => {
const wrapper = shallowMount(Footer, {
props: {
...defaultProps,
appButtons: {
ios: { text: "Descargar en App Store", href: "/app-ios" },
},
},
});
expect(wrapper.findComponent(AEMoleculesUiAppButtons).exists()).toBe(true);
});
});

Importante: usa findComponent(ComponentImportado) en lugar de { name: '...' }

// ✅ ROBUSTO - busca por referencia al archivo
wrapper.findComponent(AEMoleculesUiAppButtons).exists();
// ⚠️ FRÁGIL - busca por nombre interno (puede cambiar)
wrapper.findComponent({ name: "AppButtons" }).exists();

Funciones útiles para tests de Organismos

FunciónDescripciónEjemplo
shallowMount(Component)Monta componente con hijos como stubs vacíosshallowMount(Footer, { props: data })
wrapper.findAllComponents(Component)Encuentra todos los componentes hijoswrapper.findAllComponents(LinkList)
wrapper.findAllComponents(Component).lengthCuenta cuántos componentes hijo hayexpect(lists).toHaveLength(3)
wrapper.findComponent(Component).exists()Verifica si un componente hijo existeexpect(wrapper.findComponent(Icon).exists()).toBe(true)
wrapper.findComponent(Component).props()Obtiene las props de un componente hijowrapper.findComponent(Icon).props('size')
vi.clearAllMocks()Limpia todos los mocks después de testsafterEach(() => vi.clearAllMocks())
beforeEach() / afterEach()Setup/cleanupafterEach(() => vi.restoreAllMocks())

Paso 7: Tests de Directivas

Las directivas aplican lógica de DOM pura. Se testean sin montar ningún componente Vue.

Qué testear:

  • Que el callback se ejecuta cuando debe
  • Que NO se ejecuta cuando no debe
  • Que los event listeners se limpian (sin memory leaks)

Qué NO testear:

  • El comportamiento del componente que usa la directiva (eso va en su test)

Ejemplo

import { outsideClick } from "../../app/directives/outside-click";
describe("outsideClick directive", () => {
// Helpers para reutilizar setup/cleanup
const setup = (callback = vi.fn()) => {
const el = document.createElement("div");
document.body.appendChild(el);
outsideClick.mounted(el, { value: callback });
return { el, callback };
};
const cleanup = (el: HTMLElement) => {
outsideClick.beforeUnmount(el);
document.body.removeChild(el);
};
it("llama al callback al hacer click FUERA del elemento", () => {
const { el, callback } = setup();
const outside = document.createElement("span");
document.body.appendChild(outside);
outside.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(callback).toHaveBeenCalledTimes(1);
document.body.removeChild(outside);
cleanup(el);
});
it("NO llama al callback al hacer click DENTRO del elemento", () => {
const { el, callback } = setup();
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(callback).not.toHaveBeenCalled();
cleanup(el);
});
it("elimina el listener en beforeUnmount (sin memory leaks)", () => {
const { el, callback } = setup();
outsideClick.beforeUnmount(el);
const outside = document.createElement("span");
document.body.appendChild(outside);
outside.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// El listener ya fue eliminado, así que no se ejecuta
expect(callback).not.toHaveBeenCalled();
document.body.removeChild(outside);
document.body.removeChild(el);
});
});

Funciones útiles para tests de Directivas

FunciónDescripciónEjemplo
document.createElement(tag)Crea un elemento DOMconst el = document.createElement('div')
document.body.appendChild(el)Añade elemento al DOMdocument.body.appendChild(el)
document.body.removeChild(el)Elimina elemento del DOMdocument.body.removeChild(el)
el.dispatchEvent(event)Dispara un evento en el elementoel.dispatchEvent(new MouseEvent('click'))
new MouseEvent('click', options)Crea un evento de ratónnew MouseEvent('click', { bubbles: true })
new KeyboardEvent('keydown', options)Crea un evento de tecladonew KeyboardEvent('keydown', { key: 'Enter' })
vi.fn()Crea una función mockconst callback = vi.fn()
expect(fn).toHaveBeenCalled()Verifica que se ejecutóexpect(callback).toHaveBeenCalled()
expect(fn).not.toHaveBeenCalled()Verifica que NO se ejecutóexpect(callback).not.toHaveBeenCalled()

Paso 8: Mocking

Vitest proporciona herramientas poderosas para mockear. Úsalas correctamente.

vi.fn() — reemplazar una función completamente

Úsalo cuando necesites reemplazar el comportamiento de una función:

const mockFn = vi.fn();
mockFn("arg1");
expect(mockFn).toHaveBeenCalledWith("arg1");
expect(mockFn).toHaveBeenCalledTimes(1);
// Con valor de retorno fijo
const mockFn = vi.fn().mockReturnValue(42);
expect(mockFn()).toBe(42);
// Distinto valor en cada llamada
const mockFn = vi
.fn()
.mockReturnValueOnce("primera")
.mockReturnValueOnce("segunda");
expect(mockFn()).toBe("primera");
expect(mockFn()).toBe("segunda");

vi.spyOn() — observar sin reemplazar

Úsalo cuando quieras observar si una función se ejecuta, pero que siga funcionando de verdad:

import * as composable from "../../app/composables/useCardBehavior";
afterEach(() => vi.restoreAllMocks());
it("useCardToggle es llamado al montar el componente", () => {
// Crea un spy que OBSERVA la función
const spy = vi.spyOn(composable, "useCardToggle");
mount(MainCard, { props: defaultProps });
// Verifica que se ejecutó
expect(spy).toHaveBeenCalled();
});

Diferencia clave:

  • vi.fn() = la función ES fake
  • vi.spyOn() = la función es real, pero la observas

vi.stubGlobal() — APIs que happy-dom no tiene

Happy-dom no tiene IntersectionObserver, ResizeObserver, etc. Créalas con stubs:

describe("ComponenteConIntersectionObserver.vue", () => {
// Antes de TODOS los tests: crea el stub
beforeAll(() => {
vi.stubGlobal(
"IntersectionObserver",
vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
takeRecords: vi.fn(),
})),
);
});
// Después de TODOS los tests: limpia
afterAll(() => vi.unstubAllGlobals());
it("monta sin errores", () => {
const wrapper = mount(ComponenteConIntersectionObserver);
expect(wrapper.exists()).toBe(true);
});
});

Otros casos habituales:

// matchMedia
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
// ResizeObserver
vi.stubGlobal(
"ResizeObserver",
vi.fn(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
})),
);

vi.mock() — mockear módulos enteros

Úsalo cuando un componente importa una librería pesada que no funciona en tests (como PDF.js).

⚠️ IMPORTANTE: vi.mock() se eleva automáticamente al inicio del archivo. Debe estar fuera de describe e it.

// ⚠️ Nivel raíz del archivo (no dentro de describe/it)
vi.mock("../../app/utils/generatePdf", () => ({
generatePdf: vi.fn().mockResolvedValue("pdf-content"),
}));
import { generatePdf } from "../../app/utils/generatePdf";
describe("PdfButton.vue", () => {
it("llama a generatePdf al hacer click", async () => {
const wrapper = mount(PdfButton, { props: { data: {} } });
await wrapper.find("button").trigger("click");
expect(generatePdf).toHaveBeenCalledTimes(1);
});
});

¿Por qué mockear módulos enteros?

Porque la función real probablemente:

  • Usa librerías pesadas (pdfkit, excel, etc.)
  • Tarda mucho tiempo
  • Falla en el entorno de test

Con el mock, usas una versión fake que funciona al instante.

Funciones útiles para Mocking

FunciónDescripciónEjemplo
vi.fn()Crea una función mockconst mockFn = vi.fn()
vi.fn().mockReturnValue(val)Mock que devuelve valor fijovi.fn().mockReturnValue(42)
vi.fn().mockReturnValueOnce(val)Devuelve distinto valor en cada llamadavi.fn().mockReturnValueOnce('a').mockReturnValueOnce('b')
vi.fn().mockResolvedValue(val)Mock que devuelve Promise resueltavi.fn().mockResolvedValue(data)
vi.spyOn(obj, 'method')Espía función sin reemplazarlavi.spyOn(console, 'log')
vi.mock('path/to/module')Mockea un módulo enterovi.mock('../../utils/api')
vi.stubGlobal('GlobalAPI', vi.fn())Crea APIs globales fakesvi.stubGlobal('IntersectionObserver', ...)
vi.restoreAllMocks()Restaura mocks después del testafterEach(() => vi.restoreAllMocks())
vi.clearAllMocks()Limpia el historial de mocksafterEach(() => vi.clearAllMocks())
expect(fn).toHaveBeenCalled()Verifica que se ejecutóexpect(mockFn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledWith(args)Verifica argumentosexpect(mockFn).toHaveBeenCalledWith('val')
expect(fn).toHaveBeenCalledTimes(n)Verifica cuántas vecesexpect(mockFn).toHaveBeenCalledTimes(3)

Paso 9: Filtrado de tests

Mientras desarrollas, puedes ejecutar solo ciertos tests.

Saltar tests

// Saltar solo este test
it.skip('prueba que aún no funciona', () => { ... })
// Saltar todo el describe
describe.skip('MainCard hover (pendiente de refactor)', () => { ... })

Ejecutar solo un test

// Solo este test
it.only('el aside aparece al hacer mouseenter', async () => { ... })
// Solo este describe
describe.only('Interacciones', () => { ... })

Tests pendientes (todo)

describe("useAutoplay", () => {
it.todo("pausa al pasar el ratón");
it.todo("reanuda al quitar el ratón");
it.todo("para al desmontar");
});

Desde la CLI

Terminal window
# Solo archivos con "MainCard" en el nombre
pnpm test MainCard
# Solo tests cuyo nombre contiene "emite"
pnpm test -- -t "emite buttonClicked"
# Archivo Y nombre de test
pnpm test MainCard -- -t "mouseenter"

Paso 10: Monitorear el progreso con Coverage

Conforme escribas tests, necesitas saber qué porcentaje del código está cubierto. El coverage report te muestra exactamente qué líneas, funciones y ramas de código NO han sido testeadas.

Ejecutar coverage

Terminal window
# Generar reporte de cobertura (texto + HTML)
pnpm test -- --coverage
# Solo en la terminal (más rápido)
pnpm test -- --coverage --coverage.reporter=text
# Generar HTML interactivo
pnpm test -- --coverage --coverage.reporter=html
# Abre coverage/index.html en el navegador

Leer el reporte

El output muestra una tabla como esta:

File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------------------------|---------|----------|---------|---------|-------------------
MainCard.vue | 100 | 92.3 | 100 | 100 | 55
PlanCard.vue | 100 | 66.66 | 100 | 100 | 18
BaseLoader.vue | 0 | 100 | 100 | 0 | 13-29
Navbar.vue | 0 | 100 | 100 | 0 | 3-135

Explicación de las 5 columnas:

  1. % Stmts (Statements) — % de líneas de código ejecutadas

    • 100% → Todas las líneas pasaron por un test ✅
    • 0% → Este archivo no tiene tests ❌
  2. % Branch — % de caminos condicionales (if/else) cubiertos

    • 100% → Se testearon todas las ramas (if verdadero y falso) ✅
    • 92.3% → Falta cubrir una rama (ver “Uncovered Line #s”)
  3. % Funcs — % de funciones ejecutadas

    • 100% → Se llamó a todas las funciones en los tests ✅
    • 54.54% → Faltan funciones sin testear
  4. % Lines — % de líneas específicas ejecutadas

    • Similar a Stmts, pero más granular
  5. Uncovered Line #s — Números de líneas que NO fueron testeadas

    • 55 → La línea 55 de MainCard.vue nunca se ejecutó en tests
    • 18 → La línea 18 de PlanCard.vue nunca se ejecutó
    • 3-135 → Las líneas 3 a 135 de Navbar.vue nunca se ejecutaron

Interpretar el coverage

Objetivo: Llegar al 80-90% de coverage en componentes críticos.

✅ BUENO (>80%):
MainCard.vue → 100% Stmts, 92.3% Branch
AppButton.vue → 100% Stmts, 100% Branch
TheTitle.vue → 100% Stmts, 100% Branch
⚠️ INCOMPLETO (20-80%):
PlanCard.vue → 100% Stmts, 66.66% Branch (falta cubrir línea 18)
ContentFilters.vue → 17.1% Stmts (muchas líneas sin testear)
❌ CRÍTICO (0%):
BaseLoader.vue → 0% Stmts (sin tests)
Navbar.vue → 0% Stmts (sin tests)
SearchBar.vue → 0% Stmts (sin tests)

Usar coverage como guía de trabajo

Paso 1: Identifica gaps

Terminal window
pnpm test -- --coverage
# Mira qué componentes tienen 0% Stmts

Paso 2: Prioriza por criticidad

Prioridad Alta:
- Componentes usados en muchos lugares (Navbar, AppButton, etc.)
- Lógica compleja (MainCard, PlanCard, etc.)
Prioridad Baja:
- Componentes decorativos (Separator, ScrollToTopArrow, etc.)
- Componentes con solo slots (TheTitle, etc.)

Paso 3: Escribe tests para cubrir las líneas

Si ves MainCard.vue | 100 | 92.3 | 100 | 100 | 55, significa:

  • La línea 55 tiene una rama que nunca se ejecutó
  • Abre MainCard.vue, ve a la línea 55
  • Escribe un test que actione esa rama específica
// Ejemplo: línea 55 es una rama else que nunca se ejecutó
if (condition) {
// ... esto se ejecutó
} else {
// línea 55 ← NUNCA se ejecutó
}
// Solución: escribe un test que haga condition = false
it("muestra el estado alternativo cuando condition es false", () => {
const wrapper = mount(MainCard, { props: { condition: false } });
expect(wrapper.find(".alternative-state").exists()).toBe(true);
});

Paso 4: Re-ejecuta coverage

Terminal window
pnpm test -- --coverage
# Verifica que la línea ya no aparece en "Uncovered Line #s"

HTML report (visual)

Para ver un reporte más bonito y navegar por archivos:

Terminal window
pnpm test -- --coverage --coverage.reporter=html
# Abre coverage/index.html

Aquí ves:

  • Una página principal con resumen por carpeta
  • Clickea en cualquier archivo para ver las líneas testeadas (verdes) y no testeadas (rojas)
  • Porcentajes interactivos

Reglas prácticas

Cubre siempre:

  • Eventos y emits (click, mouseenter, change)
  • Lógica condicional (v-if, if/else)
  • Casos límite (valores vacíos, null, arrays vacíos)
  • Funciones del composable usadas en el componente

No desperdicies tiempo en:

  • Estilos CSS (no se testean)
  • Componentes 100% decorativos sin lógica
  • Librerías externas (se mockean, ya tienen sus propios tests)
  • Código “obvio” que nunca fallaría (raramente)

Objetivo mínimo por capa

Composables: 70-80% (lógica pura, es fácil testear)
Átomos: 80-90% (simples, se testean rápido)
Moléculas: 70-80% (más complejas, requieren más tests)
Organismos: 60-70% (más integración, menos aislamiento)

No obsesionarse: Coverage es una métrica, no una garantía. Un componente con 100% coverage puede seguir teniendo bugs. Lo importante es testear comportamiento, no líneas.


Paso 11: Limitaciones de happy-dom y reglas finales

Happy-dom es rápido pero incompleto. Conoce sus limitaciones.

CSS que no funciona

// ❌ happy-dom NO parsea rgba en linear-gradient
element.style.backgroundImage = `linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)), url('...')`;
// ✅ Usa transparent en su lugar
element.style.backgroundImage = `linear-gradient(transparent, transparent), url('...')`;

APIs ausentes y soluciones

APISolución
IntersectionObservervi.stubGlobal('IntersectionObserver', vi.fn(...))
ResizeObservervi.stubGlobal('ResizeObserver', vi.fn(...))
matchMediavi.stubGlobal('matchMedia', vi.fn(...))
Imports de .cssAñadir a server.deps.external en vitest.config

Lo que NUNCA se testea

// ❌ NO testear propiedades CSS
element.style.color;
element.style.backgroundImage;
element.style.width;
// ❌ NO testear clases CSS
wrapper.find("div").classes("text-red-500");
getAttribute("class");
// ❌ NO testear HTML exacto
expect(wrapper.html()).toBe("<div>...</div>");
// ❌ NO testear librerías externas (se mockean)
// Swiper, PDF.js, Multiselect, etc.

Resumen por capa

CapaFunción de mountQué testear
ComposableSin montarEstado y lógica pura
ÁtomomountProps → render + interacción → emit
MoléculamountFlujo completo de interacción
OrganismoshallowMountIntegración del flujo
DirectivaDOM directoEfecto sobre el DOM y limpieza