JavaScript

JavaScript

Tutorial JavaScript: Introducción a pruebas en JS

Vitest y Jest: descubre los beneficios de las pruebas en desarrollo software y cómo implementarlas efectivamente con frameworks de testing.

Aprende JavaScript y certifícate

¿Por qué hacer pruebas?: Conceptos básicos

Las pruebas de software son una parte fundamental del desarrollo profesional que a menudo se subestima, especialmente cuando estamos aprendiendo a programar. Muchos desarrolladores comienzan verificando su código manualmente: ejecutan la aplicación, prueban algunas funcionalidades y asumen que todo funciona correctamente. Sin embargo, este enfoque tiene serias limitaciones cuando los proyectos crecen en complejidad.

Beneficios de las pruebas automatizadas

  • 1. Detección temprana de errores: Las pruebas permiten identificar problemas antes de que lleguen a producción, cuando son más costosos de corregir.
// Sin pruebas: descubrimos tarde que esto falla con ciertos inputs
function calculateDiscount(price, percentage) {
  return price * percentage; // Error: no divide entre 100
}

// Con pruebas: detectamos el problema inmediatamente
test('applies 20% discount correctly', () => {
  expect(calculateDiscount(100, 20)).toBe(20);
});
  • 2. Documentación viva: Las pruebas actúan como una forma de documentación que siempre está actualizada y muestra exactamente cómo debe comportarse el código.
// Esta prueba documenta claramente el comportamiento esperado
test('should capitalize first letter of each word', () => {
  expect(capitalizeWords('hello world')).toBe('Hello World');
});
  • 3. Refactorización segura: Permite mejorar el código existente con la confianza de que no estamos rompiendo funcionalidades.
// Podemos refactorizar esta función...
function sumArray(numbers) {
  let total = 0;
  for (let i = 0; i < numbers.length; i++) {
    total += numbers[i];
  }
  return total;
}

// ...a una versión más moderna y concisa usando el método reduce que vimos en programación funcional
function sumArray(numbers) {
  return numbers.reduce((sum, num) => sum + num, 0);
}

// Y nuestras pruebas nos aseguran que sigue funcionando igual
  • 4. Diseño mejorado: Escribir pruebas nos obliga a pensar en la interfaz y comportamiento de nuestro código antes de implementarlo, lo que normalmente resulta en un diseño más limpio y modular.

Conexión con conceptos previos

Las pruebas se complementan perfectamente con los conceptos que hemos visto anteriormente:

  • Funciones puras (Programación Funcional): Las funciones puras que vimos en módulos anteriores son ideales para las pruebas unitarias, ya que:
    • Siempre devuelven el mismo resultado para las mismas entradas
    • No tienen efectos secundarios
    • No dependen de estado externo
// Función pura del módulo de programación funcional
function filterPositive(numbers) {
  return numbers.filter(num => num >= 0);
}

// Es muy fácil de probar
test('filters out negative numbers', () => {
  expect(filterPositive([5, -3, 10, -7, 0])).toEqual([5, 10, 0]);
});
  • Calidad de código (ESLint): Mientras que ESLint nos ayuda a mantener un código limpio y evitar errores sintácticos, las pruebas verifican que nuestro código funciona correctamente a nivel lógico. Son herramientas complementarias para asegurar la calidad.

Tipos de pruebas

Existen diferentes niveles de pruebas, cada uno con propósitos específicos:

Pruebas unitarias: Verifican el funcionamiento correcto de unidades individuales de código (generalmente funciones o métodos) de forma aislada.

Pruebas de integración: Comprueban que diferentes partes del sistema funcionan correctamente juntas.

Pruebas end-to-end (E2E): Simulan el comportamiento de un usuario real interactuando con la aplicación completa.

El costo de no hacer pruebas

No implementar pruebas puede parecer que ahorra tiempo inicialmente, pero a largo plazo genera:

  • Deuda técnica: Cada vez es más difícil modificar el código sin introducir regresiones.
  • Ciclos de desarrollo más lentos: Pasar más tiempo depurando problemas que escribiendo nuevas funcionalidades.
  • Menor confianza: Incertidumbre sobre si los cambios romperán algo en producción.

Enfoque pragmático

No es necesario alcanzar el 100% de cobertura de pruebas. Un enfoque pragmático consiste en:

// Priorizar pruebas para lógica de negocio crítica
function calculateMortgage(principal, rate, years) {
  const monthlyRate = rate / 100 / 12;
  const payments = years * 12;
  return (
    (principal * monthlyRate * Math.pow(1 + monthlyRate, payments)) /
    (Math.pow(1 + monthlyRate, payments) - 1)
  );
}

// Esta función merece buenas pruebas con diferentes escenarios
test('calculates mortgage correctly for standard case', () => {
  expect(calculateMortgage(200000, 3.5, 30)).toBeCloseTo(898.09);
});

Estructura de una prueba básica

Una buena práctica es seguir el patrón "Arrange-Act-Assert" para organizar tus pruebas:

test('should filter out negative numbers', () => {
  // Arrange (preparar): configuramos los datos de prueba
  const numbers = [5, -3, 10, -7, 0];
  
  // Act (actuar): ejecutamos la función que queremos probar
  const result = filterPositive(numbers);
  
  // Assert (verificar): comprobamos que el resultado es el esperado
  expect(result).toEqual([5, 10, 0]);
});

Herramientas principales: Vitest/Jest fundamentales

En el ecosistema de JavaScript, contar con frameworks de pruebas robustos es esencial para implementar una estrategia de testing efectiva. Dos de las herramientas más destacadas son Jest y Vitest, que proporcionan todo lo necesario para escribir y ejecutar pruebas de manera eficiente.

Jest y Vitest: ¿Qué son?

Jest es un framework de pruebas desarrollado por Facebook que se ha convertido en el estándar para muchos proyectos JavaScript.

Vitest es un framework más reciente diseñado específicamente para proyectos que utilizan Vite como bundler, ofreciendo una experiencia de desarrollo más rápida.

Instalación básica

Para comenzar a usar estas herramientas en tu proyecto:

// Instalación de Jest
// npm install --save-dev jest

// Instalación de Vitest
// npm install --save-dev vitest

Nuestro ejemplo integrador: Utility functions

A lo largo de esta lección, trabajaremos con un pequeño módulo de funciones utilitarias que podrían venir de nuestras lecciones previas de programación funcional:

// utils.js
export function capitalize(string) {
  return string.charAt(0).toUpperCase() + string.slice(1);
}

export function calculateTax(amount, taxRate) {
  return amount * taxRate / 100;
}

export function filterItems(items, predicate) {
  return items.filter(predicate);
}

Estructura básica de una prueba

Tanto Jest como Vitest comparten una sintaxis similar para escribir pruebas:

// utils.test.js
import { expect, test } from 'vitest'; // o 'jest'
import { capitalize, calculateTax } from './utils';

test('capitalizes the first letter of a string', () => {
  expect(capitalize('hello')).toBe('Hello');
});

test('calculates tax amount correctly', () => {
  expect(calculateTax(100, 10)).toBe(10);
});

Funciones principales para organizar pruebas

Ambos frameworks proporcionan funciones para ayudarte a estructurar tus pruebas:

import { describe, test, expect } from 'vitest';
import { capitalize, calculateTax, filterItems } from './utils';

describe('String utils', () => {
  test('capitalizes the first letter of a string', () => {
    expect(capitalize('hello')).toBe('Hello');
    expect(capitalize('world')).toBe('World');
  });
  
  test('returns empty string when input is empty', () => {
    expect(capitalize('')).toBe('');
  });
});

describe('Number utils', () => {
  test('calculates tax amount correctly', () => {
    expect(calculateTax(100, 10)).toBe(10);
    expect(calculateTax(200, 5)).toBe(10);
  });
});
  • test() (o it()): Define un caso de prueba individual
  • describe(): Agrupa pruebas relacionadas en bloques lógicos

Funciones para configuración y limpieza

Cuando necesitas preparar datos o configuraciones antes de tus pruebas:

import { describe, test, beforeEach, expect } from 'vitest';
import { filterItems } from './utils';

describe('Array filters', () => {
  let items;
  
  // Se ejecuta antes de cada prueba en este bloque
  beforeEach(() => {
    items = [
      { id: 1, name: 'Item 1', price: 10, inStock: true },
      { id: 2, name: 'Item 2', price: 20, inStock: false },
      { id: 3, name: 'Item 3', price: 30, inStock: true }
    ];
  });
  
  test('filters items by availability', () => {
    const inStockItems = filterItems(items, item => item.inStock);
    expect(inStockItems.length).toBe(2);
    expect(inStockItems[0].id).toBe(1);
    expect(inStockItems[1].id).toBe(3);
  });
  
  test('filters items by price threshold', () => {
    const expensiveItems = filterItems(items, item => item.price > 15);
    expect(expensiveItems.length).toBe(2);
    expect(expensiveItems[0].id).toBe(2);
    expect(expensiveItems[1].id).toBe(3);
  });
});
  • beforeEach(): Ejecuta código antes de cada prueba
  • afterEach(): Ejecuta código después de cada prueba
  • beforeAll() y afterAll(): Ejecutan código una sola vez antes/después de todas las pruebas

Matchers: Verificación de resultados

Los "matchers" son funciones que te permiten verificar valores de diferentes maneras:

test('demonstration of common matchers', () => {
  // Igualdad
  expect(2 + 2).toBe(4);                   // Igualdad estricta (===)
  expect({ name: 'test' }).toEqual({ name: 'test' });  // Igualdad profunda
  
  // Arrays
  expect([1, 2, 3]).toContain(2);          // Contiene elemento
  expect([1, 2, 3]).toHaveLength(3);       // Tiene longitud específica
  
  // Números
  expect(10).toBeGreaterThan(5);           // Mayor que
  expect(5).toBeLessThanOrEqual(5);        // Menor o igual que
  
  // Strings
  expect('hello world').toMatch(/world/);  // Coincide con regex
  expect('hello').toContain('ell');        // Contiene substring
});

Ejecución de pruebas

Para ejecutar tus pruebas, puedes configurar scripts en tu package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

Y luego ejecutarlos con:

npm test      # Modo observador: ejecuta pruebas cuando los archivos cambian
npm run test:run  # Ejecuta las pruebas una vez

Ejemplo:

Crea los siguientes archivos en la carpeta testing-demo para realizar la prueba:

Calculator.js

// Función para sumar dos números
export function sum(a, b) {
  return a + b;
}

// Función para restar dos números
export function subtract(a, b) {
  return a - b;
}

// Función para verificar si un número es par
export function isEven(number) {
  return number % 2 === 0;
}

calculator.test.js

import { expect, test, describe } from 'vitest';
import { sum, subtract, isEven } from './calculator.js';

describe('Calculadora básica', () => {
  test('suma 1 + 2 igual a 3', () => {
    expect(sum(1, 2)).toBe(3);
  });

  test('resta 5 - 3 igual a 2', () => {
    expect(subtract(5, 3)).toBe(2);
  });

  test('verifica si un número es par', () => {
    expect(isEven(4)).toBe(true);
    expect(isEven(7)).toBe(false);
  });
});

Ejecuta npm test

Terminología: pruebas, fixtures, aserciones

Para dominar el testing en JavaScript es importante familiarizarse con algunos términos clave. Estos conceptos te ayudarán a comunicarte efectivamente con otros desarrolladores y a entender mejor la documentación de las herramientas de pruebas.

Conceptos básicos de pruebas

Aserciones (Assertions): Son declaraciones que verifican si un valor cumple con ciertas condiciones. Forman el núcleo de cualquier prueba.

test('capitalize function works correctly', () => {
  // Esto es una aserción
  expect(capitalize('hello')).toBe('Hello');
});

Suite de pruebas: Un grupo de pruebas relacionadas, generalmente implementado con la función describe.

describe('String utility functions', () => {
  test('capitalize works with regular strings', () => {
    // ...
  });
  
  test('capitalize handles empty strings', () => {
    // ...
  });
});

Cobertura de código: Mide qué porcentaje de tu código está siendo probado. Aunque no debes obsesionarte con alcanzar el 100%, es un buen indicador de qué tan completas son tus pruebas.

# Ejecutar pruebas con análisis de cobertura
npm test -- --coverage

Preparación de datos para pruebas

Cuando escribes muchas pruebas, a menudo necesitas crear datos de ejemplo repetidamente. Hay varias formas de hacerlo:

1. Datos de prueba simples

test('filterItems works with predicate', () => {
  const items = [
    { id: 1, price: 10, inStock: true },
    { id: 2, price: 20, inStock: false },
    { id: 3, price: 5, inStock: true }
  ];
  
  const cheap = filterItems(items, item => item.price < 15);
  expect(cheap.length).toBe(2);
  expect(cheap[0].id).toBe(1);
  expect(cheap[1].id).toBe(3);
});

2. Funciones auxiliares

Cuando necesitas crear datos similares en múltiples pruebas, puedes usar funciones auxiliares:

function createItem(overrides = {}) {
  return {
    id: 1,
    name: 'Test Item',
    price: 10,
    inStock: true,
    ...overrides
  };
}

function createItemList() {
  return [
    createItem({ id: 1, name: 'Item 1' }),
    createItem({ id: 2, name: 'Item 2', price: 20 }),
    createItem({ id: 3, name: 'Item 3', inStock: false })
  ];
}

test('filters in-stock items', () => {
  const items = createItemList();
  const inStockItems = filterItems(items, item => item.inStock);
  expect(inStockItems.length).toBe(2);
});

test('filters by price range', () => {
  const items = createItemList();
  const affordableItems = filterItems(items, item => item.price <= 15);
  expect(affordableItems.length).toBe(2);
});

Este enfoque, a veces llamado "fixtures", te permite mantener tus pruebas más limpias y mantener la consistencia en tus datos de prueba.

Aislamiento en pruebas

Para pruebas unitarias efectivas, a menudo necesitas aislar la función que estás probando de sus dependencias:

// En vez de probar esta función que tiene dependencias externas...
function getUserData(userId) {
  return fetch(`/api/users/${userId}`)
    .then(response => response.json());
}

// ...podemos simular la dependencia para pruebas más controladas
import { vi } from 'vitest';

// Reemplazar temporalmente fetch con una implementación simulada
global.fetch = vi.fn().mockResolvedValue({
  json: () => Promise.resolve({ id: 1, name: 'Test User' })
});

test('getUserData returns user information', async () => {
  const user = await getUserData(1);
  expect(user.name).toBe('Test User');
});

Este concepto de simulación (conocido como "mocking") es una técnica avanzada que exploraremos más a fondo en lecciones posteriores.

Ejemplo integrador: Mejorando nuestras pruebas

Veamos cómo podríamos evolucionar las pruebas para nuestra función filterItems:

import { describe, test, expect } from 'vitest';
import { filterItems } from './utils';

describe('filterItems function', () => {
  // Paso 1: Prueba básica
  test('filters items with a basic predicate', () => {
    const numbers = [1, 2, 3, 4, 5];
    const evenNumbers = filterItems(numbers, n => n % 2 === 0);
    expect(evenNumbers).toEqual([2, 4]);
  });
  
  // Paso 2: Añadir más casos de prueba
  test('works with empty arrays', () => {
    const emptyArray = [];
    const result = filterItems(emptyArray, item => true);
    expect(result).toEqual([]);
  });
  
  test('works with complex objects', () => {
    const users = [
      { id: 1, name: 'Alice', age: 25 },
      { id: 2, name: 'Bob', age: 17 },
      { id: 3, name: 'Charlie', age: 30 }
    ];
    
    const adults = filterItems(users, user => user.age >= 18);
    expect(adults.length).toBe(2);
    expect(adults[0].name).toBe('Alice');
    expect(adults[1].name).toBe('Charlie');
  });
  
  // Paso 3: Probar comportamiento con predicados inválidos
  test('returns empty array when predicate is always false', () => {
    const items = [1, 2, 3];
    const result = filterItems(items, () => false);
    expect(result).toEqual([]);
  });
  
  // Paso 4: Probar borde de error
  test('throws error when predicate is not a function', () => {
    const items = [1, 2, 3];
    expect(() => filterItems(items, 'not a function')).toThrow();
  });
});

Este ejemplo muestra cómo podemos partir de una prueba simple y gradualmente expandirla para cubrir más casos y comportamientos límite, mejorando la robustez de nuestras pruebas.

Integración con tu flujo de trabajo

Las pruebas no deben ser una actividad aislada, sino una parte integral de tu proceso de desarrollo:

  1. Escribe pruebas junto con tu código - No las dejes para el final
  2. Ejecuta pruebas frecuentemente - Idealmente con cada cambio
  3. Arregla las pruebas fallidas inmediatamente - No dejes que se acumulen

Conexión con otras prácticas de calidad

Las pruebas se complementan perfectamente con otras prácticas que hemos visto:

  • ESLint detecta errores sintácticos y de estilo
  • Las pruebas verifican que el comportamiento funcional es correcto
  • La modularización (módulos ES6) hace que tu código sea más fácil de probar

Juntas, estas herramientas forman una red de seguridad que mejora significativamente la calidad de tu código y reduce los errores en producción.

Aprende JavaScript online

Otras lecciones de JavaScript

Accede a todas las lecciones de JavaScript y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a JavaScript y certifícate

Ejercicios de programación de JavaScript

Evalúa tus conocimientos de esta lección Introducción a pruebas en JS con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.