Durante ocho módulos, TaskFlow ha crecido componente a componente —<task-card>, <task-list>, <task-board>, <user-avatar>, <task-filter>— comprobando cada pieza nueva a base de recargar el navegador y mirar el resultado a simple vista. Es una forma perfectamente razonable de aprender cada concepto sobre la marcha, pero no escala: nadie quiere volver a hacer clic manualmente en el selector de estado de media docena de tarjetas cada vez que se toca una línea de código de <task-card>, para comprobar que nada se ha roto. Esta lección presenta @web/test-runner, la herramienta que el propio equipo de Lit recomienda para escribir pruebas automatizadas de Web Components, y la usa para poner los primeros tests reales sobre <task-card>.
Contenido
- Por qué las herramientas de testing centradas en Node quedan cortas
@web/test-runner: ejecutar los tests en navegadores reales- Instalación y configuración mínima
- Anatomía de un test:
describe,it,fixture,expect - Acceder al Shadow DOM desde un test
- Primer test:
<task-card>renderiza el título correcto - Segundo test: la insignia de estado cambia según la propiedad
- Esperar actualizaciones asíncronas dentro de un test
- Ejecutar la batería de pruebas
- Por qué las herramientas de testing centradas en Node quedan cortas
La forma más habitual de ejecutar pruebas unitarias de JavaScript, con herramientas como Jest, consiste en correr el código directamente sobre Node.js, sin abrir ningún navegador real. Para probar código que manipula el DOM, esas herramientas suelen apoyarse en jsdom, una implementación de las APIs del DOM escrita en JavaScript puro, capaz de simular un documento HTML sin necesidad de un navegador de verdad.
Esa simulación funciona razonablemente bien para HTML y JavaScript convencionales, pero se queda corta precisamente en los dos pilares sobre los que se sostiene todo este curso: el Shadow DOM y los Custom Elements. jsdom implementa ambas APIs de forma parcial y, en algunos aspectos (el comportamiento exacto de <slot> y la distribución de contenido, el ciclo de vida completo de un elemento personalizado al conectarse y desconectarse del documento, o detalles finos de cómo el navegador aplica estilos encapsulados dentro de un shadow root), su comportamiento diverge del de un navegador real de forma sutil pero suficiente para producir falsos positivos o falsos negativos en un test: código que pasa el test en jsdom pero falla en un navegador real, o al revés.
| Aspecto | jsdom (simulado en Node) | Navegador real |
|---|---|---|
Custom Elements (customElements.define) |
Soporte parcial, con diferencias de comportamiento en casos concretos | Implementación nativa completa |
Shadow DOM y <slot> |
Soporte parcial, especialmente en distribución de contenido y estilos | Implementación nativa completa |
| Velocidad de arranque | Muy rápida, sin abrir ningún proceso de navegador | Algo más lenta, al depender de un navegador real |
| Fiabilidad para Web Components | Riesgo de falsos positivos/negativos en comportamiento específico de la plataforma | Máxima: es el mismo entorno donde el componente se va a ejecutar de verdad |
Por este motivo, la documentación oficial de Lit no recomienda Jest con jsdom como primera opción para probar componentes, y en su lugar señala directamente a @web/test-runner, una herramienta del mismo ecosistema de Open Web Components que ejecuta los tests dentro de navegadores reales (Chromium, Firefox o WebKit, según se configure), eliminando de raíz cualquier divergencia entre lo que el test comprueba y lo que un usuario real experimentaría.
@web/test-runner: ejecutar los tests en navegadores reales
@web/test-runner: ejecutar los tests en navegadores reales@web/test-runner funciona, en líneas generales, así: toma los ficheros de test escritos en JavaScript (módulos ES normales, sin ninguna transformación previa necesaria), los sirve mediante un pequeño servidor de desarrollo, y los ejecuta dentro de una instancia real de un navegador, controlada mediante Playwright por debajo. El resultado de cada aserción se recoge de vuelta y se muestra en la terminal, exactamente como con cualquier otro framework de testing, pero con la garantía añadida de que cada test se ha ejecutado contra una implementación nativa completa de Custom Elements y Shadow DOM.
Esta forma de trabajar tiene una consecuencia práctica importante para TaskFlow: los tests que se escriban en este módulo no necesitan ningún tipo de simulación ni de parche para que <task-card> "funcione como si estuviera en un navegador"; están ejecutándose realmente dentro de uno, con el mismo customElements.define, el mismo attachShadow y el mismo motor de renderizado que ya se ha usado durante todo el curso al abrir index.html con Vite.
- Instalación y configuración mínima
Para empezar a usar @web/test-runner en el proyecto de TaskFlow, hace falta instalarlo junto con @open-wc/testing, un paquete complementario que aporta utilidades pensadas específicamente para Web Components (fixture, html y una versión ampliada de expect, que se detallan en el apartado siguiente):
Una configuración mínima, en un fichero web-test-runner.config.js en la raíz del proyecto, basta para arrancar:
files indica el patrón de ficheros donde viven los tests (por convención, dentro de un directorio test/, con el sufijo .test.js); nodeResolve: true permite que los propios ficheros de test importen paquetes instalados en node_modules (como lit o @open-wc/testing) con la misma sintaxis de import habitual, resolviendo esos módulos de la misma forma que Vite ya hace durante el desarrollo normal de TaskFlow.
- Anatomía de un test:
describe, it, fixture, expect
describe, it, fixture, expectUn test típico de @web/test-runner combina, por un lado, describe e it, la pareja de funciones estándar del formato Mocha/BDD que organiza los tests en grupos y casos individuales (una convención compartida con la práctica totalidad de frameworks de testing de JavaScript, no exclusiva de esta herramienta), y, por otro, dos utilidades propias de @open-wc/testing pensadas específicamente para Web Components: fixture y la etiqueta de plantilla html.
import { fixture, html, expect } from '@open-wc/testing';
import '../src/components/task-card.js';
describe('task-card', () => {
it('se registra como elemento personalizado', async () => {
const el = await fixture(html`<task-card></task-card>`);
expect(el).to.exist;
});
});fixture(html\crea una instancia real de, la inserta en el documento de prueba y **espera a que Lit complete su primera actualización** antes de devolver el elemento ya listo para inspeccionar; es, en esencia, el equivalente en un test de escribir enindex.htmly esperar a que la página termine de renderizarse. La etiquetahtmlde@open-wc/testingno tiene relación directa con la etiquetahtmlde Lit usada enrender()` durante todo el curso: es una plantilla de propósito general para describir HTML en un test, aunque comparta el mismo aspecto sintáctico (comillas invertidas con interpolaciones) por comodidad y familiaridad.
expect, importado también de @open-wc/testing, aporta un estilo de aserciones encadenadas (expect(valor).to.equal(...), expect(valor).to.exist, expect(valor).to.be.true) heredado de la librería Chai, muy extendida en el ecosistema de JavaScript y elegida por el propio Open Web Components como estándar para sus utilidades de testing.
- Acceder al Shadow DOM desde un test
El elemento que devuelve fixture es la instancia real del componente, con su Shadow DOM ya construido; para inspeccionar lo que <task-card> ha renderizado realmente dentro de su <article>, hace falta atravesar esa frontera exactamente igual que se explicó en el módulo 4 a propósito del encapsulamiento de estilos: a través de el.shadowRoot.
const el = await fixture(html`<task-card titulo="Revisar el PR"></task-card>`);
const titulo = el.shadowRoot.querySelector('h3');
expect(titulo.textContent).to.equal('Revisar el PR');el.shadowRoot.querySelector('h3') busca, dentro del shadow root del componente (no en el documento principal, donde un querySelector normal no encontraría nada, exactamente por el mismo motivo de encapsulamiento explicado en la lección "CSS Encapsulado con Shadow DOM"), el primer elemento <h3>, que es justamente donde render() interpola this.titulo. Este patrón —el.shadowRoot.querySelector(...), seguido de una aserción sobre textContent, sobre alguna clase CSS, o sobre la presencia o ausencia de un nodo— es la base de la práctica totalidad de los tests que se escriben en este módulo para los componentes de TaskFlow.
- Primer test:
<task-card> renderiza el título correcto
<task-card> renderiza el título correctoCon las piezas ya explicadas, el primer test real de <task-card> queda así:
// test/task-card.test.js
import { fixture, html, expect } from '@open-wc/testing';
import '../src/components/task-card.js';
describe('task-card', () => {
it('renderiza el título recibido como propiedad', async () => {
const el = await fixture(
html`<task-card titulo="Preparar la demo del sprint"></task-card>`
);
const h3 = el.shadowRoot.querySelector('h3');
expect(h3).to.exist;
expect(h3.textContent).to.equal('Preparar la demo del sprint');
});
it('usa el título por defecto si no se le pasa ninguno', async () => {
const el = await fixture(html`<task-card></task-card>`);
const h3 = el.shadowRoot.querySelector('h3');
expect(h3.textContent).to.equal('Tarea sin título');
});
});El segundo caso comprueba, de paso, algo que ya se estableció desde el módulo 3: el valor por defecto asignado en el constructor de TaskCard (this.titulo = 'Tarea sin título') cuando no se pasa ningún atributo titulo. Escribir ambos casos como tests independientes, en lugar de uno solo, es deliberado: cada it describe un único comportamiento esperado, y si en el futuro alguno de los dos deja de cumplirse, el nombre del test que falla («usa el título por defecto si no se le pasa ninguno») señala de inmediato qué comportamiento concreto se ha roto, sin tener que leer el cuerpo del test para averiguarlo.
- Segundo test: la insignia de estado cambia según la propiedad
renderInsigniaEstado(), el método de <task-card> presentado en la lección "Renderizado Condicional", decide qué insignia mostrar según el valor de this.estado. Es un candidato perfecto para una prueba parametrizada, que comprueba varios valores de entrada sin repetir la estructura del test:
// test/task-card.test.js (continuación)
describe('task-card: insignia de estado', () => {
const casos = [
{ estado: 'pendiente', textoEsperado: 'Pendiente' },
{ estado: 'en-progreso', textoEsperado: 'En progreso' },
{ estado: 'hecha', textoEsperado: 'Hecha' },
];
casos.forEach(({ estado, textoEsperado }) => {
it(`muestra "${textoEsperado}" cuando estado es "${estado}"`, async () => {
const el = await fixture(html`<task-card estado="${estado}"></task-card>`);
const insignia = el.shadowRoot.querySelector('.insignia');
expect(insignia.textContent).to.include(textoEsperado);
});
});
});El array casos recoge las tres combinaciones válidas de estado junto con el fragmento de texto que se espera encontrar dentro de la insignia; el forEach genera un it independiente por cada una, de forma que un fallo en un solo caso (por ejemplo, si alguien cambia el texto de "En progreso" a "En curso" sin actualizar el test) señala exactamente cuál de los tres estados ha dejado de comportarse como se esperaba, en lugar de un único test genérico que solo diría "algo en la insignia ha fallado". expect(...).to.include(...), en lugar de to.equal(...), se usa aquí porque renderInsigniaEstado() antepone un icono (✓, ◐, ○) al texto, y el test solo necesita comprobar que el texto relevante está presente, no el carácter exacto que lo acompaña.
- Esperar actualizaciones asíncronas dentro de un test
Hasta ahora, todos los tests han comprobado el estado de <task-card> justo después de fixture(...), que ya espera a la primera actualización. Pero algunos comportamientos, como el <select> de estado explicado en la lección "Eventos Personalizados", cambian el estado del componente después de que ya esté renderizado, en respuesta a una interacción simulada:
it('actualiza la insignia al cambiar el selector de estado', async () => {
const el = await fixture(html`<task-card estado="pendiente"></task-card>`);
const selector = el.shadowRoot.querySelector('select');
selector.value = 'hecha';
selector.dispatchEvent(new Event('change'));
await el.updateComplete;
const insignia = el.shadowRoot.querySelector('.insignia');
expect(insignia.textContent).to.include('Hecha');
});Aquí aparece el.updateComplete, la misma promesa presentada en la lección "Hooks Reactivos" del módulo 6: tras disparar el evento change sobre el <select> (que provoca, en cascada, que gestionarCambioDeSelector asigne this.estado = 'hecha'), el test necesita esperar explícitamente a que Lit termine de procesar esa actualización antes de inspeccionar de nuevo el Shadow DOM. Sin ese await el.updateComplete, la aserción se ejecutaría demasiado pronto —potencialmente antes de que render() haya vuelto a ejecutarse— y el test podría fallar de forma intermitente, dependiendo de una diferencia de tiempos de apenas milisegundos.
- Ejecutar la batería de pruebas
Con los tests ya escritos, un script en package.json permite lanzarlos desde la línea de comandos:
@web/test-runner arranca entonces un navegador (Chromium, por defecto, si no se ha configurado ninguno específico), carga cada fichero de test que coincida con el patrón test/**/*.test.js, y muestra en la terminal un resumen de cuántos it han pasado y cuántos han fallado, con el mensaje de la aserción correspondiente en cada caso de fallo. Añadiendo la opción --watch (web-test-runner --watch), la herramienta vuelve a ejecutar los tests automáticamente cada vez que se guarda un cambio en el código, un flujo de trabajo cómodo mientras se sigue desarrollando <task-card> o cualquier otro componente de TaskFlow en paralelo a sus pruebas.
Errores Comunes y Consejos
- Probar Web Components con Jest y jsdom sin ser consciente de sus límites: como se ha explicado en el apartado 1, jsdom puede ocultar problemas reales de Shadow DOM o de Custom Elements que solo aparecerían en un navegador real; si un equipo ya usa Jest para el resto de su código JavaScript, sigue siendo razonable mantener
@web/test-runnerespecíficamente para los componentes de interfaz. - Olvidar
awaitantes defixture(...):fixturedevuelve una promesa que se resuelve solo cuando el componente ya ha completado su primera actualización; sinawait, el test recibiría la propia promesa en lugar del elemento, y cualquierel.shadowRootposterior fallaría con un error de tipo, no con un fallo de aserción claro. - Consultar
document.querySelectoren lugar deel.shadowRoot.querySelector: exactamente el mismo error de encapsulamiento explicado en el módulo 4; unquerySelectorsobre el documento principal nunca encuentra elementos que viven dentro del shadow root de un componente, y el test fallaría con un error de "elemento no encontrado" que puede confundirse, a primera vista, con un fallo real del propio componente. - No esperar
updateCompletetras simular una interacción: como se ha visto en el apartado 8, cualquier cambio que dispare una actualización asíncrona de Lit (una propiedad, un evento que la modifique) necesita eseawaitantes de inspeccionar el resultado; omitirlo produce tests intermitentes, que a veces pasan y a veces fallan según el tiempo exacto de ejecución, uno de los tipos de error más difíciles de diagnosticar en cualquier suite de pruebas.
Ejercicios
- Escribe un test para
<task-card>que compruebe que, al hacer clic en el<article>(simulando el clic conel.shadowRoot.querySelector('article').click()), aparece dentro del shadow root un elemento con la clase.detalle; recuerda esperarel.updateCompletedespués del clic, ya quealternarExpandidacambia un estado interno reactivo. - Escribe un test que compruebe que
<task-card>, al recibirprioridad="5"como atributo, muestra el texto "Prioridad: 5" en algún lugar de su shadow root (pista: puedes comprobarlo conel.shadowRoot.textContenty.to.include(...), sin necesidad de localizar un selector exacto). - Un compañero de equipo propone escribir un test que compruebe directamente
el.estado === 'hecha'tras simular el cambio del<select>, en lugar de inspeccionar el contenido de la insignia como en el apartado 8. Explica qué diferencia hay entre ambos enfoques en términos de qué parte del comportamiento del componente queda realmente verificada.
Soluciones
it('muestra el detalle expandido al hacer clic en la tarjeta', async () => {
const el = await fixture(html`<task-card titulo="Tarea de prueba"></task-card>`);
el.shadowRoot.querySelector('article').click();
await el.updateComplete;
const detalle = el.shadowRoot.querySelector('.detalle');
expect(detalle).to.exist;
});it('muestra la prioridad recibida como atributo', async () => {
const el = await fixture(html`<task-card prioridad="5"></task-card>`);
expect(el.shadowRoot.textContent).to.include('Prioridad: 5');
});- Comprobar
el.estado === 'hecha'verifica únicamente que la propiedad JavaScript del componente ha cambiado correctamente, es decir, que la lógica interna degestionarCambioDeSelectorfunciona; no verifica, en cambio, que ese cambio se haya traducido en algo visible para quien usa la tarjeta, que es, en última instancia, lo que le importa a un usuario real y lo querenderInsigniaEstado()debería garantizar. Inspeccionar el contenido de la insignia, como en el apartado 8, comprueba el comportamiento de extremo a extremo —desde la interacción simulada hasta el resultado visual en el Shadow DOM—, y es preferible en la mayoría de los casos porque un test que solo mirara la propiedad interna podría seguir pasando aunquerenderInsigniaEstado()tuviera un error y mostrara siempre el mismo texto, algo que un test centrado en el DOM detectaría de inmediato.
Conclusión
En esta lección se ha introducido @web/test-runner como la herramienta recomendada para probar Web Components, precisamente porque ejecuta cada test dentro de un navegador real en lugar de una simulación parcial como jsdom, evitando así falsos positivos y negativos en comportamientos propios de Shadow DOM y Custom Elements. Con fixture, html y expect de @open-wc/testing, y con el patrón de acceder al Shadow DOM mediante el.shadowRoot.querySelector(...), <task-card> cuenta ya con una primera batería de tests que comprueba su título, su insignia de estado y su comportamiento tras una interacción simulada, esperando siempre updateComplete cuando hace falta.
Las pruebas escritas en esta lección comprueban que <task-card> hace lo que debe hacer, pero no dicen nada sobre si lo hace de forma accesible: si alguien que navega solo con teclado, o con un lector de pantalla, puede expandir una tarjeta o cambiar su estado con la misma facilidad que alguien que usa el ratón. La siguiente lección, "Accesibilidad en Web Components", revisa <task-card> y <task-filter> desde ese ángulo, con roles ARIA, gestión del foco y actualizaciones anunciadas dinámicamente.
Curso de Lit
Módulo 1: Introducción a Lit y Web Components
- ¿Qué son los Web Components y por qué Lit?
- Configuración del Entorno de Desarrollo
- Tu Primer Componente Lit
- Anatomía de un Componente Lit
Módulo 2: Plantillas Reactivas y Renderizado
- El Motor de Plantillas de Lit
- Expresiones e Interpolación en Plantillas
- Renderizado Condicional
- Renderizado de Listas
- El Ciclo de Renderizado
Módulo 3: Propiedades y Estado Reactivo
- Propiedades Reactivas
- Estado Interno con @state
- Tipos de Propiedades y Conversores Personalizados
- Atributos vs Propiedades y Reflexión
Módulo 4: Estilos en Componentes Lit
- CSS Encapsulado con Shadow DOM
- Estilos Compartidos entre Componentes
- Variables CSS Personalizadas y Theming
- Slots y Estilizado de Contenido Distribuido
Módulo 5: Eventos y Comunicación entre Componentes
- Manejo de Eventos DOM en Plantillas
- Eventos Personalizados: Comunicación Hijo a Padre
- Comunicación de Padre a Hijo con Propiedades
- Patrones de Comunicación entre Componentes Hermanos
Módulo 6: Ciclo de Vida y Comportamiento Avanzado
- Callbacks del Ciclo de Vida
- Hooks Reactivos: willUpdate, updated y firstUpdated
- Controladores Reactivos
- Mixins y Composición de Comportamiento
Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas
- Directivas Incorporadas: classMap, styleMap e ifDefined
- Directivas Personalizadas
- Renderizado Asíncrono con until
- Contexto Compartido con @lit/context
Módulo 8: Integración, Interoperabilidad y Despliegue
- Usar Componentes Lit en HTML Plano
- Integrar Lit con React, Vue y Angular
- Renderizado en el Servidor con @lit-labs/ssr
- Empaquetado, Publicación y TypeScript
Módulo 9: Pruebas y Buenas Prácticas
- Pruebas Unitarias con Web Test Runner
- Accesibilidad en Web Components
- Rendimiento y Optimización
- Patrones y Anti-patrones Comunes
