Tu proyecto hace lo que promete: el dominio cumple las reglas, los datos sobreviven a la recarga, la aplicación habla con una API y trabaja sin conexión. Y ahora llega la pregunta incómoda, la que separa una demo de un producto: ¿cómo sabes que sigue haciéndolo mañana? Porque tienes migraciones que pueden corromper datos silenciosamente, sincronización con carreras que solo aparecen con mala red, y vistas que pueden retener memoria al navegar — tres cosas que no se ven mirando la pantalla y que, cuando fallan, fallan en el dispositivo de otra persona. En esta lección llevas al proyecto propio todo lo que aprendiste en el Módulo 8, pero con una diferencia importante: allí las pruebas se te daban escritas, y aquí tienes que decidir qué probar, en qué nivel y hasta dónde. Verás cómo definir una estrategia de pruebas con la pirámide aplicada a tus módulos concretos y un objetivo de cobertura razonado en lugar de cosmético; cómo probar el dominio exhaustivamente con pruebas parametrizadas de las quince reglas; cómo probar la capa de datos con dobles, temporizadores falsos y fetch simulado, incluidas las migraciones y la cola; cómo probar la vista consultando por rol y también el recorrido con teclado; cómo elegir tres recorridos de extremo a extremo y por qué no más; cómo montar la integración continua completa con presupuesto de rendimiento; cómo depurar tres fallos que van a aparecer de verdad en tu proyecto, con la técnica de reproducir primero y escribir una prueba que falle; cómo automatizar y complementar las pruebas de accesibilidad; y cómo medir tu proyecto contra la línea base de Nómada Tareas. Al terminar tendrás la suite completa en verde en CI y el informe de accesibilidad y rendimiento.

Contenido

  1. La estrategia de pruebas: decidir qué se prueba y dónde
  2. La pirámide aplicada a tus módulos concretos
  3. Un objetivo de cobertura razonado, no cosmético
  4. Probar el dominio: las quince reglas parametrizadas
  5. Probar las transiciones y los casos límite
  6. Probar la capa de datos con dobles y temporizadores falsos
  7. Probar las migraciones y la cola offline
  8. Probar la vista con jsdom y Testing Library
  9. Probar el recorrido con teclado
  10. Tres recorridos de extremo a extremo, y por qué no más
  11. Integración continua con GitHub Actions
  12. Lighthouse CI y el presupuesto de rendimiento
  13. Depurar el proyecto propio: el método aplicado
  14. Fallo 1: la migración que corrompe datos
  15. Fallo 2: la condición de carrera al sincronizar
  16. Fallo 3: la fuga de memoria al navegar
  17. Pruebas de accesibilidad: automáticas y manuales
  18. Medir el rendimiento del proyecto propio
  19. Las pruebas intermitentes
  20. Errores Comunes y Consejos
  21. Ejercicios
  22. Conclusión

  1. La estrategia de pruebas: decidir qué se prueba y dónde

Antes de escribir una sola prueba más, hay que responder a tres preguntas. Escribirlas en docs/estrategia-pruebas.md es media hora que ahorra semanas de pruebas mal colocadas.

Pregunta 1 · ¿Qué es lo que no puede fallar nunca?

En Órbita, la respuesta es concreta: las reglas de negocio y la persistencia. Que un botón esté dos píxeles a la izquierda es un defecto cosmético; que una migración pierda las tareas de alguien es la muerte del producto. Las pruebas se concentran donde el fallo es catastrófico, no donde es fácil escribirlas.

Pregunta 2 · ¿Qué cambia a menudo y qué no?

Lo que cambia mucho —la maquetación, los textos, las clases CSS— no debe estar acoplado a las pruebas, o cada retoque visual romperá veinte pruebas que no detectan ningún fallo real. Lo que cambia poco —las reglas, los contratos— se puede probar a fondo sin coste de mantenimiento.

Pregunta 3 · ¿Cuánto cuesta cada prueba y cuánto vale?

Nivel Coste de escribir Coste de ejecutar Coste de mantener Confianza que da
Unitaria de dominio Bajo Milisegundos Bajo Alta sobre una regla
Integración con DOM Medio Decenas de ms Medio Alta sobre un flujo
Extremo a extremo Alto Segundos Alto Muy alta sobre el sistema

La conclusión operativa: muchas unitarias, bastantes de integración, muy pocas de extremo a extremo. No es una preferencia estética; es una consecuencia directa de la tabla.

La regla de decisión que resuelve casi todos los casos:

Prueba en el nivel más bajo que capte el fallo que te preocupa.

Si te preocupa que R13 no bloquee el cierre, es una prueba unitaria de dominio: no hace falta un navegador. Si te preocupa que el botón no esté deshabilitado cuando debe, es de integración con DOM. Si te preocupa que crear una tarea, recargar y verla no funcione de punta a punta, es de extremo a extremo. Probar R13 desde Cypress es cien veces más lento y da la misma información.

  1. La pirámide aplicada a tus módulos concretos

La pirámide de pruebas genérica no ayuda mucho. Esta sí, porque nombra tus ficheros:

Módulo Nivel principal Nº orientativo Herramienta Qué se comprueba
dominio/tarea.js Unitaria (Node) ~35 Jest R1–R6, R8–R10, invariantes, toJSON/desdeJSON
dominio/usuario.js Unitaria (Node) ~12 Jest R11, R15 (roles), normalización
dominio/arbol.js Unitaria (Node) ~22 Jest R12: construcción, hojas, horas, ciclos, profundidad
dominio/tablero.js Unitaria (Node) ~25 Jest R7, R13, R15 (carga), resumen, filtros
dominio/reglas.js 0 Son constantes; se prueban a través de quien las usa
datos/repositorio-*.js Contrato + integración ~10 × 3 Jest + jsdom El contrato común contra las tres implementaciones
datos/migraciones.js Unitaria con ficheros reales ~14 Jest Cada salto, idempotencia, versión futura, validez
datos/http.js Unitaria con fetch simulado ~15 Jest + doble Códigos, tiempo agotado, reintentos, cancelación
datos/cola.js Unitaria con temporizadores falsos ~12 Jest + fake timers Orden, persistencia, intentos, idempotencia
aplicacion/casos-uso.js Integración ~18 Jest + dobles Estados, reversión optimista, errores
aplicacion/selectores.js Unitaria pura ~14 Jest Filtros, carga por semana, derivados
vista/*-vista.js Integración con DOM ~30 Testing Library Consulta por rol, eventos, accesibilidad, destruir
vista/formulario-tarea.js Integración con DOM ~16 Testing Library + user-event Validación, foco, aria-invalid, teclado
Recorridos completos Extremo a extremo 3 Cypress Los tres flujos que definen el producto

Total orientativo: unas 250 pruebas, de las cuales tres son de extremo a extremo. Nómada Tareas tenía 124 pruebas y 3 recorridos con menos funcionalidades; el orden de magnitud es coherente.

Dos observaciones sobre esta tabla:

  • reglas.js no se prueba directamente. Son constantes. Una prueba que comprueba que MAX_HORAS === 40 no verifica nada: reescribe el valor en otro sitio. Lo que se prueba es que la regla se aplica, y eso ocurre en tarea.js.
  • La proporción vista/dominio es de aproximadamente 1 a 2. Si tu proyecto tuviera más pruebas de vista que de dominio, sería señal de que la lógica está en la vista — exactamente lo que la arquitectura de 11-01 pretendía evitar. La distribución de tus pruebas es un diagnóstico de tu arquitectura.

  1. Un objetivo de cobertura razonado, no cosmético

La cobertura mide qué porcentaje del código se ejecuta durante las pruebas. Es una métrica útil y peligrosa a partes iguales, y conviene entender por qué.

Lo que la cobertura sí dice: qué código no está probado en absoluto. Un 0 % en migraciones.js es una alarma real.

Lo que la cobertura no dice: si lo probado está bien probado. Esta prueba da 100 % de cobertura y no verifica nada:

test('crea una tarea', () => {
  const tarea = new Tarea(datosValidos);
  expect(tarea).toBeDefined();          // ← siempre pasa; no comprueba nada útil
});

Por eso un objetivo único para todo el proyecto es un mal objetivo. Lo razonable es por capa, y con criterio:

// jest.config.js
export default {
  projects: [
    { displayName: 'dominio', testEnvironment: 'node',  testMatch: ['<rootDir>/test/dominio/**/*.test.js'] },
    { displayName: 'datos',   testEnvironment: 'jsdom', testMatch: ['<rootDir>/test/datos/**/*.test.js'] },
    { displayName: 'vista',   testEnvironment: 'jsdom', testMatch: ['<rootDir>/test/vista/**/*.test.js'] }
  ],
  collectCoverageFrom: ['src/**/*.js', '!src/main.js', '!src/datos/semilla.js'],
  coverageThreshold: {
    global:                { branches: 75, functions: 80, lines: 80 },
    './src/dominio/':      { branches: 95, functions: 95, lines: 95 },
    './src/datos/migraciones.js': { branches: 100, functions: 100, lines: 100 },
    './src/aplicacion/':   { branches: 85, functions: 85, lines: 85 },
    './src/vista/':        { branches: 60, functions: 65, lines: 65 }
  }
};

La justificación de cada umbral, que es lo que hay que escribir en tu estrategia:

Ruta Umbral Por qué
dominio/ 95 % Es donde vive el valor y donde un fallo es una regla incumplida. Barato de probar
migraciones.js 100 % Un camino no probado es un camino que puede destruir datos irreversiblemente
aplicacion/ 85 % Orquestación; importan los caminos de error, que son los que se olvidan
vista/ 65 % Probar cada rama de maquetación es caro y frágil. Se prueban los flujos, no los píxeles
Global 80 % Consecuencia de los anteriores, no un objetivo en sí

Y la métrica que de verdad importa no es el porcentaje: es la cobertura de las reglas. Mantén una tabla en docs/estrategia-pruebas.md:

Regla Caso que pasa Caso que falla Caso límite Fichero
R1 ✅ id tras borrar el último dominio/tarea.test.js
R2 ✅ solo espacios y solo tabuladores dominio/tarea.test.js
R3 ✅ 0, 0.5, 40, 40.1 dominio/tarea.test.js
R12 ✅ los tres tipos de ciclo dominio/arbol.test.js
R13 ✅ sin subtareas dominio/tablero.test.js
R14 ✅ intento de modificar el historial dominio/historial.test.js
R15 ✅ semana 53, cambio de año dominio/tablero.test.js

Quince reglas × tres casos = cuarenta y cinco pruebas mínimas. Esa tabla es un objetivo mucho más honesto que «85 % de cobertura», porque no se puede aprobar sin verificar nada.

  1. Probar el dominio: las quince reglas parametrizadas

Las pruebas parametrizadas son la herramienta natural para las reglas, porque una regla es una afirmación sobre un conjunto de casos.

// test/dominio/reglas-validacion.test.js
import { Tarea } from '../../src/dominio/tarea.js';
import { ErrorDeValidacion } from '../../src/dominio/errores.js';
import { tareaValida } from '../ayudas/fabricas.js';

describe.each([
  // regla, campo,            valor,                  ¿válido?, nota
  ['R2', 'titulo',            'Revisar el torno',      true,  'normal'],
  ['R2', 'titulo',            '',                      false, 'vacío'],
  ['R2', 'titulo',            '   ',                   false, 'solo espacios'],
  ['R2', 'titulo',            '\t\n',                  false, 'solo blancos'],
  ['R2', 'titulo',            'a',                     true,  'un carácter'],
  ['R3', 'horasEstimadas',    0,                       false, 'cero'],
  ['R3', 'horasEstimadas',    0.5,                     true,  'mínimo razonable'],
  ['R3', 'horasEstimadas',    40,                      true,  'límite exacto'],
  ['R3', 'horasEstimadas',    40.1,                    false, 'justo por encima'],
  ['R3', 'horasEstimadas',    -3,                      false, 'negativo'],
  ['R3', 'horasEstimadas',    NaN,                     false, 'no numérico'],
  ['R3', 'horasEstimadas',    '12',                    false, 'texto: no se convierte'],
  ['R8', 'responsableId',     null,                    true,  'sin asignar'],
  ['R8', 'responsableId',     '',                      false, 'cadena vacía prohibida'],
  ['R4', 'fechaLimite',       '2020-01-01',            false, 'anterior a la creación']
])('%s · %s = %p (%s)', (regla, campo, valor, valido) => {
  test(valido ? 'se acepta' : 'se rechaza con ErrorDeValidacion', () => {
    const construir = () => new Tarea({ ...tareaValida(), [campo]: valor });

    if (valido) {
      expect(construir).not.toThrow();
    } else {
      expect(construir).toThrow(ErrorDeValidacion);
      try { construir(); } catch (e) { expect(e.campo).toBe(campo); }
    }
  });
});

Cuatro decisiones de esta tabla que la hacen valiosa:

1 · La tabla es legible como especificación. Cualquiera que la lea sabe exactamente qué acepta y qué rechaza el modelo, sin leer el código de Tarea.

2 · Cada regla tiene sus límites exactos. Para R3: 0 no, 0.5 sí, 40 sí, 40.1 no. Los fallos de los desarrolladores viven en los > que deberían ser >=, y solo se cazan probando el valor exacto del límite.

3 · Se comprueba error.campo, no solo el tipo. Ese dato es lo que permite al formulario marcar el campo correcto (11-02). Si no se prueba, un día alguien pone campo: 'horas' en lugar de 'horasEstimadas' y el formulario deja de resaltar nada, en silencio.

4 · Hay casos de tipo, no solo de valor. '12' como texto y NaN son los que se cuelan por validaciones ingenuas como if (horas > 40). Con NaN, esa comparación es false y el valor pasa.

El caso R14 merece una prueba especial, porque su invariante no es sobre un valor sino sobre la estructura:

test('R14 · una entrada de historial no se puede modificar', () => {
  const entrada = historial.registrar({ tareaId: 1, campo: 'estado', antes: 'pendiente', despues: 'en-curso' });

  expect(() => { entrada.despues = 'hecha'; }).toThrow(TypeError);   // congelada
  expect(historial.listar(1)).toHaveLength(1);
});

test('R14 · una corrección genera una entrada nueva, no modifica la anterior', () => {
  historial.registrar({ tareaId: 1, campo: 'horas', antes: 10, despues: 99 });
  historial.registrar({ tareaId: 1, campo: 'horas', antes: 99, despues: 12 });

  expect(historial.listar(1)).toHaveLength(2);
  expect(historial.listar(1)[0].despues).toBe(99);   // la primera sigue diciendo 99
});

  1. Probar las transiciones y los casos límite

La matriz de transiciones de R6 se prueba entera, como en 11-02. Pero hay tres tipos de caso límite que se olvidan sistemáticamente y que conviene enumerar:

Los límites numéricos. Para cada comparación de tu dominio, prueba el valor exacto, uno por debajo y uno por encima:

Regla Valores obligatorios
R3 (0 < h ≤ 40) 0, 0.01, 40, 40.01
R7 (≤ 40 h/semana) 39.5, 40, 40.5
R12 (profundidad ≤ 3) 3 niveles (válido), 4 (rechazado)

Los límites de colección. Para cada función que recibe una lista:

Caso Por qué importa
Lista vacía reduce sin valor inicial lanza; el estado vacío de la interfaz depende de esto
Un elemento Los algoritmos que comparan pares fallan aquí
Todos iguales Ordenaciones inestables, agrupaciones degeneradas
Con null intercalado Datos migrados imperfectos

Los límites temporales. Los que más fallos producen y menos se prueban:

describe.each([
  ['2026-01-01', 2026,  1, 'año que empieza en jueves'],
  ['2027-01-01', 2026, 53, 'el 1 de enero cae en la semana 53 del año anterior'],
  ['2028-02-29', 2028,  9, 'año bisiesto'],
  ['2026-12-31', 2026, 53, 'último día del año']
])('semana ISO de %s', (fecha, año, semana) => {
  test(`es ${año}-S${semana}`, () => {
    expect(semanaISO(fecha)).toEqual({ año, semana });
  });
});

La segunda fila es la que rompe casi todas las implementaciones caseras de semana ISO, y afecta directamente a R15 y al informe de carga. Si tu proyecto usa semanas, esta prueba no es opcional.

Y un caso que solo aparece en proyectos con árboles: el árbol degenerado. Una tarea con 200 subtareas en un solo nivel, y una cadena de 3 niveles con una hoja en cada uno. Los dos ejercitan caminos distintos de tu recursividad.

  1. Probar la capa de datos con dobles y temporizadores falsos

Aquí se aplica todo lo de 08-04. La regla que gobierna: se sustituye lo que es lento, no determinista o externo; nunca la lógica que estás probando.

Qué se sustituye Con qué Por qué
fetch Doble que devuelve respuestas controladas Sin red, sin servidor, determinista
Temporizadores jest.useFakeTimers() Un reintento de 8 s tarda 0 ms en la prueba
Date jest.setSystemTime() Las pruebas de vencimiento no dependen del día
localStorage El de jsdom, limpiado en beforeEach Aislamiento entre pruebas
crypto.randomUUID Doble con secuencia predecible Poder afirmar sobre los ids

6.1 fetch simulado

// test/ayudas/fetch-simulado.js
export function simularFetch(respuestas) {
  const llamadas = [];
  global.fetch = jest.fn(async (url, opciones = {}) => {
    llamadas.push({ url, metodo: opciones.method ?? 'GET', cuerpo: opciones.body, cabeceras: opciones.headers });
    const siguiente = respuestas.shift();
    if (!siguiente) throw new Error(`fetch inesperado a ${url}`);
    if (siguiente.error) throw siguiente.error;
    return {
      ok: siguiente.status < 400,
      status: siguiente.status,
      json: async () => siguiente.cuerpo,
      text: async () => JSON.stringify(siguiente.cuerpo)
    };
  });
  return { llamadas };
}

El detalle de throw new Error('fetch inesperado') es importante: una llamada de más que no esperabas es un fallo que quieres ver, no algo que deba devolver undefined y producir un error confuso más adelante.

test('un 500 se reintenta y el segundo intento tiene éxito', async () => {
  const { llamadas } = simularFetch([
    { status: 500, cuerpo: { mensaje: 'Servidor no disponible' } },
    { status: 200, cuerpo: [{ id: 1, titulo: 'Revisar el torno' }] }
  ]);

  const repo = new RepositorioApi('http://api.local');
  const tareas = await repo.listarTareas();

  expect(tareas).toHaveLength(1);
  expect(llamadas).toHaveLength(2);                  // se reintentó exactamente una vez
});

test('un 400 NO se reintenta', async () => {
  const { llamadas } = simularFetch([
    { status: 400, cuerpo: { mensaje: 'Falta el título', codigo: 'TITULO_REQUERIDO' } }
  ]);

  await expect(new RepositorioApi('http://api.local').crearTarea({}))
    .rejects.toMatchObject({ name: 'ErrorDeApi', status: 400, codigo: 'TITULO_REQUERIDO' });
  expect(llamadas).toHaveLength(1);                  // ni un intento más
});

Las dos aserciones sobre llamadas.length son el corazón de estas pruebas. Sin ellas, ambas pasarían aunque el reintento estuviera mal implementado.

6.2 Temporizadores falsos

test('el retroceso exponencial espera 300, 600 y 1200 ms', async () => {
  jest.useFakeTimers();
  simularFetch([{ status: 503 }, { status: 503 }, { status: 503 }, { status: 200, cuerpo: [] }]);

  const promesa = new RepositorioApi('http://api.local').listarTareas();

  await jest.advanceTimersByTimeAsync(300);
  expect(global.fetch).toHaveBeenCalledTimes(2);
  await jest.advanceTimersByTimeAsync(600);
  expect(global.fetch).toHaveBeenCalledTimes(3);
  await jest.advanceTimersByTimeAsync(1200);
  expect(global.fetch).toHaveBeenCalledTimes(4);

  await expect(promesa).resolves.toEqual([]);
  jest.useRealTimers();
});

advanceTimersByTimeAsync y no advanceTimersByTime. La versión asíncrona vacía también la cola de microtareas entre avances, y sin ella las promesas intermedias no se resuelven y la prueba se cuelga. Es uno de esos detalles que cuestan una tarde la primera vez, y tiene todo el sentido a la luz de 05-07: las microtareas y los temporizadores son colas distintas.

Recuerda restaurar los temporizadores reales. Un jest.useFakeTimers() que se queda activo contamina las pruebas siguientes con fallos aparentemente aleatorios. Ponlo en un afterEach global.

  1. Probar las migraciones y la cola offline

7.1 Migraciones

Ya viste las seis pruebas base en 11-03. Añade estas tres, que cubren lo que sale mal en la práctica:

test('un documento con un campo desconocido no se pierde ni rompe', () => {
  const conExtra = { ...v1, datos: { ...v1.datos, experimento: [1, 2, 3] } };
  expect(() => migrar(conExtra)).not.toThrow();
});

test('migrar 500 tareas tarda menos de 100 ms', () => {
  const grande = generarDocumentoV1({ tareas: 500 });
  const inicio = performance.now();
  migrar(grande);
  expect(performance.now() - inicio).toBeLessThan(100);
});

test('cada salto de versión se prueba por separado', () => {
  const paso2 = MIGRACIONES.find((m) => m.a === 2);
  const resultado = paso2.migrar(structuredClone(v1));
  expect(resultado.datos.tareas[0]).not.toHaveProperty('responsable');
  expect(resultado.datos.tareas[0]).toHaveProperty('responsableId');
});

La segunda es una prueba de rendimiento en la suite, y aquí sí está justificada: la migración ocurre al arrancar, bloqueando la primera pintura. Una migración de dos segundos arruina el LCP del presupuesto de 11-01, y ese fallo solo aparece con datos de usuario real. Ponerle un umbral en la prueba lo previene.

7.2 La cola offline

describe('ColaDeCambios', () => {
  beforeEach(() => { localStorage.clear(); jest.useFakeTimers(); });
  afterEach(() => jest.useRealTimers());

  test('encola cuando no hay red y aplica en local igualmente', async () => { /* … */ });

  test('conserva el orden al vaciar', async () => {
    const { llamadas } = simularFetch([{ status: 201, cuerpo: { id: 7 } }, { status: 200, cuerpo: {} }]);
    cola.encolar({ tipo: 'crear',      recurso: 'tarea', cargaUtil: { titulo: 'A' } });
    cola.encolar({ tipo: 'actualizar', recurso: 'tarea', cargaUtil: { id: 7, estado: 'en-curso' } });

    await sincronizador.vaciar();

    expect(llamadas[0].metodo).toBe('POST');
    expect(llamadas[1].metodo).toBe('PUT');
  });

  test('reenviar usa la MISMA clave de idempotencia', async () => {
    simularFetch([{ error: new TypeError('Failed to fetch') }, { status: 201, cuerpo: { id: 7 } }]);
    const id = cola.encolar({ tipo: 'crear', recurso: 'tarea', cargaUtil: { titulo: 'A' } });

    await sincronizador.vaciar();      // falla, sigue en cola
    await sincronizador.vaciar();      // reintenta

    const claves = global.fetch.mock.calls.map(([, o]) => o.headers['Idempotency-Key']);
    expect(claves[0]).toBe(claves[1]);
    expect(claves[0]).toBe(id);
  });

  test('tras 5 intentos fallidos, la entrada se marca como necesita atención', async () => { /* … */ });

  test('la cola sobrevive a recrear el objeto', () => {
    cola.encolar({ tipo: 'crear', recurso: 'tarea', cargaUtil: {} });
    expect(new ColaDeCambios('orbita:cola').pendientes).toBe(1);
  });
});

La tercera prueba es la que evita el duplicado del apartado 13 de 11-03, y es imposible de comprobar a mano de forma fiable. Es el ejemplo perfecto de un fallo que solo se caza con una prueba automatizada.

  1. Probar la vista con jsdom y Testing Library

El principio de 08-05, que conviene recordar porque lo cambia todo:

Prueba como usa la aplicación una persona, no como está construida por dentro.

En la práctica, eso significa consultar por rol y por texto accesible, nunca por clase CSS ni por estructura.

❌ Frágil y poco informativo ✅ Robusto y significativo
container.querySelector('.tarea__btn') getByRole('button', { name: /marcar en curso/i })
document.querySelectorAll('.fila') getAllByRole('listitem')
input.value = 'x'; input.dispatchEvent(...) await user.type(input, 'x')
expect(el.className).toContain('error') expect(input).toHaveAttribute('aria-invalid', 'true')

El beneficio doble: la prueba sobrevive a un cambio de clases CSS, y además verifica accesibilidad de propina. Si getByRole('button', { name: /crear/i }) no encuentra nada, es porque tu botón no es un botón o no tiene nombre accesible — y eso es un fallo real que un querySelector habría ocultado.

// test/vista/tablero-vista.test.js
import { screen, within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';

describe('TableroVista', () => {
  let contenedor, emitidos, vista;

  beforeEach(() => {
    document.body.innerHTML = '<div id="raiz"></div><div id="anuncios" role="status" aria-live="polite"></div>';
    contenedor = document.getElementById('raiz');
    emitidos = [];
    vista = crearTableroVista(contenedor, { alEmitir: (e) => emitidos.push(e) });
  });

  afterEach(() => vista.destruir());

  test('pinta una fila por tarea, como lista semántica', () => {
    vista.render(estadoCon(3));
    expect(screen.getAllByRole('listitem')).toHaveLength(3);
  });

  test('la tarea vencida se identifica con texto, no solo con color', () => {
    vista.render(estadoCon([tareaVencida({ titulo: 'Presupuesto' })]));
    const fila = screen.getByRole('listitem');
    expect(within(fila).getByText(/vencida/i)).toBeInTheDocument();
  });

  test('pulsar el botón emite la intención con el id correcto', async () => {
    const user = userEvent.setup();
    vista.render(estadoCon([tarea({ id: 42, titulo: 'Revisar el torno' })]));

    await user.click(screen.getByRole('button', { name: /marcar en curso/i }));

    expect(emitidos).toEqual([{ accion: 'cambiar-estado', id: 42, valor: 'en-curso' }]);
  });

  test('el número de tareas visibles se anuncia', () => {
    vista.render(estadoCon(12));
    expect(screen.getByRole('status')).toHaveTextContent(/12 tareas/i);
  });

  test('sin resultados muestra estado vacío con salida', () => {
    vista.render(estadoConFiltroSinResultados());
    expect(screen.getByText(/ninguna tarea coincide/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /quitar filtros/i })).toBeInTheDocument();
  });

  test('destruir deja el contenedor vacío y sin escuchas', () => {
    vista.render(estadoCon(3));
    const espia = jest.spyOn(contenedor, 'removeEventListener');
    vista.destruir();
    expect(contenedor).toBeEmptyDOMElement();
    expect(espia).toHaveBeenCalled();
  });
});

Fíjate en que la mitad de estas pruebas verifican requisitos de accesibilidad y de experiencia —lista semántica, texto además del color, anuncio del recuento, estado vacío con salida— que en 11-01 escribiste como criterios de aceptación. El círculo se cierra: historia → criterio → prueba.

  1. Probar el recorrido con teclado

Esta es la parte que casi ningún proyecto de portafolio tiene, y es la que más impresiona en una revisión.

test('se puede crear una tarea sin tocar el ratón', async () => {
  const user = userEvent.setup();
  montarAplicacion();

  await user.tab();                                     // primer elemento enfocable
  expect(screen.getByRole('button', { name: /nueva tarea/i })).toHaveFocus();

  await user.keyboard('{Enter}');                       // abrir el diálogo

  const dialogo = screen.getByRole('dialog', { name: /nueva tarea/i });
  expect(within(dialogo).getByLabelText(/título/i)).toHaveFocus();   // foco dentro al abrir

  await user.keyboard('Revisar el torno');
  await user.tab();
  await user.keyboard('8');                             // horas
  await user.keyboard('{Enter}');                       // enviar con Enter

  expect(screen.getByRole('listitem', { name: /revisar el torno/i })).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /nueva tarea/i })).toHaveFocus();  // el foco VUELVE
});

test('Escape cierra el diálogo y devuelve el foco al disparador', async () => {
  const user = userEvent.setup();
  montarAplicacion();
  const disparador = screen.getByRole('button', { name: /nueva tarea/i });

  await user.click(disparador);
  await user.keyboard('{Escape}');

  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  expect(disparador).toHaveFocus();
});

test('el foco no se escapa del diálogo abierto', async () => {
  const user = userEvent.setup();
  montarAplicacion();
  await user.click(screen.getByRole('button', { name: /nueva tarea/i }));

  for (let i = 0; i < 12; i++) await user.tab();        // dar la vuelta varias veces

  const dialogo = screen.getByRole('dialog');
  expect(dialogo.contains(document.activeElement)).toBe(true);
});

Las tres cubren los tres fallos de foco más comunes: no entrar al abrir, no volver al cerrar y escaparse mientras está abierto. Si usas <dialog> con showModal(), las tres pasan casi sin esfuerzo — que es exactamente el argumento de 11-02 sobre usar el elemento correcto.

Advertencia sobre jsdom: jsdom implementa <dialog> de forma incompleta en algunas versiones, y no calcula estilos, así que no puede verificar visibilidad ni contraste. Las pruebas de teclado en jsdom cubren la lógica del foco; la verificación real de que se ve dónde estás es manual o de extremo a extremo.

  1. Tres recorridos de extremo a extremo, y por qué no más

Las pruebas de extremo a extremo (08-06) son las más valiosas por prueba y las más caras por prueba. Esa doble condición dicta la estrategia: pocas y muy bien elegidas.

Coste Detalle
Ejecución Segundos por recorrido, frente a milisegundos
Mantenimiento Cualquier cambio de flujo las rompe
Intermitencia Dependen de tiempos, red y animaciones
Diagnóstico Cuando fallan, dicen que algo falla, no qué

El criterio de selección, en una frase: elige los recorridos que, si se rompieran, el producto no serviría para nada. Para Órbita:

Recorrido 1 · El ciclo de vida completo de una tarea.

// cypress/e2e/01-ciclo-de-vida.cy.js
describe('Ciclo de vida de una tarea', () => {
  beforeEach(() => {
    cy.clearLocalStorage();
    cy.visit('/');
  });

  it('crea, descompone, avanza y cierra una tarea', () => {
    cy.findByRole('button', { name: /nueva tarea/i }).click();
    cy.findByLabelText(/título/i).type('Revisar el torno de madera');
    cy.findByLabelText(/horas/i).type('8');
    cy.findByRole('button', { name: /crear tarea/i }).click();

    cy.findByRole('listitem', { name: /revisar el torno/i }).as('tarea');
    cy.get('@tarea').findByRole('button', { name: /añadir subtarea/i }).click();
    cy.findByLabelText(/título/i).type('Comprar la lija');
    cy.findByRole('button', { name: /crear subtarea/i }).click();

    // R13: no se puede cerrar la madre con la hija abierta
    cy.get('@tarea').findByRole('button', { name: /marcar hecha/i }).click();
    cy.findByRole('alert').should('contain.text', 'Comprar la lija');

    // Cerrar la hija primero, y entonces sí
    cy.findByRole('listitem', { name: /comprar la lija/i })
      .findByRole('button', { name: /marcar hecha/i }).click();
    cy.get('@tarea').findByRole('button', { name: /marcar hecha/i }).click();
    cy.get('@tarea').should('contain.text', 'Hecha');

    // Persistencia real
    cy.reload();
    cy.findByRole('listitem', { name: /revisar el torno/i }).should('contain.text', 'Hecha');
  });
});

Este recorrido cubre de una vez: crear con validación, el árbol, R13 aplicada de verdad desde la interfaz, el mensaje de error, la transición completa y la persistencia tras recargar. Un recorrido, seis riesgos.

Recorrido 2 · Filtrar, compartir el filtro y ver el informe. Cubre el estado compartido, la URL como fuente de verdad, el cálculo de carga y el estado vacío.

Recorrido 3 · Trabajar sin conexión y recuperar. Cubre la cola, la persistencia y la reconciliación:

it('los cambios sin conexión se sincronizan al recuperarla', () => {
  cy.visit('/');
  cy.intercept('POST', '**/tareas', { forceNetworkError: true }).as('sinRed');

  cy.crearTarea('Presupuesto de la carpintería');
  cy.findByText(/1 cambio sin sincronizar/i).should('be.visible');

  cy.intercept('POST', '**/tareas', { statusCode: 201, body: { id: 99 } }).as('conRed');
  cy.window().then((win) => win.dispatchEvent(new Event('online')));

  cy.wait('@conRed');
  cy.findByText(/sin sincronizar/i).should('not.exist');
  cy.findAllByRole('listitem', { name: /presupuesto de la carpintería/i }).should('have.length', 1);
});

Ese último should('have.length', 1) es la prueba del duplicado: si la idempotencia falla, aparecen dos.

Por qué exactamente tres. Con tres recorridos cubres los tres riesgos sistémicos —el flujo principal, el estado compartido y la resiliencia— en unos 30 segundos de ejecución. Con veinte tendrías cinco minutos de CI, fallos intermitentes semanales y una carga de mantenimiento que acabaría con alguien desactivándolos. Una suite de extremo a extremo desactivada vale cero, así que el número correcto es el máximo que puedes mantener siempre en verde. Tres lo es. Nómada Tareas también tenía tres, con menos funcionalidades: la proporción se sostiene.

  1. Integración continua con GitHub Actions

La integración continua ejecuta npm run verificar en cada push, en una máquina limpia. Ese «máquina limpia» es la mitad del valor: caza los «en mi máquina funciona» que se deben a un fichero no confirmado o a una dependencia global.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

# Cancela ejecuciones antiguas de la misma rama: ahorra minutos y da resultados relevantes
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  calidad:
    name: Lint, formato y pruebas
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm            # cachea ~/.npm: de ~60 s a ~10 s

      - name: Instalar dependencias
        run: npm ci             # ci, no install: respeta el lockfile exactamente

      - name: Revisar el código
        run: npm run lint

      - name: Comprobar el formato
        run: npm run format:check

      - name: Pruebas con cobertura
        run: npm run test:cov -- --ci --maxWorkers=2

      - name: Publicar el informe de cobertura
        if: always()            # también si las pruebas fallaron
        uses: actions/upload-artifact@v4
        with:
          name: cobertura
          path: coverage/

      - name: Compilar
        run: npm run build

      - name: Guardar la compilación
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  e2e:
    name: Recorridos de extremo a extremo
    runs-on: ubuntu-latest
    needs: calidad              # no gastar minutos si el lint ya falló
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci

      - name: Cypress
        uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm run preview
          wait-on: 'http://localhost:4173'
          wait-on-timeout: 60

      - name: Guardar capturas de los fallos
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: cypress-fallos
          path: cypress/screenshots/

  accesibilidad-y-rendimiento:
    name: Lighthouse y axe
    runs-on: ubuntu-latest
    needs: calidad
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run build

      - name: Lighthouse CI
        run: npx @lhci/[email protected] autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

Las decisiones del fichero, una a una:

Línea Por qué
concurrency con cancel-in-progress Si haces tres push seguidos, solo se ejecuta el último. Ahorra minutos y evita resultados obsoletos
cache: npm Reduce la instalación de ~60 s a ~10 s. Es la mejora más barata que existe
npm ci en vez de npm install Instala exactamente lo del package-lock.json. Reproducible; install puede actualizar versiones menores
--ci --maxWorkers=2 Los ejecutores tienen 2 núcleos; más trabajadores compiten y ralentizan. --ci desactiva la actualización automática de instantáneas
if: always() en la cobertura Cuando las pruebas fallan es justo cuando quieres ver el informe
needs: calidad en los otros trabajos No gastar cinco minutos de Cypress si el lint falló en diez segundos
if: failure() en las capturas Cypress guarda captura y vídeo del fallo: es lo primero que mirarás

La protección de la rama. La CI no sirve de nada si se puede fusionar en rojo. En los ajustes del repositorio, marca main como protegida y exige que los tres trabajos pasen antes de fusionar. Es un clic que convierte una recomendación en una garantía — la misma idea que la regla de ESLint de 11-01.

  1. Lighthouse CI y el presupuesto de rendimiento

En 11-01 fijaste el presupuesto. Aquí lo haces cumplir automáticamente.

{
  "ci": {
    "collect": {
      "staticDistDir": "./dist",
      "numberOfRuns": 3,
      "settings": { "preset": "desktop" }
    },
    "assert": {
      "assertions": {
        "categories:performance":   ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["error", { "minScore": 0.95 }],
        "categories:best-practices":["warn",  { "minScore": 0.9 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift":  ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time":      ["error", { "maxNumericValue": 200 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 61440 }],
        "resource-summary:total:count": ["warn", { "maxNumericValue": 10 }],
        "color-contrast":           ["error", { "minScore": 1 }],
        "label":                    ["error", { "minScore": 1 }],
        "button-name":              ["error", { "minScore": 1 }],
        "unused-javascript":        ["warn",  { "maxLength": 1 }]
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}

Cuatro observaciones:

  • numberOfRuns: 3. Lighthouse tiene ruido; con una sola ejecución tendrías fallos aleatorios. Tres y toma la mediana, exactamente la disciplina de medición de 09-01.
  • error frente a warn. Lo que rompe la compilación es lo del presupuesto firmado; lo demás informa. Si todo es error, acabarás desactivándolo entero al primer viernes complicado.
  • resource-summary:script:size en 61440 son los 60 kB del presupuesto de 11-01, en bytes. Esta única línea es la que impide que un día alguien añada una librería de gráficos y nadie se entere.
  • Las tres auditorías de accesibilidad con minScore: 1 —contraste, etiquetas, nombres de botón— son las que más se degradan con el tiempo. Exigir perfección en esas tres es asumible y evita la erosión.

Cuando el presupuesto falla, hay tres salidas legítimas y una ilegítima:

Salida Cuándo
Optimizar el cambio Casi siempre. Aplica el Módulo 9
Renunciar a la funcionalidad Si su valor no compensa su coste
Subir el presupuesto conscientemente y anotar por qué Si el producto ha crecido de forma legítima
~~Desactivar la comprobación~~ Nunca. Es cómo se llega a aplicaciones de 2 MB

  1. Depurar el proyecto propio: el método aplicado

La lección 08-01 dio el método. Aquí se aplica a fallos tuyos, y el cambio importante es que nadie te va a decir dónde está el problema.

El procedimiento, en seis pasos:

flowchart TD
    A["1 · Reproducir<br/>de forma fiable"] --> B["2 · Reducir al<br/>caso mínimo"]
    B --> C["3 · Escribir una prueba<br/>QUE FALLE"]
    C --> D["4 · Formar una hipótesis<br/>concreta y falsable"]
    D --> E["5 · Comprobarla<br/>con una sola medición"]
    E -->|Falsa| D
    E -->|Cierta| F["6 · Corregir · La prueba<br/>se pone en verde"]
    F --> G["La prueba se queda:<br/>regresión cubierta"]

    style C fill:#fee2e2,stroke:#b91c1c
    style G fill:#dcfce7,stroke:#16a34a

El paso 3 es el que casi todo el mundo se salta, y es el más rentable de los seis. Escribir la prueba antes de corregir tiene cuatro beneficios simultáneos:

  1. Demuestra que has entendido el fallo. Si no puedes escribir la prueba, no sabes qué está mal: solo tienes un síntoma.
  2. Te dice cuándo has terminado. Sin ella, «parece que ya funciona» es todo lo que tienes.
  3. Evita la reaparición. Los fallos corregidos sin prueba vuelven, y vuelven en el peor momento.
  4. Documenta el caso raro. Dentro de un año, esa prueba explicará por qué el código tiene esa línea aparentemente innecesaria.

El paso 4 merece un matiz. Una hipótesis útil es falsable y concreta: «el id que llega al PUT es undefined porque el servidor devuelve _id». Una hipótesis inútil es «algo pasa con los ids». La primera se comprueba con una medición; la segunda te lleva a tocar cosas a ver si se arregla, que es la forma más cara de depurar que existe.

Los tres apartados siguientes recorren tres fallos que van a aparecer en tu proyecto. No son ejemplos inventados: son las consecuencias directas de lo que has construido en 11-02 y 11-03.

  1. Fallo 1: la migración que corrompe datos

El síntoma. Un usuario de prueba (o tú, con tu perfil de desarrollo antiguo) actualiza la aplicación y ve el tablero con las tareas, pero todas aparecen sin responsable y el informe de carga sale a cero. No hay ningún error en consola.

Paso 1 · Reproducir. El fallo no ocurre con datos nuevos, solo con datos antiguos. La reproducción exige el documento original — y aquí se cobra el respaldo de 11-03:

// En la consola: recuperar el respaldo previo a la migración
const antiguo = localStorage.getItem('orbita:tablero:respaldo:v1');
console.log(JSON.parse(antiguo).datos.tareas.slice(0, 2));
[
  { id: 1, titulo: 'Rediseñar la sala', responsable: 'Iván',  horasEstimadas: 12 },
  { id: 2, titulo: 'Cartelería',        responsable: 'Marta', horasEstimadas: 6 }
]

Los datos antiguos tienen responsable. Luego lo pierde la migración.

Paso 2 · Reducir. Al caso mínimo: dos tareas y un usuario.

Paso 3 · La prueba que falla:

test('la migración v1→v2 conserva la asignación de responsable', () => {
  const v1 = {
    version: 1,
    datos: {
      usuarios: [{ id: 'u-ivan', nombre: 'Iván' }],
      tareas: [{ id: 1, titulo: 'Rediseñar la sala', responsable: 'Iván', horasEstimadas: 12 }]
    }
  };

  const resultado = migrar(v1);

  expect(resultado.datos.tareas[0].responsableId).toBe('u-ivan');   // ← FALLA: recibe null
});

Paso 4 · Hipótesis. El mapa porNombre no encuentra 'Iván'. Posibles causas: (a) los usuarios no están cargados cuando corre la migración, (b) el nombre no coincide exactamente, (c) el orden de las migraciones es incorrecto.

Paso 5 · Comprobar con una medición:

migrar: (doc) => {
  const porNombre = new Map(doc.datos.usuarios.map((u) => [u.nombre, u.id]));
  console.log('claves del mapa:', [...porNombre.keys()]);
  console.log('busco:', JSON.stringify(doc.datos.tareas[0].responsable));
  // …
}
claves del mapa: [ 'Iván' ]
busco: "Iván"

Ahí está. Los dos textos se ven idénticos en pantalla y no son iguales: uno usa el carácter precompuesto í (U+00ED) y el otro una i seguida de un acento combinante (U+0301). Es un clásico de los datos que han pasado por distintos sistemas operativos o navegadores.

Paso 6 · Corregir. Normalizando Unicode en ambos lados:

const normalizar = (s) => s?.normalize('NFC').trim();

const porNombre = new Map(doc.datos.usuarios.map((u) => [normalizar(u.nombre), u.id]));
// …
responsableId: t.responsable ? (porNombre.get(normalizar(t.responsable)) ?? null) : null

Y las tres lecciones generalizables:

  1. Emparejar por texto es frágil. Emparejar por identificador, siempre que sea posible. Si no queda más remedio, normaliza (normalize('NFC'), trim(), y valora toLocaleLowerCase()).
  2. Un fallo silencioso es peor que uno ruidoso. La migración ponía null sin avisar. Debería contar cuántas asignaciones no pudo resolver y registrarlo: registrar('migracion v2: 6 de 8 responsables sin resolver'). Un aviso habría revelado el fallo el primer día.
  3. El respaldo salvó la investigación. Sin él, los datos originales habrían desaparecido y solo tendrías el síntoma.

La prueba de regresión que se queda, con el caso Unicode explícito:

test.each([
  ['Iván',   'Iván'],     // combinante vs precompuesto
  ['Lucía ', 'Lucía'],          // espacio sobrante
])('empareja %p con %p pese a la diferencia de codificación', (enUsuario, enTarea) => {
  const doc = documentoV1({ usuario: enUsuario, responsableEnTarea: enTarea });
  expect(migrar(doc).datos.tareas[0].responsableId).not.toBeNull();
});

  1. Fallo 2: la condición de carrera al sincronizar

El síntoma. Con la red lenta, a veces —no siempre— al marcar una tarea como hecha, vuelve a aparecer como pendiente al cabo de un segundo. No pasa en tu máquina con red rápida. No hay error en consola.

Este es el peor tipo de fallo: intermitente y dependiente del entorno. Y la primera regla es no intentar depurarlo hasta poder reproducirlo a voluntad.

Paso 1 · Reproducir. Con el panel Network en Slow 3G, sale una de cada tres veces. Todavía no basta. Para hacerlo determinista, hay que controlar los tiempos:

// En la consola, retrasar artificialmente el PUT
const original = window.fetch;
window.fetch = async (url, opciones) => {
  if (opciones?.method === 'PUT') await new Promise((r) => setTimeout(r, 3000));
  return original(url, opciones);
};

Con tres segundos de retraso, ocurre siempre. Ya es reproducible.

Paso 2 · Reducir. La secuencia mínima resulta ser:

t=0.0 s  Marcar hecha        → optimista: hecha  → PUT sale (tardará 3 s)
t=0.5 s  Se refresca la lista → GET sale (rápido)
t=0.8 s  Llega el GET         → el servidor aún dice PENDIENTE → se pinta pendiente
t=3.0 s  Llega el PUT         → confirma hecha

La lista se refresca con datos anteriores a mi cambio y pisa la actualización optimista.

Paso 3 · La prueba que falla, con temporizadores falsos para hacerla determinista:

test('un refresco en vuelo no revierte una actualización optimista', async () => {
  jest.useFakeTimers();
  const repo = repositorioConLatencias({ PUT: 3000, GET: 300 });
  const almacen = crearAlmacen(estadoCon([tarea({ id: 1, estado: 'pendiente' })]));

  const marcado = marcarHecha(almacen, repo, 1);        // no esperamos
  await jest.advanceTimersByTimeAsync(500);
  const refresco = refrescarTareas(almacen, repo);      // se lanza en medio
  await jest.advanceTimersByTimeAsync(3000);
  await Promise.all([marcado, refresco]);

  expect(almacen.obtener().tareas[0].estado).toBe('hecha');   // ← FALLA: 'pendiente'
  jest.useRealTimers();
});

Paso 4 · Hipótesis. El GET sustituye la lista entera con lo que devuelve el servidor, sin tener en cuenta que hay cambios locales pendientes de confirmar.

Paso 5 · Confirmado: refrescarTareas hace almacen.actualizar({ tareas: delServidor }).

Paso 6 · Corregir. Tres soluciones posibles, y conviene ver por qué se elige una:

Solución Cómo Valoración
No refrescar mientras haya envíos en vuelo Bandera global Frágil; bloquea refrescos legítimos
Fusionar respetando lo pendiente El refresco no pisa las tareas con cambio en vuelo La correcta
Versionar y descartar lo viejo Cada tarea con version; se ignora lo anterior La más robusta, si la API la ofrece
export function fusionarConPendientes(delServidor, actuales, idsPendientes) {
  return delServidor.map((remota) =>
    idsPendientes.has(remota.id)
      ? actuales.find((t) => t.id === remota.id)   // conservar lo local optimista
      : remota
  );
}

Las tres lecciones generalizables:

  1. Un fallo intermitente casi siempre es una carrera. Cuando algo pasa «a veces», busca dos operaciones asíncronas que puedan terminar en distinto orden.
  2. Reproducir exige controlar el tiempo. Retrasar peticiones a propósito convierte «una de cada tres veces» en «siempre», y eso es lo que hace posible depurar.
  3. Cualquier estado que se sustituye entero es sospechoso. actualizar({ tareas: nuevas }) descarta información que puede ser más reciente. Sustituir siempre debe ser una decisión, no el camino por defecto.

  1. Fallo 3: la fuga de memoria al navegar

El síntoma. Tras navegar entre tablero, informe e historial durante un rato, la aplicación se vuelve lenta. Al principio no se nota; tras veinte navegaciones, cada cambio de pantalla tarda visiblemente más.

Paso 1 · Reproducir y medir, con el protocolo de 09-03:

  1. Abrir el panel Memory en modo incógnito.
  2. Instantánea 1 (línea base).
  3. Navegar 20 veces entre las tres pantallas.
  4. Forzar la recolección de basura (el icono de la papelera).
  5. Instantánea 2.
  6. Comparar por Objects allocated between snapshots.

El resultado:

Instantánea Heap Detached nodes TableroVista
1 (base) 12,4 MB 0 1
2 (tras 20 navegaciones) 38,9 MB 4.812 21

Veintiuna instancias de TableroVista vivas. Debería haber una. Los 4.812 nodos separados son el DOM de las veinte anteriores, retenido por algo.

Paso 2 · Encontrar el retenedor. En el panel Memory, seleccionar una instancia y mirar Retainers:

TableroVista
  └── contexto de suscriptor
        └── Set (suscriptores)
              └── Almacen

El almacén está reteniendo veinte funciones suscriptoras, cada una con su clausura sobre su vista y su DOM.

Paso 3 · La prueba que falla:

test('destruir la vista la da de baja del almacén', () => {
  const almacen = crearAlmacen(estadoInicial());
  const vista = crearTableroVista(document.createElement('div'), { almacen, alEmitir: () => {} });

  expect(almacen.numeroDeSuscriptores).toBe(1);
  vista.destruir();
  expect(almacen.numeroDeSuscriptores).toBe(0);        // ← FALLA: sigue en 1
});

test('navegar diez veces no acumula suscriptores', () => {
  const almacen = crearAlmacen(estadoInicial());
  for (let i = 0; i < 10; i++) {
    const v = crearTableroVista(document.createElement('div'), { almacen, alEmitir: () => {} });
    v.destruir();
  }
  expect(almacen.numeroDeSuscriptores).toBe(0);        // ← FALLA: 10
});

Paso 4 y 5 · La causa. El código era este:

// ❌ Se suscribe y tira la función de baja
almacen.suscribir((estado) => actualizar(estado));

function destruir() {
  contenedor.replaceChildren();      // limpia el DOM… pero la suscripción sigue viva
}

Paso 6 · Corregir, guardando todas las bajas:

const bajas = [];
bajas.push(almacen.suscribir((estado) => actualizar(estado)));
bajas.push(conectarControlador(contenedor, alEmitir));
bajas.push(canal.suscribir(alMensaje));

const observador = new IntersectionObserver(alVerse);
bajas.push(() => observador.disconnect());

const temporizador = setInterval(refrescar, 30_000);
bajas.push(() => clearInterval(temporizador));

function destruir() {
  bajas.forEach((baja) => baja());
  bajas.length = 0;
  contenedor.replaceChildren();
  ultimoEstado = null;               // soltar la referencia al estado
}

Las cuatro lecciones generalizables:

  1. Todo lo que se conecta debe desconectarse. Escuchas, suscripciones, temporizadores, observadores, sockets. El patrón «guardar la baja en un array» convierte destruir() en tres líneas siempre iguales.
  2. La fuga no se ve, se mide. Nadie detecta 26 MB mirando la pantalla. Sin el protocolo de tres instantáneas de 09-03, esto se descubre cuando un usuario dice «va lenta después de un rato».
  3. Es comprobable en una prueba unitaria. No hace falta el panel Memory para prevenir la reaparición: basta con exponer numeroDeSuscriptores y contar. Esa prueba tarda un milisegundo y corre en cada push.
  4. Añade el contador de navegaciones a tu lista de comprobación. Entrar y salir tres veces de cada pantalla, con el contador de escuchas a la vista, debería ser parte del cierre de cada incremento.

  1. Pruebas de accesibilidad: automáticas y manuales

Hay una cifra que conviene tener presente: las herramientas automáticas detectan alrededor de un tercio de los problemas reales de accesibilidad. Son imprescindibles y no son suficientes.

17.1 La parte automática: axe

npm install --save-dev jest-axe
// test/accesibilidad/pantallas.test.js
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

describe.each([
  ['tablero',   montarTablero],
  ['formulario', montarFormularioAbierto],
  ['informe',   montarInforme],
  ['historial', montarHistorial],
  ['calendario', montarCalendario],
  ['estado vacío', montarSinResultados],
  ['estado de error', montarConError]
])('Accesibilidad · %s', (nombre, montar) => {
  test('sin incidencias de axe', async () => {
    const { container } = montar();
    expect(await axe(container)).toHaveNoViolations();
  });
});

Los dos últimos casos —estado vacío y estado de error— son los que nadie prueba y donde más incidencias aparecen, porque se maquetan deprisa y sin revisar.

Y en Cypress, sobre la aplicación real (que sí calcula estilos, y por tanto sí detecta contraste):

// cypress/e2e/accesibilidad.cy.js
it('el tablero no tiene incidencias graves', () => {
  cy.visit('/');
  cy.injectAxe();
  cy.checkA11y(null, { includedImpacts: ['critical', 'serious'] });
});

17.2 La parte manual: los dos tercios restantes

Ninguna herramienta detecta que el orden de tabulación sea absurdo, que un texto alternativo diga «imagen1», o que el flujo sea imposible de seguir sin ver la pantalla. Eso se prueba a mano, con esta lista:

# Prueba Cómo Qué buscar Tiempo
1 Solo teclado Guarda el ratón, recorre toda la aplicación Todo alcanzable, foco visible, orden lógico, sin trampas 10 min
2 Foco en diálogos Abrir y cerrar todos Entra al abrir, vuelve al cerrar, Escape funciona 3 min
3 Lector de pantalla NVDA (Windows), VoiceOver (macOS), Orca (Linux) Con los ojos cerrados: ¿puedes crear una tarea? 20 min
4 Zoom al 200 % Ctrl y rueda Nada se corta, nada desaparece, sin barra horizontal 3 min
5 Solo texto ampliado Fuente del navegador a 200 % El diseño aguanta sin solaparse 3 min
6 Sin color Filtro de escala de grises del sistema ¿Distingues vencida, sobrecarga y estado? 2 min
7 Movimiento reducido prefers-reduced-motion activado Las animaciones se desactivan 2 min
8 Contraste DevTools sobre cada texto ≥ 4,5:1 normal, ≥ 3:1 grande e interfaz 5 min

Unos 50 minutos por revisión completa. Hazla al cerrar cada hito, no al final.

El punto 3 asusta la primera vez y es el que más enseña. Consejos para empezar: aprende cinco atajos (leer todo, siguiente elemento, siguiente encabezado, lista de enlaces, lista de encabezados), cierra los ojos de verdad, e intenta completar una tarea concreta. Lo que descubrirás casi seguro: que tus encabezados no forman una estructura navegable, que tus botones de icono no dicen nada, y que cuando algo cambia en pantalla no te enteras.

17.3 El informe de accesibilidad

Un entregable del hito, en docs/accesibilidad.md:

# Informe de accesibilidad — Órbita v1.0
Fecha: 2026-11-14 · Revisor: <tu nombre>

## Automático (axe 4.x)
| Pantalla | Críticas | Graves | Moderadas | Menores |
|---|---|---|---|---|
| Tablero | 0 | 0 | 1 | 2 |
| Formulario | 0 | 0 | 0 | 1 |
| Informe | 0 | 0 | 0 | 0 |
| Historial | 0 | 0 | 0 | 1 |
| Estado vacío | 0 | 0 | 0 | 0 |

## Manual
| Prueba | Resultado | Notas |
|---|---|---|
| Solo teclado | ✅ | Recorrido completo verificado |
| Foco en diálogos | ✅ | `<dialog>` nativo |
| Lector de pantalla (NVDA) | ⚠️ | La tabla del informe se lee lenta con muchas columnas |
| Zoom 200 % | ✅ | |
| Sin color | ✅ | Vencida y sobrecarga con texto |
| Contraste | ✅ | Mínimo medido: 4,8:1 |

## Limitaciones conocidas
- El calendario no está optimizado para lector de pantalla con más de 30 tareas/mes.
- No probado con lectores de pantalla móviles.

## Próximos pasos
- [ ] Añadir resumen textual del informe antes de la tabla
- [ ] Probar con TalkBack en Android

Ese documento, con sus limitaciones declaradas, vale más en una entrevista que una puntuación de 100 en Lighthouse. Demuestra que has mirado de verdad.

  1. Medir el rendimiento del proyecto propio

Aplica el protocolo de 09-01 a tu proyecto: mediana de 15 repeticiones tras 5 de calentamiento, CPU 4×, red Slow 4G, incógnito, misma máquina.

Genera datos realistas primero. Medir con seis tareas no dice nada:

// src/datos/semilla-grande.js — solo en desarrollo
export function generarCargaRealista({ tareas = 500, subtareasPor = 2, historial = 3000 } = {}) {
  // Distribución parecida a la real: 60 % pendientes, 25 % en curso, 15 % hechas
  // Fechas repartidas en 6 meses; 20 % sin fecha; 8 % vencidas
  // 4 usuarios, uno de ellos inactivo
}

Y la tabla de resultados, con la referencia de Nómada Tareas al lado:

# Medida Presupuesto (11-01) Órbita Nómada Tareas Veredicto
1 JS inicial comprimido ≤ 60 kB 54,1 kB 58,3 kB
2 Peticiones primera pantalla ≤ 4 4 3
3 LCP (móvil simulado) ≤ 2,5 s 2,1 s 1,9 s
4 CLS ≤ 0,1 0,03 0,02
5 INP al filtrar ≤ 200 ms 61 ms 42 ms
6 render() con 500 tareas ≤ 50 ms 44 ms 31 ms (600)
7 Nodos DOM ≤ 1.500 1.380 1.194
8 Memoria tras 3 ciclos ≈ 0 +0,4 MB sin fugas
9 Rendimiento Lighthouse ≥ 90 94

Cómo interpretar la comparación honestamente, que es la parte que importa:

  • Órbita es algo más pesada que Nómada Tareas, y debe serlo. Tiene tres pantallas más, árbol de subtareas, historial y cola de sincronización. Estar dentro del presupuesto con más funcionalidad es el resultado correcto.
  • Estar por debajo del presupuesto no es motivo para relajarse: es margen para lo que venga. Anota el margen: 5,9 kB de JavaScript y 400 ms de LCP.
  • Si estás fuera, la tabla te dice dónde mirar. Cada fila tiene su lección del Módulo 9: render() alto → 09-04; JS alto → 09-05; memoria → 09-03; INP → 09-02.

Y una regla contra el autoengaño: mide en modo incógnito, sin extensiones y sin el servidor de desarrollo. Vite en desarrollo sirve módulos sin empaquetar, y medir ahí da números que no tienen nada que ver con producción. Se mide sobre npm run build y npm run preview.

  1. Las pruebas intermitentes

Una prueba intermitente (o flaky) pasa unas veces y falla otras sin que cambie el código. Son el peor enemigo de una suite, por una razón concreta: destruyen la confianza. En cuanto la gente empieza a relanzar la CI «a ver si esta vez pasa», las pruebas dejan de ser una señal y pasan a ser ruido.

Las causas, con su solución:

Causa Síntoma típico Solución
Dependencia del tiempo real Falla los lunes, o a fin de mes jest.setSystemTime(); inyectar hoy
Esperas fijas cy.wait(500) que a veces no basta Esperar por condición: cy.findByText(...)
Orden entre pruebas Pasa sola, falla en grupo Limpiar en beforeEach; no compartir estado
Asincronía sin esperar Falla en máquinas lentas await de verdad; findBy* en vez de getBy*
Aleatoriedad Falla una de cada veinte Sustituir Math.random y crypto.randomUUID
Animaciones El elemento «no es visible» prefers-reduced-motion en pruebas; desactivar transiciones
Concurrencia Falla solo en CI Ejecutar en serie lo que comparte recurso
Recursos externos Falla si la red va mal Simular; nunca llamar a un servicio real

El protocolo cuando aparece una intermitente:

  1. Nunca la relances y sigas. Ese es el hábito que arruina las suites.
  2. Etiquétala y ejecútala en bucle para medir la frecuencia:
    npx jest --testNamePattern="nombre" --runInBand --repeat=50
    
  3. Aísla la causa con la tabla de arriba. Casi siempre es tiempo o estado compartido.
  4. Corrige la causa, no el síntoma. Añadir cy.wait(2000) no arregla nada: hace la prueba más lenta y sigue fallando en una máquina más lenta.
  5. Si no puedes arreglarla hoy, márcala con test.skip y abre una incidencia con fecha. Una prueba desactivada y anotada es honesta; una prueba intermitente activa es corrosiva.

Y la regla de higiene: ejecuta la suite completa cinco veces seguidas antes de dar por bueno un hito. Si las cinco pasan, tienes una suite fiable. Si falla una, tienes una intermitente que ya conoces — y es mucho mejor conocerla ahora que un viernes durante un despliegue.

Errores Comunes y Consejos

Probar la implementación en lugar del comportamiento. Una prueba que comprueba que se llamó a un método interno se rompe con cada refactorización sin detectar ningún fallo real. Prueba lo que entra y lo que sale, o lo que ve el usuario. La regla práctica: si al reorganizar el código por dentro, sin cambiar el comportamiento, se rompen pruebas, esas pruebas estaban mal.

Perseguir el 100 % de cobertura. Los últimos puntos porcentuales suelen ser ramas defensivas que nunca se ejecutan, y forzarlos produce pruebas artificiales que no aportan confianza. Sube el listón donde el riesgo es alto —dominio y migraciones— y acepta menos donde el coste es alto y el riesgo bajo.

Escribir veinte pruebas de extremo a extremo. Son lentas, frágiles e intermitentes. Con veinte, la CI tarda cinco minutos y alguien acabará desactivándolas. Tres bien elegidas dan casi la misma confianza sistémica a una fracción del coste.

querySelector por clase CSS en las pruebas de vista. Acopla la prueba a la maquetación y no verifica accesibilidad. getByRole hace las dos cosas bien y falla cuando tu HTML no es semántico, que es información valiosa.

No limpiar el estado entre pruebas. localStorage, temporizadores falsos, dobles de fetch y el DOM se arrastran de una prueba a otra y producen fallos que dependen del orden. beforeEach que limpia todo, siempre.

Usar advanceTimersByTime con código asíncrono. No vacía la cola de microtareas y la prueba se cuelga o falla sin explicación. advanceTimersByTimeAsync y await.

Corregir un fallo sin escribir antes la prueba que falla. Pierdes las cuatro ventajas: la demostración de que lo entendiste, el criterio de terminación, la protección contra la reaparición y la documentación del caso raro. Es el hábito que más separa a quien depura con método de quien toca cosas a ver.

Depurar un fallo intermitente sin hacerlo determinista primero. «A veces pasa» no se puede depurar. Controla el tiempo, las respuestas y la aleatoriedad hasta que pase siempre; entonces empieza a investigar.

Relanzar la CI hasta que pase. Cada vez que lo haces, tu suite pierde valor. Trata cada intermitente como un fallo real, porque suele serlo: la carrera que produce el fallo en CI existe también en producción.

Creer que axe basta. Detecta alrededor de un tercio de los problemas. Un teclado y veinte minutos con un lector de pantalla encuentran cosas que ninguna herramienta ve.

Consejo · Ejecuta las pruebas en modo vigilancia mientras programas. npm run test:watch con Jest ejecuta solo lo afectado por lo que acabas de guardar. El ciclo de retroalimentación baja de minutos a menos de un segundo, y eso cambia cómo trabajas.

Consejo · Escribe el nombre de la prueba como una frase que describa el comportamiento. 'rechaza cerrar una tarea con subtareas abiertas', no 'test cambiarEstado 3'. Cuando falle en CI dentro de tres meses, el nombre será toda la información que tengas al principio.

Consejo · Mantén una carpeta test/ayudas/ cuidada. Fábricas de entidades, montaje de la aplicación, fetch simulado, generadores de datos. Cada minuto invertido ahí se multiplica por el número de pruebas que escribirás después.

Consejo · Añade el número de pruebas y la cobertura al README. «251 pruebas · 3 recorridos E2E · 94 % de cobertura en el dominio» es una señal inmediata de seriedad para quien mire tu repositorio, y a ti te recuerda mantenerlo.

Ejercicios

Estos ejercicios son el hito H5 de tu proyecto: la suite completa en verde en CI y el informe de accesibilidad y rendimiento.

Ejercicio 1 — La estrategia y la suite completa.

  1. Escribe docs/estrategia-pruebas.md con: las tres preguntas del apartado 1 respondidas para tu proyecto, la tabla de la pirámide con tus módulos y sus números, y los umbrales de cobertura por capa con su justificación.
  2. Configura Jest con proyectos separados: node para dominio y jsdom para el resto, con coverageThreshold por ruta.
  3. Completa la tabla de cobertura de reglas: las 15 reglas × (caso que pasa, caso que falla, caso límite). Mínimo 45 pruebas de dominio.
  4. Escribe pruebas parametrizadas con describe.each para al menos: la validación de campos, la matriz de transiciones completa y los casos de fecha (incluida la semana 53).
  5. Prueba la capa de datos con fetch simulado y temporizadores falsos: códigos de estado, reintentos selectivos, tiempo agotado, cancelación y retroceso exponencial verificado con tiempos.
  6. Prueba las migraciones (mínimo 9 pruebas, incluidas idempotencia, validez para el dominio y la de rendimiento) y la cola (mínimo 6, incluida la clave de idempotencia compartida entre reintentos).
  7. Prueba la vista con Testing Library consultando siempre por rol o texto accesible, incluidas al menos tres pruebas de recorrido con teclado (entrada de foco, retorno de foco, sin escape).
  8. Escribe exactamente tres recorridos de Cypress y justifica por escrito por qué esos tres.

Ejercicio 2 — La integración continua completa.

  1. Escribe .github/workflows/ci.yml con los tres trabajos: calidad, extremo a extremo y Lighthouse, con concurrency, caché de npm, needs y subida de artefactos.
  2. Configura lighthouserc.json con tu presupuesto de 11-01 traducido a aserciones, distinguiendo error de warn.
  3. Activa la protección de la rama main exigiendo los tres trabajos.
  4. Provoca un fallo a propósito de cada tipo y comprueba que la CI lo detecta: un error de lint, una prueba rota, una regresión de cobertura por debajo del umbral, y un aumento de tamaño del paquete por encima del presupuesto (importa una librería grande y quítala después).
  5. Documenta en el README el estado de la CI con su insignia.

Ejercicio 3 — Los tres fallos, el informe y la línea base.

  1. Reproduce los tres fallos de los apartados 14, 15 y 16 en tu propio proyecto. Si alguno no se da de forma natural, provócalo: quita la normalización de una migración, retrasa artificialmente un PUT, o elimina una baja de suscripción.
  2. Para cada uno, documenta en docs/depuracion.md: síntoma, procedimiento de reproducción, caso mínimo, prueba que falla, hipótesis (incluidas las descartadas), causa raíz y corrección.
  3. Deja las tres pruebas de regresión en la suite.
  4. Ejecuta la revisión de accesibilidad completa: axe automatizado sobre las 7 pantallas (incluidos estado vacío y de error) y las 8 pruebas manuales. Escribe docs/accesibilidad.md con el formato del apartado 17.3, incluidas las limitaciones conocidas.
  5. Genera datos realistas (≥ 500 tareas, ≥ 3.000 entradas de historial) y mide tu línea base con el protocolo de 09-01 sobre la compilación de producción. Escribe docs/rendimiento.md con la tabla de 9 filas comparando presupuesto, tu resultado y Nómada Tareas.
  6. Ejecuta la suite completa cinco veces seguidas. Si alguna falla, encuentra y arregla la intermitente antes de dar el hito por cerrado.

Soluciones

Criterios de aceptación del ejercicio 1 — Suite

# Criterio Cómo se comprueba
1 Las pruebas de dominio corren sin jsdom testEnvironment: 'node' y todas pasan
2 Las 15 reglas tienen sus 3 casos Tabla completa, sin huecos
3 Los límites numéricos exactos están probados 40 y 40.1 aparecen en la suite
4 La semana 53 está probada Prueba explícita del 1 de enero
5 Los reintentos son selectivos Prueba que cuenta llamadas para 500 y para 400
6 Los tiempos de retroceso se verifican Con temporizadores falsos
7 La idempotencia se prueba Misma clave en dos intentos
8 La vista se consulta por rol grep -r "querySelector" test/vista/ no devuelve nada relevante
9 Hay ≥ 3 pruebas de teclado Entrada, retorno y atrapado de foco
10 Hay exactamente 3 recorridos E2E Con justificación escrita
11 Cobertura de dominio ≥ 95 % npm run test:cov
12 Cobertura de migraciones = 100 % Ídem

Rúbrica del ejercicio 1 (27 puntos)

Dimensión 0 1 2 3
Estrategia documentada No existe Lista de pruebas Con niveles Con las 3 preguntas y umbrales justificados
Cobertura de reglas < 8 reglas 12 reglas Las 15 Las 15 con límites exactos
Parametrización Todo repetido Alguna tabla Tablas donde toca Las tablas se leen como especificación
Dobles Ninguno fetch simulado Además temporizadores Además Date, crypto y contador de llamadas
Migraciones Sin probar Camino feliz Con datos reales Con idempotencia, validez y rendimiento
Pruebas de vista Por CSS Por texto Por rol Por rol y verificando accesibilidad
Teclado No 1 prueba 3 pruebas Recorrido completo sin ratón
E2E 0 o > 5 3 sin justificar 3 justificados 3 que cubren los 3 riesgos sistémicos
Fiabilidad Intermitentes conocidas Alguna anotada Suite estable 5 ejecuciones seguidas en verde

Umbral: 19/27, con obligatoriamente ≥ 2 en «Cobertura de reglas» y en «Fiabilidad».

Criterios de aceptación del ejercicio 2 — CI

# Criterio Cómo se comprueba
1 La CI se dispara en cada PR y en main Ver ejecuciones en la pestaña Actions
2 Un error de lint la pone en rojo Provocarlo
3 Una prueba rota la pone en rojo Provocarlo
4 Bajar la cobertura la pone en rojo Borrar una prueba del dominio
5 Superar el presupuesto de bytes la pone en rojo Importar una librería grande
6 Los artefactos se suben también al fallar if: always() verificado
7 Los trabajos lentos dependen del rápido needs: presente
8 La caché funciona Segunda ejecución claramente más rápida
9 main está protegida No se puede fusionar en rojo
10 El tiempo total es razonable < 5 minutos

Criterios de aceptación del ejercicio 3 — Depuración e informes

# Criterio Cómo se comprueba
1 Los tres fallos están documentados docs/depuracion.md con las siete secciones cada uno
2 Cada uno tiene su prueba de regresión Están en la suite y fallan si se revierte la corrección
3 Se documentan las hipótesis descartadas No solo la acertada: el proceso importa
4 axe sobre 7 pantallas, 0 críticas y 0 graves Informe con la tabla
5 Las 8 pruebas manuales están hechas Con resultado y notas, no solo marcadas
6 El lector de pantalla se usó de verdad La sección de notas lo demuestra
7 Hay limitaciones declaradas Un informe sin limitaciones es sospechoso
8 La línea base se midió sobre producción npm run build + preview, no dev
9 Los datos eran realistas ≥ 500 tareas
10 La comparación con Nómada Tareas está interpretada No solo la tabla: qué significa cada diferencia
11 Todas las filas dentro del presupuesto, o justificadas Con plan de corrección si alguna falla
12 Cinco ejecuciones seguidas en verde Sin intermitentes

Rúbrica global del hito H5 (24 puntos)

Dimensión Peso Qué se evalúa
Estrategia y niveles 4 Documentada, justificada, aplicada con coherencia
Cobertura de reglas 5 Las 15 con sus tres casos y sus límites exactos
Capa de datos 4 Dobles, temporizadores, migraciones, cola, idempotencia
Vista y teclado 4 Por rol, recorrido completo, destruir verificado
CI 3 Los tres trabajos, presupuestos activos, rama protegida
Depuración 2 Los tres fallos con método y regresión
Accesibilidad y rendimiento 2 Los dos informes con limitaciones declaradas

Umbral: 17/24. Y una condición adicional que no se compensa: la suite tiene que estar en verde cinco veces seguidas. Una suite intermitente no está terminada, por muchas pruebas que tenga.

Autoevaluación del hito H5:

Pregunta Sí / No
¿Sabría decir, para cada regla, en qué fichero está su prueba?
¿Mis pruebas de vista siguen pasando si cambio todas las clases CSS?
¿He usado un lector de pantalla con mi propia aplicación?
¿Mi CI se pone en rojo si el paquete crece 30 kB?
¿He escrito la prueba antes de corregir los tres fallos?
¿He ejecutado la suite cinco veces seguidas sin un solo fallo?
¿Mi línea base está medida sobre la compilación de producción?

Conclusión

Has convertido «funciona en mi máquina» en «funciona, y hay una máquina que lo comprueba en cada push».

Tienes una estrategia de pruebas construida sobre tres preguntas —qué no puede fallar nunca, qué cambia a menudo, y cuánto cuesta y cuánto vale cada prueba— y sobre una regla de decisión que resuelve casi todos los casos: prueba en el nivel más bajo que capte el fallo que te preocupa. Tienes la pirámide traducida a tus ficheros concretos, con unas 250 pruebas de las que solo tres son de extremo a extremo, y con una observación que es un diagnóstico gratuito: si tuvieras más pruebas de vista que de dominio, tu lógica estaría en el sitio equivocado.

Sabes usar la cobertura sin dejarte engañar por ella: dice qué no está probado, no si lo probado está bien probado. Por eso tus umbrales son por capa y justificados —95 % en dominio, 100 % en migraciones, 65 % en vista— y por eso la métrica que de verdad gobierna no es el porcentaje sino la tabla de reglas × tres casos: quince reglas, cuarenta y cinco pruebas mínimas, y ningún hueco que se pueda aprobar sin verificar nada.

Sabes probar el dominio con tablas parametrizadas que se leen como especificación, con los límites exactos donde viven los fallos —40 y 40.1, no 20—, con los casos de tipo que se cuelan por las validaciones ingenuas —'12' y NaN—, comprobando error.campo y no solo el tipo de error, y con las tres familias de casos límite que todo el mundo olvida: numéricos, de colección y temporales, incluida la semana 53 que rompe casi todas las implementaciones caseras de semana ISO.

Sabes probar la capa de datos sustituyendo solo lo lento, lo externo y lo no determinista: fetch simulado que falla ante llamadas inesperadas, temporizadores falsos con advanceTimersByTimeAsync —y no la versión síncrona, que deja las microtareas sin vaciar—, reloj fijado, y aserciones sobre el número de llamadas, que es lo que distingue una prueba de reintentos real de una que pasaría igual con el reintento roto. Con las migraciones probadas contra documentos reales, incluida la prueba de rendimiento que evita que una migración lenta arruine el LCP, y con la cola probada en lo que es imposible verificar a mano: que el reenvío usa la misma clave de idempotencia.

Sabes probar la vista como la usa una persona: por rol y por texto accesible, nunca por clase CSS, con el doble beneficio de sobrevivir a los cambios de maquetación y de verificar accesibilidad de propina. Y sabes probar el recorrido con teclado, que casi ningún proyecto tiene, cubriendo los tres fallos de foco clásicos —no entrar, no volver, escaparse—, que con <dialog> nativo pasan casi solos.

Tienes tres recorridos de extremo a extremo elegidos con un criterio explícito —los que, si se rompieran, dejarían el producto inservible—, cubriendo el ciclo de vida completo con R13 aplicada de verdad, el estado compartido en la URL con el informe, y la resiliencia sin conexión con la comprobación del duplicado. Y sabes por qué no veinte: una suite de extremo a extremo desactivada vale cero, así que el número correcto es el máximo que puedes mantener siempre en verde.

Tienes la integración continua completa y comentada línea a línea: concurrency que cancela lo obsoleto, caché de npm, npm ci en vez de install, artefactos que se suben también cuando falla —que es justo cuando los necesitas—, trabajos lentos que dependen del rápido, y la protección de rama que convierte la recomendación en garantía. Con Lighthouse CI haciendo cumplir tu presupuesto de 11-01 traducido a aserciones, tres ejecuciones por el ruido, error solo donde firmaste, y la línea de 61.440 bytes que impide que un día entre una librería de gráficos sin que nadie se entere.

Y sabes depurar tu propio proyecto con un método de seis pasos cuyo tercer paso —escribir la prueba que falla antes de corregir— es el que casi todo el mundo se salta y el que da cuatro beneficios de una vez. Lo has aplicado a tres fallos reales: la migración que corrompía datos por emparejar nombres con distinta normalización Unicode, con sus tres lecciones —emparejar por identificador, no fallar en silencio, y que el respaldo salvó la investigación—; la condición de carrera en la que un refresco en vuelo pisaba una actualización optimista, donde reproducir exigió controlar el tiempo para convertir «una de cada tres veces» en «siempre»; y la fuga de memoria de veintiuna vistas vivas retenidas por suscripciones sin baja, que no se ve pero se mide, y que se previene para siempre con una prueba unitaria de un milisegundo.

Sabes que las herramientas automáticas de accesibilidad detectan alrededor de un tercio de los problemas, y tienes las dos mitades: axe sobre las siete pantallas —incluidos el estado vacío y el de error, que nadie prueba y donde más incidencias hay— y las ocho pruebas manuales de cincuenta minutos, con el lector de pantalla y los ojos cerrados como la que más enseña. Con un informe que declara sus limitaciones, que vale más que un 100 de Lighthouse.

Tienes tu línea base medida sobre la compilación de producción, con datos realistas, comparada con Nómada Tareas fila a fila, y sabes interpretarla: ser algo más pesado con más funcionalidad es correcto, estar por debajo del presupuesto es margen y no permiso, y cada fila fuera de rango apunta a su lección del Módulo 9.

Y sabes tratar las pruebas intermitentes como lo que son: fallos reales que destruyen la confianza en la suite. Con las ocho causas y sus soluciones, el protocolo de no relanzar nunca, y la regla de higiene de cinco ejecuciones seguidas antes de cerrar un hito.

El hito H5 está cerrado. Tu proyecto funciona, está probado, se mide y se vigila solo. Le falta lo único que convierte un repositorio en un producto: que otras personas puedan usarlo. Compilar para producción sin filtrar un solo secreto, elegir dónde alojarlo, configurar caché, seguridad y HTTPS, desplegar de forma continua con posibilidad de revertir, y vigilarlo cuando ya no está en tu máquina, es Despliegue del Proyecto.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados