Cambiar tema

Pruebas unitarias con Vitest: flujo TDD y configuración de cobertura

Easton editorial illustration: failed red test card, passing green test card, central refactor wrench, coverage gate

Mirando la terminal: Test Suites: 1 failed, 47 passed. Cambias una línea de código, esperas 28 segundos a que terminen las pruebas. Cambias otra línea, otros 28 segundos.

Así fue, más o menos, mi migración de Jest a Vitest.

El proyecto tenía cerca de 500 casos de prueba; cada npm test daba tiempo de leer dos páginas de Hacker News. Tras cambiar a Vitest, las mismas pruebas terminaban en poco más de 3 segundos.

Hoy quiero hablar de dos cosas: cómo conseguir esa experiencia de pruebas rápidas con Vitest, y cómo usar TDD (desarrollo guiado por pruebas) para que escribir tests deje de ser tan doloroso. Con una función completa de formateo de precios recorreremos el ciclo Red-Green-Refactor, y luego veremos configuración de cobertura, trucos de Mock y Vitest UI, esa herramienta de depuración tan útil.

Por qué Vitest + TDD

Empecemos con un dato.

50.000 pruebas
Vitest 3 s vs Jest 28-34 s

SitePoint hizo una comparativa en 2026: 50.000 casos de prueba. Vitest terminó en 3 segundos; Jest, entre 28 y 34. No es una pequeña diferencia: es un orden de magnitud.

La velocidad es solo una razón. Si alguna vez usaste Jest con módulos ESM, probablemente caíste en la trampa: instalar Babel, configurar transformers y rezar para que los «strings mágicos» de jest.config.js funcionen. Vitest es distinto: soporta ESM de forma nativa, sin configuración de transpilación. Tu código se prueba tal como lo escribes. Simple.

Otro punto que encaja bien con usuarios de Vite: Vitest reutiliza directamente la configuración de Vite. Los alias, variables de entorno y plugins que definiste en vite.config.ts se heredan en el entorno de pruebas. No hace falta duplicar un moduleNameMapper como en Jest. La primera vez que lo descubrí me quedé unos segundos pensando: ¿así de fácil puede ser configurar pruebas?

Hablando de método: TDD lo han oído muchos, pero pocos lo mantienen. Su núcleo es el ciclo Red-Green-Refactor: escribes primero una prueba que falle (rojo), luego el código mínimo que la pasa (verde) y al final refactorizas (refactor). Suena contraintuitivo, ¿no? ¿Pruebas antes que código?

Pero hay una ventaja: cada línea que escribes existe para que las pruebas pasen. Sin lógica de más, sin código «por si acaso». Y al ir las pruebas primero, te obligas a pensar qué debe hacer la función, qué devuelve y dónde están los límites. Esa restricción aclara el diseño.

El modo watch de Vitest hace el ciclo muy fluido. Guardas el archivo, las pruebas corren al instante y el resultado aparece en la terminal. Sin cambiar de ventana, sin ejecutar comandos a mano. Como un copiloto que te avisa: «Oye, aquí rompiste algo» o «Listo, todo en verde». Ese feedback inmediato te mete en flow sin darte cuenta.

TDD en la práctica: desarrollar una función desde cero

Palabras sin acción no sirven. Vamos a desarrollar con TDD una función formatPrice() que formatea números como moneda. Por ejemplo, convertir 1234.5 en ¥1,234.50.

Fase Red: escribe primero una prueba que falle

Abre tu proyecto y crea formatPrice.test.ts:

// formatPrice.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './formatPrice'

describe('formatPrice', () => {
  it('debe formatear números como moneda china', () => {
    expect(formatPrice(1234.5)).toBe('¥1,234.50')
  })
})

Ejecuta npx vitest y la terminal te mostrará un error rojo grande: Cannot find module './formatPrice'. Normal: la función aún no existe.

Eso es la fase Red: la prueba falla porque definiste un requisito aún no implementado. Muchos encuentran raro escribir pruebas primero, pero si escribes código antes, ¿cómo sabes que la prueba realmente verifica lo que quieres?

Fase Green: el mínimo código para pasar la prueba

Crea formatPrice.ts con lo justo para que pase:

// formatPrice.ts
export function formatPrice(value: number): string {
  return '¥1,234.50'  // hardcode temporal
}

Vuelve a ejecutar las pruebas. ¡Verde!

Espera, dirás: «¿Eso no es hacer trampa?» En realidad no. TDD pide escribir el código «justo» para pasar, ni más ni menos. Hardcode o lógica mínima: si la prueba pasa, tienes una base verificable. Luego añades pruebas y evolucionas el código paso a paso.

Añade otro caso:

it('debe manejar distintos valores correctamente', () => {
  expect(formatPrice(0)).toBe('¥0.00')
  expect(formatPrice(99.99)).toBe('¥99.99')
})

La prueba vuelve a rojo. Ya no puedes hardcodear; hace falta lógica real:

export function formatPrice(value: number): string {
  return `¥${value.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
}

Ejecuta las pruebas: todo verde. La expresión regular es fea, pero funciona por ahora.

Fase Refactor: optimiza la estructura

Las pruebas pasan, pero el código puede ser más claro. Refactoriza con confianza: las pruebas te avisan si rompes algo.

// Versión refactorizada
export function formatPrice(value: number): string {
  // Intl.NumberFormat es más robusto
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: 'CNY',
    minimumFractionDigits: 2,
  }).format(value)
}

Ejecuta las pruebas: siguen en verde. Refactor completado.

Ahí tienes un ciclo Red-Green-Refactor completo. Empiezas con el fallo, escribes lo mínimo y optimizas la estructura. Las pruebas te respaldan en cada paso. Y como cada paso es pequeño, la carga mental es baja: no tienes que resolver todos los casos límite de golpe; las pruebas te lo recuerdan.

En proyectos reales suelo correr este ciclo en modo watch. Guardar → prueba automática → ver resultado → cambiar código → guardar → repetir. Todo en segundos, sin salir del editor. Esa sensación de «cambio algo y al instante sé si está bien» es muy satisfactoria.

Configuración de cobertura e integración con CI

Escribiste pruebas; conviene saber cuánto cubren. Para eso sirve el informe de cobertura.

Configuración básica

Añade la cobertura en vitest.config.ts:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',      // o 'istanbul'; v8 suele ser más rápido
      reporter: ['text', 'html', 'json-summary'],
      reportsDirectory: './coverage',
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.test.ts', 'src/types/**'],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
})

Hay dos proveedores: v8 e istanbul. v8 usa la API nativa de cobertura del motor V8 y es más rápido; istanbul es la opción clásica, con mejor compatibilidad. En un proyecto puro Vite/Node, v8 basta.

reporter define el formato: text en terminal, html para informe visual, json-summary para que lo lean herramientas de CI.

Umbrales

thresholds tiene cuatro dimensiones:

  • statements: cobertura de sentencias, cuántas líneas de código se ejecutaron
  • branches: cobertura de ramas, si probaste cada rama de if/else
  • functions: cobertura de funciones, cuántas se invocaron
  • lines: cobertura de líneas, similar a statements pero con cálculo ligeramente distinto

Suelo fijar umbrales entre 75% y 85%. Demasiado bajo no aporta; demasiado alto agota al equipo — hay código (comprobaciones límite, manejo de errores) difícil de llevar al 100%.

Integración con GitHub Actions

La cobertura brilla en CI, bloqueando PRs que no cumplan el umbral. En .github/workflows/test.yml:

- name: Run tests with coverage
  run: npm run test -- --coverage

- name: Check coverage threshold
  run: |
    COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
      echo "Coverage $COVERAGE% is below threshold 80%"
      exit 1
    fi

Si la cobertura baja del 80%, el PR no se puede fusionar. El equipo debe asegurar pruebas suficientes antes de subir cambios.

Leer el informe

Tras npx vitest --coverage, la terminal muestra algo así:

 % Stmts   % Branch   % Funcs   % Lines   Uncovered Line
----------|----------|----------|----------|----------------
  82.45    |   76.32   |   85.71   |   82.45   | 23-25, 67

Uncovered Line lista las líneas sin cubrir. Abre coverage/index.html para el detalle visual: verde = probado, rojo = sin probar.

Al principio perseguía la cobertura con obsesión, queriendo el 100%. Luego vi que no compensa. El 80% suele cubrir lógica central y ramas principales. El 20% restante suele ser casos límite extremos donde forzar pruebas no vale la pena.

Los tres mosqueteros del Mock: vi.fn, vi.spy, vi.mock

Lo más pesado en pruebas suele ser manejar dependencias externas: peticiones API, temporizadores, librerías de terceros. Vitest ofrece tres formas de Mock, cada una con su uso.

vi.fn(): crear funciones falsas

Cuando necesitas una función «falsa», sin importar la original, y solo te importa cómo se invoca, usa vi.fn().

test('el callback debe llamarse una vez', () => {
  const callback = vi.fn()

  callMeMaybe(callback)

  expect(callback).toHaveBeenCalledTimes(1)
  expect(callback).toHaveBeenCalledWith('hello')
})

callback es una función nueva que registra cuántas veces se llamó, con qué argumentos y qué devolvió. Puedes usar mockReturnValue para el retorno o mockImplementation para el comportamiento.

vi.spy(): observar funciones reales

A veces no quieres reemplazar la función, solo ver si se llamó y con qué argumentos. Ahí entra vi.spyOn.

test('debe llamar a console.log', () => {
  const logSpy = vi.spyOn(console, 'log')

  greet('World')

  expect(logSpy).toHaveBeenCalledWith('Hello, World!')
  logSpy.mockRestore()  // no olvides restaurar
})

El spy conserva el comportamiento original y solo observa. Al terminar, mockRestore(); si no, afecta otras pruebas.

vi.mock(): reemplazar un módulo entero

Para simular respuestas de API o sustituir librerías de terceros, usa vi.mock. Reemplaza el módulo completo.

// Simular axios
vi.mock('axios', () => ({
  default: {
    get: vi.fn(() => Promise.resolve({ data: { name: 'test' } }))
  }
}))

test('getUser debe devolver datos del usuario', async () => {
  const user = await getUser(1)

  expect(user.name).toBe('test')
  expect(axios.get).toHaveBeenCalledWith('/users/1')
})

Ojo con vi.mock: se eleva al inicio del archivo, sin importar dónde lo escribas. El contenido del mock no puede depender de otras variables.

¿Cuál elegir?

En resumen:

  • ¿Solo necesitas una función falsa? vi.fn()
  • ¿Quieres observar una función real? vi.spy()
  • ¿Hay que reemplazar un módulo entero? vi.mock()

Yo también los confundía al principio. Una regla mnemotécnica: fn «inventa», spy «espía», mock «reemplaza todo». Funciona bastante bien.

La limpieza importa

Que las pruebas no se interfieran es base de la fiabilidad. Tras cada test, limpia los mocks:

afterEach(() => {
  vi.restoreAllMocks()
})

O actívalo globalmente en la config:

test: {
  restoreMocks: true
}

Vitest UI y trucos de depuración

La terminal basta para ver resultados, pero si quieres algo más visual, prueba Vitest UI.

Interfaz visual

npx vitest --ui

Se abre el navegador: lista de pruebas a la izquierda, detalle a la derecha. Clic en cualquier prueba para ver salida completa, stack trace y tiempo de ejecución. Hay un botón de cobertura que abre el informe HTML.

Ideal para depurar. Si una prueba falla, no hace falta rebuscar en la terminal: ves el error en la UI, el código al lado, guardas y la interfaz se actualiza sola.

Modo watch: solo pruebas afectadas

En el día a día uso npx vitest en modo watch. Las pruebas incrementales son listas: si cambias utils/formatPrice.ts, solo corre tests relacionados con ese archivo, no todo el suite.

Con muchas pruebas, la diferencia es enorme. En un proyecto con más de 800 tests, el suite completo tarda 4 segundos; el incremental suele ir en cientos de milisegundos.

Trucos de depuración

¿Qué hacer cuando una prueba falla?

Ejecutar una sola prueba: añade .only tras it

it.only('esta prueba falla, la corro sola', () => {
  // ...
})

Saltar una prueba: usa .skip

it.skip('la salto por ahora', () => {
  // ...
})

Actualizar snapshots: cambiaste la estructura del componente y falló el snapshot

npx vitest -u  # -u es abreviatura de --update

Depurar con console.log: sí, lo de siempre. Vitest muestra la salida de console en el resultado de la prueba.

Errores frecuentes

ErrorCausaSolución
Cannot find moduleAlias de ruta mal configuradoRevisa alias en vitest.config
vi.mock is not a functionImport incorrectoUsa import { vi } from 'vitest'
Zona horaria incorrectaPor defecto es UTCEn setup configura process.env.TZ = 'Asia/Shanghai'

Caí en todos estos. Especialmente el de zona horaria: en CI todas las pruebas de fecha fallaban en local hasta que descubrí el problema.

Conclusión

En resumen: Vitest + TDD puede hacer que escribir pruebas deje de doler tanto.

En velocidad, pasar de decenas de segundos con Jest a pocos segundos no es solo un número: es experiencia. No alternas entre cambiar código y esperar; no sufres el «cambio una línea, espera media eternidad». ESM nativo y reutilizar la config de Vite son ahorros reales de tiempo.

El ciclo Red-Green-Refactor de TDD suena contraintuitivo, pero tras probarlo unas veces ves la ventaja: pasos pequeños, verificación constante, carga mental baja. No diseñas la solución perfecta de golpe; las pruebas detectan problemas y guían la iteración.

Cobertura y Mock son herramientas que refuerzan pruebas más sólidas. Lo importante es el hábito de escribir tests — no por cifras, sino por confianza en el código.

Si tu proyecto ya usa Vite, migrar de Jest a Vitest cuesta poco: npm add -D vitest, adaptar la sintaxis (casi idéntica) y listo. Si dudas, prueba en un módulo pequeño y siente el feedback instantáneo del modo watch.

Arranca Vitest con tu primera prueba y prueba el ciclo TDD. Esa sensación de «cambio algo y al instante sé si está bien» engancha. Quizá termines como yo, disfrutando escribir pruebas.

Flujo práctico de TDD con Vitest

Desarrolla una función con TDD desde cero y configura el informe de cobertura

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Instalar Vitest

    Añade Vitest a tu proyecto Vite:

    npm add -D vitest

    Sin configuración extra: Vitest reutiliza automáticamente la config de Vite
  2. 2

    Step 2: Fase Red: escribe una prueba que falle

    Crea el archivo de prueba con un test que debe fallar:

    import { describe, it, expect } from 'vitest'
    import { formatPrice } from './formatPrice'

    describe('formatPrice', () => {
    it('debe formatear moneda', () => {
    expect(formatPrice(1234.5)).toBe('¥1,234.50')
    })
    })

    Ejecuta npx vitest y confirma que la prueba falla (rojo)
  3. 3

    Step 3: Fase Green: escribe el código mínimo

    Crea el archivo de implementación con lo justo para pasar la prueba:

    export function formatPrice(value: number): string {
    return '¥1,234.50' // hardcode temporal
    }

    Ejecuta las pruebas y confirma que pasan (verde)
  4. 4

    Step 4: Fase Refactor: optimiza el código

    Refactoriza con Intl.NumberFormat:

    export function formatPrice(value: number): string {
    return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: 'CNY',
    }).format(value)
    }

    Confirma que las pruebas siguen pasando
  5. 5

    Step 5: Configurar cobertura

    Añade en vitest.config.ts:

    test: {
    coverage: {
    provider: 'v8',
    thresholds: { statements: 80, branches: 75 }
    }
    }

    Ejecuta npx vitest --coverage para ver el informe

FAQ

¿Cuál es la principal diferencia entre Vitest y Jest?
Vitest está diseñado para Vite, soporta ESM de forma nativa y es 5-10 veces más rápido que Jest. Reutiliza automáticamente la configuración de Vite (alias, variables de entorno), sin necesidad de configurar moduleNameMapper como en Jest.
¿Cómo funciona el ciclo Red-Green-Refactor de TDD?
Tres pasos en bucle:

• Red: escribe primero una prueba que falle y define el requisito
• Green: escribe el código mínimo para que pase, incluso con hardcode
• Refactor: optimiza la estructura bajo la protección de las pruebas

Cada paso es pequeño, la carga mental es baja y las pruebas te cubren en todo momento.
¿Qué umbral de cobertura conviene establecer?
Se recomienda 75%-85%. Demasiado bajo no aporta; demasiado alto agota al equipo. El 80% suele cubrir la lógica central; el 20% restante suele ser casos límite extremos donde forzar pruebas no compensa.
¿Cómo elegir entre vi.fn, vi.spy y vi.mock?
Según el escenario:

• vi.fn(): crea una función falsa nueva y registra llamadas y argumentos
• vi.spy(): observa una función existente, conserva su comportamiento; al terminar usa mockRestore()
• vi.mock(): reemplaza un módulo entero, útil para simular APIs o librerías de terceros

Regla mnemotécnica: fn inventa, spy espía, mock reemplaza todo.
¿Qué ventajas tiene el modo watch de Vitest?
Las pruebas incrementales solo ejecutan archivos afectados: 800 pruebas completas en 4 segundos, incrementales en cientos de milisegundos. Al guardar el archivo, las pruebas corren al instante sin cambiar de ventana, ideal para entrar en flow.
¿Cómo resolver problemas de zona horaria en las pruebas?
Vitest usa UTC por defecto. En setupFiles de vitest.config.ts configura process.env.TZ = 'Asia/Shanghai', o hazlo manualmente al inicio del archivo de prueba.

11 min de lectura · Publicado el: 29 abr 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog