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.
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
pnpm test # Ejecutar todos los tests una sola vezpnpm test:watch # Modo watch: re-ejecuta al guardarpnpm test:types # Verificar tipos con vue-tsc
# Filtrar testspnpm test MultiSelect # Solo archivos con "MultiSelect" en el nombrepnpm 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.tsEstructura 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.tsPlantilla base de un test
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/shallowMountexplí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()(porquenoUncheckedIndexedAccess: 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
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ón | Descripción | Ejemplo |
|---|---|---|
vi.useFakeTimers() | Activa timers simulados para controlar setTimeout/setInterval | beforeEach(() => vi.useFakeTimers()) |
vi.advanceTimersByTime(ms) | Avanza el tiempo simulado instantáneamente | vi.advanceTimersByTime(1000) para 1 segundo |
vi.useRealTimers() | Restaura los timers reales después de los tests | afterEach(() => vi.useRealTimers()) |
vi.spyOn(obj, 'method') | Espía una función sin reemplazarla | vi.spyOn(composable, 'useCard') |
expect(fn).toHaveBeenCalled() | Verifica que la función se ejecutó | expect(spy).toHaveBeenCalled() |
expect(fn).toHaveBeenCalledWith(args) | Verifica argumentos específicos | expect(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 test | beforeEach(() => 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á undefinedconst props = withDefaults(defineProps<Props>(), { showIcon: true,});
// ✅ CORRECTO - todos los defaults definidosconst props = withDefaults(defineProps<Props>(), { showIcon: true, isLoading: false, // necesario, aunque sea el "default natural"});Funciones útiles para tests de Átomos
| Función | Descripción | Ejemplo |
|---|---|---|
mount(Component, options) | Monta un componente completo para testear | mount(MyButton, { props: { label: 'Click' } }) |
wrapper.find(selector) | Busca un elemento en el DOM | wrapper.find('button') |
wrapper.findAll(selector) | Busca múltiples elementos | wrapper.findAll('.item') |
wrapper.findComponent(Component) | Busca un componente Vue hijo | wrapper.findComponent(Icon) |
wrapper.text() | Obtiene el texto renderizado | expect(wrapper.text()).toContain('Hola') |
wrapper.html() | Obtiene el HTML renderizado | expect(wrapper.html()).toContain('<span>') |
wrapper.emitted('eventName') | Obtiene los eventos emitidos | expect(wrapper.emitted('click')).toBeTruthy() |
wrapper.props('propName') | Obtiene una prop | wrapper.props('disabled') |
wrapper.setProps(props) | Cambia props reactivamente | await wrapper.setProps({ title: 'Nuevo' }) |
wrapper.trigger('event') | Dispara un evento en el elemento | await wrapper.find('button').trigger('click') |
wrapper.exists() | Verifica si el elemento existe | expect(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 testconst 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ón | Descripción | Ejemplo |
|---|---|---|
global.stubs | Reemplaza componentes hijo complejos con stubs | global: { stubs: { Icon: true, Swiper: true } } |
global.provide | Proporciona valores inyectados (inject) | global: { provide: { myFunc: vi.fn() } } |
wrapper.emitted('event') | Obtiene todos los eventos emitidos | wrapper.emitted('buttonClicked') |
wrapper.emitted('event')?.[0] | Obtiene los argumentos del 1er evento | wrapper.emitted('click')?.[0] |
vi.fn() | Crea una función mock que se puede espiar | const mockFn = vi.fn() |
wrapper.find(selector).exists() | Verifica que un elemento existe | expect(wrapper.find('aside').exists()).toBe(true) |
nextTick() | Espera a que Vue actualice el DOM | await nextTick(); expect(wrapper.find('div').exists()).toBe(true) |
wrapper.vm.$emit('event', args) | Emite un evento desde el wrapper | await 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 archivowrapper.findComponent(AEMoleculesUiAppButtons).exists();
// ⚠️ FRÁGIL - busca por nombre interno (puede cambiar)wrapper.findComponent({ name: "AppButtons" }).exists();Funciones útiles para tests de Organismos
| Función | Descripción | Ejemplo |
|---|---|---|
shallowMount(Component) | Monta componente con hijos como stubs vacíos | shallowMount(Footer, { props: data }) |
wrapper.findAllComponents(Component) | Encuentra todos los componentes hijos | wrapper.findAllComponents(LinkList) |
wrapper.findAllComponents(Component).length | Cuenta cuántos componentes hijo hay | expect(lists).toHaveLength(3) |
wrapper.findComponent(Component).exists() | Verifica si un componente hijo existe | expect(wrapper.findComponent(Icon).exists()).toBe(true) |
wrapper.findComponent(Component).props() | Obtiene las props de un componente hijo | wrapper.findComponent(Icon).props('size') |
vi.clearAllMocks() | Limpia todos los mocks después de tests | afterEach(() => vi.clearAllMocks()) |
beforeEach() / afterEach() | Setup/cleanup | afterEach(() => 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ón | Descripción | Ejemplo |
|---|---|---|
document.createElement(tag) | Crea un elemento DOM | const el = document.createElement('div') |
document.body.appendChild(el) | Añade elemento al DOM | document.body.appendChild(el) |
document.body.removeChild(el) | Elimina elemento del DOM | document.body.removeChild(el) |
el.dispatchEvent(event) | Dispara un evento en el elemento | el.dispatchEvent(new MouseEvent('click')) |
new MouseEvent('click', options) | Crea un evento de ratón | new MouseEvent('click', { bubbles: true }) |
new KeyboardEvent('keydown', options) | Crea un evento de teclado | new KeyboardEvent('keydown', { key: 'Enter' }) |
vi.fn() | Crea una función mock | const 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 fijoconst mockFn = vi.fn().mockReturnValue(42);expect(mockFn()).toBe(42);
// Distinto valor en cada llamadaconst 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 fakevi.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:
// matchMediavi.stubGlobal( "matchMedia", vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), })),);
// ResizeObservervi.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ón | Descripción | Ejemplo |
|---|---|---|
vi.fn() | Crea una función mock | const mockFn = vi.fn() |
vi.fn().mockReturnValue(val) | Mock que devuelve valor fijo | vi.fn().mockReturnValue(42) |
vi.fn().mockReturnValueOnce(val) | Devuelve distinto valor en cada llamada | vi.fn().mockReturnValueOnce('a').mockReturnValueOnce('b') |
vi.fn().mockResolvedValue(val) | Mock que devuelve Promise resuelta | vi.fn().mockResolvedValue(data) |
vi.spyOn(obj, 'method') | Espía función sin reemplazarla | vi.spyOn(console, 'log') |
vi.mock('path/to/module') | Mockea un módulo entero | vi.mock('../../utils/api') |
vi.stubGlobal('GlobalAPI', vi.fn()) | Crea APIs globales fakes | vi.stubGlobal('IntersectionObserver', ...) |
vi.restoreAllMocks() | Restaura mocks después del test | afterEach(() => vi.restoreAllMocks()) |
vi.clearAllMocks() | Limpia el historial de mocks | afterEach(() => vi.clearAllMocks()) |
expect(fn).toHaveBeenCalled() | Verifica que se ejecutó | expect(mockFn).toHaveBeenCalled() |
expect(fn).toHaveBeenCalledWith(args) | Verifica argumentos | expect(mockFn).toHaveBeenCalledWith('val') |
expect(fn).toHaveBeenCalledTimes(n) | Verifica cuántas veces | expect(mockFn).toHaveBeenCalledTimes(3) |
Paso 9: Filtrado de tests
Mientras desarrollas, puedes ejecutar solo ciertos tests.
Saltar tests
// Saltar solo este testit.skip('prueba que aún no funciona', () => { ... })
// Saltar todo el describedescribe.skip('MainCard hover (pendiente de refactor)', () => { ... })Ejecutar solo un test
// Solo este testit.only('el aside aparece al hacer mouseenter', async () => { ... })
// Solo este describedescribe.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
# Solo archivos con "MainCard" en el nombrepnpm test MainCard
# Solo tests cuyo nombre contiene "emite"pnpm test -- -t "emite buttonClicked"
# Archivo Y nombre de testpnpm 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
# 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 interactivopnpm test -- --coverage --coverage.reporter=html# Abre coverage/index.html en el navegadorLeer el reporte
El output muestra una tabla como esta:
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s-----------------------------------------|---------|----------|---------|---------|-------------------MainCard.vue | 100 | 92.3 | 100 | 100 | 55PlanCard.vue | 100 | 66.66 | 100 | 100 | 18BaseLoader.vue | 0 | 100 | 100 | 0 | 13-29Navbar.vue | 0 | 100 | 100 | 0 | 3-135Explicación de las 5 columnas:
-
% Stmts (Statements) — % de líneas de código ejecutadas
100%→ Todas las líneas pasaron por un test ✅0%→ Este archivo no tiene tests ❌
-
% 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”)
-
% Funcs — % de funciones ejecutadas
100%→ Se llamó a todas las funciones en los tests ✅54.54%→ Faltan funciones sin testear
-
% Lines — % de líneas específicas ejecutadas
- Similar a Stmts, pero más granular
-
Uncovered Line #s — Números de líneas que NO fueron testeadas
55→ La línea 55 deMainCard.vuenunca se ejecutó en tests18→ La línea 18 dePlanCard.vuenunca se ejecutó3-135→ Las líneas 3 a 135 deNavbar.vuenunca 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
pnpm test -- --coverage# Mira qué componentes tienen 0% StmtsPaso 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 = falseit("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
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:
pnpm test -- --coverage --coverage.reporter=html# Abre coverage/index.htmlAquí 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-gradientelement.style.backgroundImage = `linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)), url('...')`;
// ✅ Usa transparent en su lugarelement.style.backgroundImage = `linear-gradient(transparent, transparent), url('...')`;APIs ausentes y soluciones
| API | Solución |
|---|---|
IntersectionObserver | vi.stubGlobal('IntersectionObserver', vi.fn(...)) |
ResizeObserver | vi.stubGlobal('ResizeObserver', vi.fn(...)) |
matchMedia | vi.stubGlobal('matchMedia', vi.fn(...)) |
Imports de .css | Añadir a server.deps.external en vitest.config |
Lo que NUNCA se testea
// ❌ NO testear propiedades CSSelement.style.color;element.style.backgroundImage;element.style.width;
// ❌ NO testear clases CSSwrapper.find("div").classes("text-red-500");getAttribute("class");
// ❌ NO testear HTML exactoexpect(wrapper.html()).toBe("<div>...</div>");
// ❌ NO testear librerías externas (se mockean)// Swiper, PDF.js, Multiselect, etc.Resumen por capa
| Capa | Función de mount | Qué testear |
|---|---|---|
| Composable | Sin montar | Estado y lógica pura |
| Átomo | mount | Props → render + interacción → emit |
| Molécula | mount | Flujo completo de interacción |
| Organismo | shallowMount | Integración del flujo |
| Directiva | DOM directo | Efecto sobre el DOM y limpieza |