¿Alguna vez has visto cy.click() ponerse en verde en el command log (elemento encontrado, visible, no tapado, evento despachado, Cypress completamente satisfecho) mientras la aplicación en pantalla no hacía absolutamente nada? ¡A todos nos ha pasado!
Este es más traicionero que un selector incorrecto, porque el log sigue en verde hasta la aserción. Así que empiezas a dudar de la aserción, luego del timing, luego de los datos de prueba, luego del framework, más o menos en ese orden. El clic queda para el final. Debería ir primero.
Me sigo encontrando con cuatro causas para esto, más una quinta muy breve. Así que sentemos al clic en el banquillo, una causa a la vez, con lo que de verdad funciona en cada una.
El componente solo escucha a humanos
1. El componente comprueba event.isTrusted
El clic por defecto de Cypress es un evento sintético, despachado desde JavaScript. Y los navegadores lo notan: sintético → isTrusted: false, entrada real del usuario → true. Cualquier componente que se proteja con esa comprobación te ignorará, educadamente y en silencio:
function onBackdropClick(e) {
if (!e.isTrusted) return
close()
}
¿De dónde sale un guard así? De componentes que tuvieron que defenderse de eventos disparándose en bucle, de librerías que se blindan contra la entrada generada por scripts y (mi favorito) de código donde alguien lo añadió para arreglar un bug y nunca documentó por qué. No hay forma de discutir con él desde dentro de la página. Sintético es sintético.
Así que deja de despachar eventos y empieza a controlar el navegador. cypress-real-events envía la entrada a través del Chrome DevTools Protocol, así que es el propio navegador quien genera el evento, y es trusted:
cy.get('[data-cy="save"]').realClick()
Lo mismo con realHover, realType y realPress. Ahora bien, por tentador que sea, no lo esparzas por todas partes por reflejo. Mi regla: usa .click() por defecto y recurre a .realClick() en cuanto un clic sea un no-op confirmado, solo en ese elemento. El inconveniente: se salta las comprobaciones de actionability de Cypress y solo funciona en navegadores basados en Chromium.
La celda que se iluminó y mintió
2. El objetivo es un canvas que hace su propio hit-testing
Las tablas tipo hoja de cálculo, las librerías de gráficos y los editores WebGL o canvas no tienen elementos DOM para sus celdas. Tienen un único <canvas> y un mapa interno de coordenadas a celdas. No hay nada que seleccionar, y trigger('mousedown') sobre el elemento canvas no lleva a ninguna parte útil: estos componentes hacen hit-testing a partir de las coordenadas del evento y a menudo dependen de la secuencia completa y trusted de eventos de puntero, no de un único evento que puedas falsificar.
El síntoma es curiosamente específico. La interacción parece haber funcionado (una celda se resalta) y luego lo que necesitabas simplemente no ocurre. En un componente de hoja de cálculo con el que me peleé, fue así:
Clic en una celda → queda seleccionada visualmente → la barra de fórmulas nunca recibe una celda de destino → cada paso posterior opera sobre nada → ni un solo error.
Parece seleccionada. No lo está.
Dos cosas te sacan del apuro:
Haz clic por coordenadas, de verdad. Calcula la posición relativa a la esquina superior izquierda del canvas, que es lo que esperan las opciones x y y de realClick, y lanza ahí un clic por CDP:
function realClickCell(row: number, col: number) {
cy.get('canvas.grid-canvas').realClick({
x: HEADER_W + col * CELL_W + CELL_W / 2,
y: HEADER_H + row * CELL_H + CELL_H / 2,
})
}
Después, verifica el estado interno, no los píxeles. Todo el problema era que el feedback visual mentía. Si el componente expone un objeto api, haz la aserción sobre él («la celda activa es B4») y detectas el no-op silencioso en el momento en que ocurre, no tres pasos después.
Y ya que estás ahí, cuidado con los offsets hardcodeados. Cualquier helper con un +3 columnas o +2 filas mágico incrustado para compensar los paneles inmovilizados fallará sin avisar en cuanto se ejecute en algún lugar sin esos paneles: un diálogo, una vista previa, una vista de drill-down. Si de verdad necesitas el offset, conviértelo en un parámetro con nombre y un valor por defecto, no en un literal en medio de la aritmética.
Dirección equivocada, sin salida 🚪
3. El overlay se renderiza mediante un portal en otra parte del DOM
Los desplegables, selectores de fecha, multi-selects y tooltips suelen renderizarse en un contenedor al final de <body>, no dentro del componente que los abrió. Así que esto:
cy.get('[data-cy="region-select"]').click().find('.option').contains('EMEA').click()
falla, porque las opciones no son descendientes del select. En su lugar, acota la búsqueda al contenedor del overlay:
cy.get('[data-cy="region-select"]').click()
cy.get('.cdk-overlay-container').contains('.option', 'EMEA').click()
Y luego está cerrarlos. Me topé con un overlay de multi-select que no había forma legítima de cerrar:
- hacer clic en el backdrop → ignorado (un guard de
isTrusted, hola de nuevo) {esc}→ se propagó más allá del overlay y cerró en su lugar el diálogo padrerealClicksobre el backdrop → cayó en una región transparente que se tragó el evento
La selección ya se había guardado con el cambio, así que el estado que de verdad me importaba era correcto antes de cerrar. La respuesta pragmática fue eliminar el overlay y su backdrop del DOM. Un diálogo padre construido sobre el CDK tiene su propio pane y su propio backdrop en el mismo contenedor, así que quédate con el último de cada uno. Pertenecen al overlay abierto más recientemente:
cy.get('.cdk-overlay-pane').last().invoke('remove')
cy.get('.cdk-overlay-backdrop').last().invoke('remove')
¿Es un hack? Sí. Por eso quiero ser cuidadoso con cómo lo recomiendo, porque es un trade-off real: ya no estás probando que el overlay se cierra. Recurre a ello solo cuando el comportamiento bajo prueba ya esté verificado y el overlay estorbe para el siguiente paso, y deja una nota en el spec indicando a qué comportamiento renunciaste. La alternativa, borrar el test o dejarlo en rojo para siempre, verifica estrictamente menos. Y abre el bug: un panel que un usuario real puede cerrar y una herramienta automatizada no suele estar a un guard de isTrusted de ser también un problema de accesibilidad.
El elemento que todavía no está ahí
4. La lista está virtualizada, así que el elemento no está ahí
Listas largas de miembros, combo boxes con miles de entradas, selectores virtualizados: solo la ventana visible existe en el DOM, exactamente igual que en una tabla de datos virtualizada. Dos hábitos se rompen de inmediato:
Hacer clic por posición → .eq(12) apunta al decimotercer elemento renderizado, que no es el decimotercer elemento. En cuanto la lista hace scroll, o la altura de fila cambia, estás haciendo clic en otra cosa. Y la altura de los elementos varía de verdad entre entornos con distintas configuraciones de densidad o fuentes.
Hacer clic en .active → el elemento resaltado es el último que tocó el cursor de teclado del componente. No es tu elemento.
Así que filtra primero y luego selecciona por contenido:
function selectMember(filter: string, match: string | RegExp = filter) {
cy.get('[data-cy="member-filter"]').clear().type(filter)
cy.get('.member-list').contains('.member-item', match).click()
}
Escribir en el filtro es lo que hace que el elemento exista. Luego haz el match por texto, y usa una regex cuando necesites una coincidencia exacta entre nombres donde uno es subcadena de otro. V3 y V3.1 en la misma lista te van a jugar una mala pasada, porque contains se queda con la primera coincidencia, que puede no ser la que querías.
Dos más, rápido. Si tienes que hacer scroll en lugar de filtrar, usa la api de «scroll into view» del propio componente, no offsets en píxeles. Y cuando la lista tenga etiquetas duplicadas, deja de intentar identificar filas solo por la etiqueta: usa un par como clave, como la etiqueta más un badge o un chip de tipo, y haz la aserción sobre el par.
Bonus: el botón que cambia de opinión
5. El botón disparador cambió tras la primera apertura (una breve)
Sutil, pero puede doler. Algunas barras de herramientas mutan sus propios controles una vez que hay un panel abierto: el botón «más acciones» se convierte en un botón «eliminar», o un floating action button cambia su icono y su handler.
Así que un helper que «abre el panel» funciona la primera vez. Segunda llamada → hace clic en una acción completamente distinta. Si tienes suerte, falla. Si no, borra algo.
Ábrelo una vez y mantenlo abierto durante los pasos que lo necesiten. Si un flujo de verdad necesita una segunda apertura, escribe un helper aparte que apunte al control mutado por su nombre. No hagas un único helper lo bastante «listo» para manejar ambos casos, o habrás escondido la trampa en lugar de eliminarla.
Básicamente, con todo esto en su lugar, un clic en verde significa que el clic de verdad llegó. Y cuando no llegó, el spec se pone en rojo justo en ese paso, no tres pasos después en una aserción que nunca fue el problema.
¡Felices pruebas!
