Managing random dynamic elements in Cypress

En ocasiones podemos encontrarnos con elementos que pueden (o no) aparecer en el flujo de nuestra aplicación, pero que no son el objeto de nuestra prueba. Un ejemplo de ello puede ser el popup que nos solicita aceptar o denegar las cookies.

Una posible solución para gestionar este problema puede ser forjar una cookie en el hook beforeEach() que haga que nuestro servidor detecte que las cookies ya han sido aceptadas, pero esto puede llevarnos a encontrarnos otros problemas (como que Cloudfare, si es el caso, identifique el acceso como un potencial ataque de forgery y nos devuelva un error 403)

Una solución más universal consiste en utilizar un snippet en JavaScript que genere un temporizador que compruebe la presencia de un elemento cada cierto tiempo y, en caso de que aparezca, ejecute una acción y descarte el temporizador.

Dado que no estamos "exigiendo" la presencia de un elemento mediante un cy.get(), la interacción únicamente se producirá en paralelo a la prueba en caso de que dicho elemento aparezca.

Quede claro que esta técnica es invasiva: estamos alterando el comportamiento original de la página, por lo que es necesario estudiar el caso de uso en el que utilizarlo y asegurarnos de que no afecta al escenario. Úsese con precaución.

Comprobando en segundo plano

El truco es simple: declaramos un intervalo que, en cada ejecución, compruebe si el botón existe y, en caso afirmativo, lo pulse y elimine el intervalo haciendo que no se vuelva a ejecutar. En caso de que el elemento no aparezca, se volverá a ejecutar pasados poolingTimeMs milisegundos.

La pulsación del botón hará que las cookies se acepten, haciendo que el popup desaparezca y no se vuelva a mostrar hasta que las cookies se eliminen.

function tryToClickButtonUntilFound(
  buttonLocator: string,
  poolingIntervalMs: number
) {
  return new Cypress.Promise((resolve) => {
    const interval = setInterval(() => {
      const button = Cypress.$(buttonLocator);
      if (button.length) {
        button.trigger("click");
        clearInterval(interval);
        resolve("Button clicked");
      }
    }, poolingIntervalMs);
  });
}

Configuración del hook

Para que nuestro snippet se ejecute en cada prueba, basta con invocarlo desde el hook before(), dentro de nuestro fichero e2e.js / e2e.ts:

before(): {
  tryToClickButtonUntilFound("accept-cookies-button", 3000);
}

Ejemplo

Vamos a crear un ejemplo real para ilustrar esta técnica. Crearemos un escenario en el que el usuario navega a una página que puede (o no) mostrar un popup para aceptar las cookies.

Vamos a servirnos nuevamente de https://the-internet.herokuapp.com, ya que dispone de un ejemplo de popup gestionado por cookies. Haremos que nuestra prueba acceda aleatoriamente a esta página o, en su defecto, a otra página que no muestre el popup, cerrando el popup en caso de que éste aparezca pero no fallando en caso de que el popup nunca aparezca. https://the-internet.herokuapp.com again, since it has an example of a popup managed by cookies. We will make our test randomly access this page or, failing that, another page that does not show the popup, closing the popup in case it appears but not failing in case the popup never appears.

Feature: Element that can be present or not is dismissed
    Scenario: Popup button is clicked if it appears, but it's not clicked otherwise
        Given the user navigates to a page that may show a popup or not
        When the user waits for 3 seconds
        Then the modal popup is not present in the page

Implementamos el paso que navega aleatoriamente entre dos páginas:

import { Given, When, Then } from "@badeball/cypress-cucumber-preprocessor";

const ENTRY_AD_URL = "entry_ad";
const FLOATING_MENU_URL = "floating_menu";

const locators = {
  modal: "#modal:visible",
};

Given("the user navigates to a page that may show a popup or not", () => {
  cy.visit(Math.random() > 0.5 ? ENTRY_AD_URL : FLOATING_MENU_URL);
});

Añadimos un paso que fuerce una espera explícita para, en caso de que exista un popup, pueda mostrarse en pantalla. Esta espera explícita se añade en el ejemplo para simular el tiempo que la prueba tomaría para seguir ejecutándose. Recuerda que en un entorno real, hay que evitar las esperas explícitas a toda costa:

When("the user waits for {int} seconds", (seconds: number) => {
  cy.log(`Waiting ${seconds} seconds...`);
  cy.wait(Math.ceil(seconds * 1000));
  cy.log(`Finished waiting`);
});

Finalmente, comprobamos que el Popup no existe:

Then("the modal popup is not present in the page", () => {
  cy.get(locators.modal).should("not.exist");
});

Para que el pooling se ejecute antes de cada prueba, añadimos la función al hook before() en el fichero e2e.ts:

const COOKIE_POPUP_BUTTON_LOCATOR = "#modal:visible .modal-footer p";

function tryToClickButtonUntilFound(
  buttonLocator: string,
  poolingIntervalMs: number
) {
  return new Cypress.Promise((resolve) => {
    const intervalId = setInterval(() => {
      const button = Cypress.$(buttonLocator);
      if (button.length) {
        button.trigger("click");
        clearInterval(intervalId);
        resolve("Button clicked");
      }
    }, poolingIntervalMs);
  });
}

before(() => {
  tryToClickButtonUntilFound(COOKIE_POPUP_BUTTON_LOCATOR, 2000);
});

Si el popup no se muestra, la prueba continúa sin más:

En caso contrario, el callback de nuestro interval se encargará de cerrar el popup y el test continuará:

Puedes encontrar el ejemplo de uso de esta técnica aquí.

Bruno: una genial alternativa open-source a Postman

Postman lleva años siendo la herramienta por defecto a la hora de desarrollar y probar APIs. Sin embargo, salvo que dispongamos de la versión de pago, las funcionalidades que la herramienta ofrece a la comunidad de forma gratuita van reduciéndose poco a poco, y la reciente obligatoriedad de subir nuestras colecciones entornos a su nube ha hecho que mucha gente se plantee otras herramientas alternativas.

Bruno es una de esas alternativas que cumple con muchos de los requisitos que cualquier desarrollador y/o probador de APIs le pide a una herramienta de este tipo:

  • Trabaja offline (las colecciones y los entornos se almacenan en el sistema de archivos)
  • Su interfaz es muy similar a la de Postman
  • Ofrece un CLI similar a Newman
  • Adicionalmente, añade una capa de extensibilidad que hará las delicias de cualquier desarrollador.

Instalación

La instalación de Bruno no puede ser más sencilla. Basta con acudir a su repositorio de GitHub y descargarnos la versión deseada. Tenemos clientes para Windows, Linux y Mac, por lo que podremos hacer uso de ella en cualquier plataforma.

Una vez instalado o descomprimido, ejecutamos el binario asociado y nos encontraremos una interfaz que nos resultará bastante familiar, salvo por la presencia de un simpático perrito como mascota de la aplicación:

Colecciones

Crear una colección es tan simple como pulsar en el botón [+ Create Collection] Esto nos mostrará un popup en el que cumplimentaremos la siguiente información:

  • Nombre de la colección
  • Ruta en la que almacenaremos la colección (todos los datos se almacenan en el sistema de ficheros)
  • Nombre de la carpeta en la que almacenaremos la colección

El hecho de que la colección se almacene como un árbol de directorios tiene otras ventajas, como la posibilidad de crear un repositorio compartido con nuestros compañeros en el que almacenar nuestras colecciones.

Dentro de nuestra colección también podemos crear carpetas. En nuestra colección, pulsamos el botón [···] y seleccionamos la opción [New Folder] .

Ahora le asignamos el nombre que queramos

Y con esto, nuestra carpeta estará lista para crear una petición. Para ello, seleccionamos la carpeta o la colección en la que queremos crear nuestra petición y pulsamos [···] y, a continuación, [New Request].

Bruno nos permite realizar dos tipos de peticiones: HTTP o GraphQL. Seleccionamos el tipo que queramos, le asignamos un nombre y rellenamos el tipo de petición y la URL en la que se encuentra el endpoint.

Por supuesto, nuestras peticiones pueden también estar almacenadas en variables, y la sintaxis es similar a la de Postman, encerrando las variables entre llaves dobles ({{variable}}):

Entornos

Al crear la petición, vemos que las variables aparecen resaltadas en color rojo. Esto se debe a que las variables no existen. Para ello, crearemos un entorno en el que las guardaremos, y para ello acudiremos al desplegable que se encuentra en la esquina superior derecha:

Seleccionamos la opción [Configure].

Tenemos la opción de crear un nuevo entorno o de importar un entorno existente. Optaremos por crearlo, seleccionando [Create Environment]:

Le asignamos un nombre a nuestro entorno:

Y finalmente, creamos nuestras variables y les asignamos valor.

Hecho esto, las variables aparecerán en color verde y el entorno seleccionado se mostrará seleccionado en el desplegable de la esquina superior derecha.

Peticiones

El proceso de codificar peticiones es muy similar al que utilizábamos en Postman: dependiendo del tipo de petición podemos configurar los parámetros en la query o en el body. Y pulsando la flecha [->] junto a la URL de la petición, lanzaremos la consulta a nuestro endpoint y obtendremos el resultado en la parte derecha de la pantalla. Como vemos, la interfaz es muy familiar:

Al enviar una petición POST podemos editar el cuerpo de la petición y seleccionar su formato (Multipart, URLEncoded, JSON, text, ...). También podemos editar las cabeceras, al igual que haríamos con Postman.

Variables

Como funcionalidad interesante, Bruno nos permite asignar variables antes o después de la petición sin necesidad de codificarlo explícitamente en un script. Por ejemplo, imaginemos que tenemos un endpoint en el que accedemos a la información de un registro pasándole el id como parte de la ruta, es decir:

Esta variable se muestra en color rojo, ya que no ha sido declarada. Queremos que su valor sea el id devuelto por la operación POST que inserta un registro y devuelve la información almacenada en la base de datos.

¿Cómo podemos hacer esto? A través de la variable global res.

Respuestas

La variable global res nos permite acceder a la respuesta en los scripts post-response, asserts y tests. Para almacenar en una variable un elemento recuperado en una respuesta, seleccionaremos la pestaña [Vars] y en la sección [Post response] , añadiremos en la tabla el nombre de la variable y el valor que debe adquirir. En este caso, queremos que la variable "id" se rellene con el campo "id" obtenido en el cuerpo de la respuesta, por lo que la expresión que asignará valor a la variable será "res.body.id“:

Probemos ahora a ejecutar la petición POST que inserta un registro. Vemos que el id con el que el registro se ha almacenado en base de datos es 796:

Si ahora volvemos a abrir nuestra petición "users", podremos comprobar que la variable {{id}} tiene ahora color verde. Es más, si colocamos el puntero del ratón sobre la variable, Bruno nos mostrará su valor, que coincide con el devuelto en la petición anterior (796):

Autenticación

Otra de las características que ofrece Bruno es la de configurar la autenticación de las peticiones. Al igual que en Postman, es posible utilizar variables para, por ejemplo, almacenar un bearer token y utilizarlo posteriormente en la pestaña [Auth] para autenticar nuestras peticiones.

Asserts

Otra de las características de Bruno es la posibilidad de utilizar aserciones en las peticiones, de modo que no tengamos que codificar pruebas específicas para ello.

Las aserciones son útiles para realizar comprobaciones atómicas, como el estado de una respuesta, la presencia de un campo o cuando esperamos un valor concreto. Los asserts se ejecutan tras obtener la respuesta, y para ello basta con abrir la pestaña [Assert] y completar la tabla.

  • Expr: expresión a comprobar
  • Operator: operador a aplicar a la expresión
  • Value: valor esperado

Las aserciones evalúan la expresión y comprueban que, tras aplicar el operador correspondiente, obtiene el valor esperado.

Tests

Si has manejado Postman con anterioridad, esta pestaña te será familiar. En esta sección podremos codificar pruebas en Javascript, permitiéndonos jugar con los datos tal y como estábamos acostumbrados. No obstante, a diferencia de Postman, los tests están pensados únicamente para codificar pruebas, no para código post-request. Ciertamente, la codificación de esas tareas en esta sección era algo que personalmente no me gustaba demasiado, ya que obligaba al usuario a mezclar dos tareas distintas.

Therefore, if you need to perform tasks that have nothing to do with testing after the execution of a request, it is advisable to do it in its corresponding section ([Script] > [Post Response]).

Bruno también ofrece la variable global "bru", que actúa de forma similar a como "pm" funciona en Postman ofreciendo una interfaz para interactuar con el entorno. La documentación de la herramienta ofrece un listado de métodos y funciones disponibles, como leer y guardar variables y otras tareas similares.

Collection Runner

Una de las funcionalidades que Postman limitó hace tiempo fue la posiblidad de ejecutar colecciones o carpetas completas. Bruno permite realizarlo sin restricción alguna, y en dos sabores diferentes:

  • Ejecutar una colección: ejecuta las peticiones de la carpeta actual, pero no las de las subcarpetas
  • Ejecutar una colección de forma recursiva: ejecuta todas las peticiones de la carpeta actual y las de sus subcarpetas

Hacer uso de esta funcionalidad es sencillo: basta pulsar el botón [···] de una carpeta y seleccionar la opción [Run] . Esto abrirá un popup que nos permitirá realizar una de estas dos operaciones:

El resultado de la ejecución se mostrará en la pestaña "Runner":

Bola extra: extensibilidad

Dejo para el final una de las funcionalidades de Bruno que más me han encantado: su extensibilidad.

Bruno nos permite utilizar cualquier paquete npm dentro del entorno. Sí, CUALQUIER paquete npm. Para ello, es tan sencillo como acceder a la carpeta en la que se encuentra nuestra colección e inicializar un proyecto npm o crear un fichero package.json :

Una vez tenemos nuestro proyecto inicializado, instalaremos paquetes como si de un proyecto node se tratase. Por ejemplo, instalaremos el paquete @faker-js/faker para mockear datos en nuestras peticiones:

Finalmente, dentro de la pestaña [Script] importaremos y utilizaremos los paquetes que hemos importado de manera normal. En este caso, la variable global "req" nos permite interactuar con la petición antes de su envío.

Además de todo esto, es posible editar el fichero con la configuración de la colección, bruno.json y añadir soporte para funcionalidades como acceso al sistema de ficheros o añadir a la lista blanca paquetes como form-data:

{
  "version": "1",
  "name": "MyCollection",
  "type": "collection",
  "scripts": {
    "moduleWhitelist": ["form-data"],
    "filesystemAccess": {
      "allow": true
    }
  }
}

En esta discusión de GitHub puedes encontrar algunos trucos y snippets sobre cómo realizar magia con esta maravillosa herramienta, como subir archivos o implementar un autenticador OAuth2 automático antes de ejecutar una petición. ¡Merece la pena probarlo!

Más información:

Localizadores CSS avanzados para Cypress (u otras herramientas de automatización)

Idealmente, cada elemento a localizar en nuestras pruebas debería contener un atributo específico que lo identificara de forma unívoca, como por ejemplo data-testid o data-cy. Sin embargo, a veces es posible que no podamos añadir estos atributos, como cuando se utilizan componentes de terceros.

Por ello, no viene de más estar familiarizados con la localización de elementos mediante otras formas. La más común en Cypress es a través de CSS locators.

En este artículo no voy a entrar en detalles sobre cómo localizar elementos a través de su tagname o su clase, ya que hay cientos de artículos sobre cómo hacerlo, como por ejemplo este o este. En vez de eso, comentaré de un par de estrategias que nos permitirán seleccionar elementos de la forma más explícita posible en situaciones menos típicas.

Valores de atributos parciales.

Sea el siguiente código HTML:

<header>
  <nav>
    <ul>
      <li data-testid="header-menu-home-option">
          <span>Home</span>
      </li>
      <li data-testid="header-menu-cart-option">
          <span>Cart</span>
      </li>
      <li data-testid="header-menu-aboutus-option">
          <div>
              <span>About Us</span>
          </div>
      </li>
    </ul>
  </nav>
<header>
<main>
  <h1>This is my page</h1>
  <div>
    <span>Hello!</span>
  </div>
</main>

Cada elemento contiene un data-testid, por lo que es posible seleccionar cada uno de ellos de forma unívoca. No obstante, es posible que necesitemos seleccionar todos los list items utilizando un único selector para realizar algún tipo de comprobación (por ejemplo, comprobar que existen tres elementos en el menú). Para ello, podemos utilizar los valores de los atributos data-testid utilizando aproximaciones parciales. Esto es:

Elementos cuyo valor contenga un texto concreto:

cy.get('[data-testid*="-menu-"]');

Elementos cuyo valor comience por un texto determinado:

cy.get('[data-testid^="header-menu-"]');

Elementos cuyo valor finalice por un texto determinado:

cy.get('[data-testid$="-option"]');

Y la opción más restrictiva: elementos cuyo valor comience por un texto determinado y finalice por otro texto determinado:

cy.get('[data-testid^="header-menu-"][data-testid$="-option"]');

Este último selector nos devolverá todos aquellos elementos que empiecen por "header-menu-" y terminen por "-option".

const expectedItems = 3;
cy.get('[data-testid^="header-menu-"][data-testid$="-option"]').should('have.length', expectedItems);

Descendientes directos (hijos)

CSS nos ofrece una serie de selectores que nos permiten referenciar elementos que sean hijos de otros elementos o que compartan su mismo padre.

Así, para obtener todos los elementos <span> que sean hijos directos de <li>, utilizaríamos el siguiente selector:

cy.get('li > span');

Este selector devolverá únicamente los nodos span que sean hijos directos de un nodo li, es decir:

<span>Home</span>
<span>Cart</span>

El <span> que contiene el texto "About Us" no sería devuelto por este localizador, ya que su padre es un <div>, no un <li>. Nótese la diferencia con el siguiente selector:

 cy.get('li span');

En este caso obtendríamos todos los que tengan algún ancestro (padre, abuelo, bisabuelo, ...) que sea un li. Por lo tanto, nos devolvería lo siguiente:

<span>Home</span>
<span>Cart</span>
<span>About Us</span>

 También podemos seleccionar elementos que sean nietos de otros elementos concatenando el operador '>' con el comodín '*'. Por ejemplo, el siguiente selector devolverá los tags <ul> que sean nietos de un tag <header> :

 cy.get('header >*> ul');

Elementos consecutivos (hermanos)

También podemos seleccionar elementos que compartan un mismo nodo padre, es decir, que sean hermanos. Para hacer esto haremos uso de los operadores '+' y '~‘.

 El operador '+' nos devolverá el siguiente hermano del elemento anterior (y sólo el siguiente hermano). Así, el siguiente selector:

 cy.get('[data-testid="header-menu-home-option"] + li');

Nos devolverá el primer hermano del li cuyo data-testid es "header-menu-home-option", es decir: 

<li data-testid="header-menu-cart-option">
  <span>Cart</span>
</li>

Si lo que queremos es obtener todos los hermanos que se encuentren después de un elemento determinado, utilizaremos el selector '~‘:

 cy.get('[data-testid="header-menu-home-option"] + li');

El selector nos devolverá todos los hermanos del primer li, es decir:

<li data-testid="header-menu-cart-option">
  <span>Cart</span>
</li>
<li data-testid="header-menu-aboutus-option">
  <div>
    <span>About Us</span>
  </div>
</li>

Es importante tener en cuenta que sólo obtendremos los hermanos que se encuentran a continuación del elemento anterior a '~‘, so if there were any other <li> element before it, it would not be retrieved using this selector.

Elementos que contienen otros elementos. :has()

Uno de los grandes problemas que nos encontramos a la hora de localizar elementos con CSS es la imposibilidad de navegar en sentido ascendente, es decir, obtener el padre de un elemento. Si bien XPath nos ofrece la posibilidad de hacer esto directamente, CSS nos permite también hacerlo mediante un pequeño truco: indicando en el selector que queremos seleccionar un elemento que contenga otro elemento. Por ejemplo, si queremos obtener aquellos <li> que contengan un <span>, utilizaremos el operador :has() para indicárselo:

 cy.get('li:has(span)');

Esto devolverá lo siguiente:

<li data-testid="header-menu-home-option">
  <span>Home</span>
</li>
<li data-testid="header-menu-cart-option">
  <span>Cart</span>
</li>
<li data-testid="header-menu-aboutus-option">
  <div>
    <span>About Us</span>
  </div>
</li>

¡Ojo! Podemos aprovecharnos del filtrado de hijos y hermanos y realizar queries más complejas, como por ejemplo, aquellos <li> que tengan un <span> como descendiente directo (child):

 cy.get('li:has(> span)');

En este caso, el resultado omitirá el último <li>, que tiene un <div> como hijo directo en lugar de un <span>

<li data-testid="header-menu-home-option">
  <span>Home</span>
</li>
<li data-testid="header-menu-cart-option">
  <span>Cart</span>
</li>

Elementos que no cumplen una condición. :not()

El El operador :not() es también un buen aliado a la hora de seleccionar elementos por descarte. Así, el siguiente selector nos permitirá localizar todos los <li> excepto "About Us":

 cy.get('[data-testid^="header-menu-"][data-testid$="-option"]:not([data-testid*="-aboutus-"])');

Elementos que contienen un texto. :contains()

Mediante la pseudo-clase :contains() es posible localizar elementos que contengan un texto, bien directamente o en uno de sus descendientes. Por ejemplo, el siguiente selector:

cy.get('*:contains("Ho")');

devolverá los elementos header, nav, ul, li y span corresponde al siguiente subárbol:

<header>
  <nav>
    <ul>
      <li data-testid="header-menu-home-option">
        <span>Home</span>

También podemos refinar la búsqueda especificando otras opciones:

cy.get('span:contains("Ho")');

Lo que devolverá únicamente el elemento span

Nota:: :contains() es case-sensitive

Elementos que cumplen una condición entre varias

Como última sugerencia, podemos crear un selector CSS que actúe como un OR lógico concatenando otros selectores con comas (,). Por ejemplo:

 cy.get('span:contains("Home"),span:contains("Cart")');

Este selector devolverá aquellos elementos span que contenga bien la cadena "Homeo la cadena "Cart". Esta técnica es muy útil para escenarios en los que dos elementos diferentes pueden mostrarse ante una misma acción.

Más información:

Creación proyecto en Cypress con Typescript y Cucumber

Lo reconozco: desde que descubrí Cypress, la automatización de pruebas no ha vuelto a ser lo mismo. Si bien mis orígenes en este campo se remontan a la combinación de Java, Gradle, TestNG, Selenium y Jenkins, hace ya tiempo que migré a campos más verdes con Typescript, GitHub Actions, Cypress y Cucumber. La gestión de localización de elementos de Cypress me conquistó desde el principio, por lo que no tardé en comenzar a remar hacia esta tecnología.

Si bien Cypress no proporciona soporte directo para trabajar con BDD, sí que existe una serie de paquetes que le dotan de esta versatilidad, haciendo que sea posible escribir escenarios de prueba utilizando un lenguaje natural y ejecutarlos directamente en Cypress. Esta sinergia facilita la comunicación entre los diferentes roles y mejora la visibilidad de las pruebas en todo el equipo, lo que a su vez fomenta la transparencia y el entendimiento común de los requisitos y las funcionalidades de la aplicación. En resumen: que permite que el Product Owner y otros miembros del equipo (con perfil técnico o sin él) entiendan qué funcionalidades están cubriendo las pruebas.

En este artículo vamos a aprender cómo configurar el esqueleto de un pequeño proyecto que hará uso de las siguientes tecnologías para automatizar las pruebas de una web:

  • Typescript
  • Cypress
  • Cucumber

Para ello, utilizaremos https://the-internet.herokuapp.com como objetivo de nuestras pruebas, ya que nos ofrece una serie de funcionalidades típicas en el mundo de la automatización.

Prerrequisitos

Para llevar a cabo este proyecto, necesitaremos lo siguiente:

  • Una cuenta de GitHub .
  • Un IDE de desarrollo. Personalmente, recomiendo Visual Studio Code.
  • NodeJS + npm instalado en nuestro equipo. Si aún no lo usas, recomiendo nvm para gestionar las versiones de NodeJS.

Creación del proyecto en GitHub

Nuestro primer paso será acceder a nuestra cuenta de GitHub y crear un nuevo repositorio. Le daremos un nombre representativo, una breve descripción, le añadiremos un README y le indicaremos "Node" como plantilla de .gitignore.

Con nuestro proyecto creado, es hora de clonarlo en nuestro IDE. Para ello, desplegaremos el menú "Code" del repositorio y copiaremos la dirección del repositorio.

Tras hacer esto, abrimos una consola nos dirigimos al directorio en el que queremos alojar localmente el repositorio y ejecutamos lo siguiente (cambiando la dirección del repositorio por el que acabamos de crear):

git clone git@github.com:GarciaDan/cypress-ts-bdd.git

Si la visibilidad del repositorio es pública, esto funcionará sin problemas. No obstante, si queremos comunicarnos con GitHub de forma efectiva y subir nuestros cambios locales al repositorio remoto, necesitaremos utilizar una clave ssh.

Para crear una clave privada, ejecutaremos lo siguiente en un terminal:

ssh-keygen -t ed25519 -C "your_email@example.com"

Esto generará una clave privada y una clave pública:

Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/daniel/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/daniel/.ssh/id_ed25519
Your public key has been saved in /home/daniel/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:+Fss5a3UMmvRY+t6EjbDJC9kVCnWtP0NxsToFsZAZFY your_email@example.com
The key's randomart image is:
+--[ED25519 256]--+
|         *O=Eo.  |
|        +ooo=o.  |
|       o ..o..+  |
|       .+ . oo o |
|      .oS=.o  . .|
|       ..+Oo+    |
|        ooB*oo   |
|         =o=o    |
|        ..+=.    |
+----[SHA256]-----+

Ahora, copiamos el contenido de la clave pública:

cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMkFnvZBzPiSscOcew1THpzm0URhwmNvP1Tfhlld/KWQ your_email@example.com

En GitHub, hacemos click en nuestro Avatar, seleccionamos "Settings" y vamos a la sección "SSH and GPG keys". Una vez allí, hacemos click en "New SSH Key"

Pegamos el contenido de la clave pública en la caja de texto y guardamos los cambios.

Con esto ya seremos capaces de utilizar nuestra clave privada para conectarnos a nuestra cuenta de GitHub. Para ello, configuraremos nuestro cliente git. Abrimos un terminal y escribimos lo siguiente (si no lo hemos hecho aún):

git config --global user.name "FIRST_NAME LAST_NAME"
git config --global user.email "your_email@example.com"

Finalmente, ejecutamos el SSH Agent y le añadimos la clave privada pasándole la ruta del fichero donde la hemos generado:

eval "$(ssh-agent -s)"
> Agent pid 59566
ssh-add ~/.ssh/id_ed25519

Con esto ya tenemos comunicación entre nuestro repositorio remoto y nuestro repositorio local.

Creación del proyecto NodeJS

En nuestro repositorio local, inicializamos un nuevo proyecto NPM.

npm init

Instalamos ahora los paquetes necesarios para nuestro proyecto:

  • Cypress
  • Cucumber Preprocessor
  • EsBuild bundler Preprocessor
  • Typescript
npm i -D cypress
npm i -D @badeball/cypress-cucumber-preprocessor
npm i -D @bahmutov/cypress-esbuild-preprocessor
npm i -D typescript
npm i -D @types/node @types/cypress-cucumber-preprocessor

Nuestro fichero package.json tendrá ahora el siguiente aspecto:

{
  "name": "cypress-ts-bdd",
  "version": "1.0.0",
  "description": "A simple Cypress+Typescript+BDD scaffolding project",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" &amp;&amp; exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/GarciaDan/cypress-ts-bdd.git"
  },
  "author": "Daniel Garcia",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/GarciaDan/cypress-ts-bdd/issues"
  },
  "homepage": "https://github.com/GarciaDan/cypress-ts-bdd#readme",
  "devDependencies": {
    "@badeball/cypress-cucumber-preprocessor": "^17.1.1",
    "@bahmutov/cypress-esbuild-preprocessor": "^2.2.0",
    "@types/cypress-cucumber-preprocessor": "^4.0.1",
    "@types/node": "^20.2.3",
    "cypress": "^12.12.0",
    "typescript": "^5.0.4"
  }
}

Generación de la configuración por defecto de Cypress

Cypress genera una configuración predeterminada durante la primera ejecución. Por lo tanto, abrimos la interfaz de Cypress:

npx cypress open

Pulsamos en "Continue >" y seleccionamos la opción "E2E Testing", que aparece en estado "Not Configured"

Esto debería generar los ficheros de configuración por defecto:

Ahora los ficheros de nuestro proyecto deberían de ser los siguientes:

Hecho esto, definimos el lugar donde almacenaremos la definición de los pasos (código Typescript). Añadimos lo siguiente en nuestro package.json:

  "cypress-cucumber-preprocessor": {
    "stepDefinitions": [
      "cypress/e2e/**/*.ts"
    ]
  }

Con lo que el fichero debería tener ahora este aspecto:

{
  "name": "cypress-ts-bdd",
  "version": "1.0.0",
  "description": "A simple Cypress+Typescript+BDD scaffolding project",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/GarciaDan/cypress-ts-bdd.git"
  },
  "author": "Daniel Garcia",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/GarciaDan/cypress-ts-bdd/issues"
  },
  "homepage": "https://github.com/GarciaDan/cypress-ts-bdd#readme",
  "devDependencies": {
    "@badeball/cypress-cucumber-preprocessor": "^17.1.1",
    "@bahmutov/cypress-esbuild-preprocessor": "^2.2.0",
    "@types/cypress-cucumber-preprocessor": "^4.0.1",
    "@types/node": "^20.2.3",
    "cypress": "^12.12.0",
    "typescript": "^5.0.4"
  },
  "cypress-cucumber-preprocessor": {
    "stepDefinitions": [
      "cypress/e2e/**/*.ts"
    ]
  }
}

Configuración de Typescript

Configuraremos ahora Typescript mediante la creación del fichero tsconfig.json en el directorio raíz del proyecto. En él incluiremos lo siguiente:

{
    "compilerOptions": {
        "target": "ES2021",
        "lib": ["ES2021", "DOM"],
        "types": ["cypress", "node"],
        "moduleResolution": "node16",
        "esModuleInterop": true,
        "allowSyntheticDefaultImports": true
    },
    "include": ["**/*.ts"]
}

Con estas opciones indicaremos a typescript que transpile el código Javascript en la versión ES2021 de ECMAScript, permitiéndonos así utilizar APIs como array.include(), Object.entries(), string.trimEnd(), promise.any(), ….

  • Al incluir la biblioteca "DOM" también podremos acceder a objetos del DOM como window o document.
  • Incluimos también los tipos de Cypress y de NodeJS, realizando la resolución de módulos "node16" para dar soporte a las últimas versiones de Typescript (a partir de la versión 4.7).
  • Por último, incluimos las opciones esModuleInterop y allowSyntheticDefaultImports para evitar errores al importar ciertos módulos (como aquellos que no tienen un default export).

Configuración de Cypress

Es hora de configurar Cypress. Aunque no es obligatorio, comenzaremos creando un fichero para definir las Cypress tasks. De este modo evitaremos declararlas directamente en el fichero de configuración y su modificación será mucho más sencilla. Crearemos por ejemplo una tarea "dummy" a la que llamaremos "stdout" y que mostrará un mensaje por consola:

const tasks = {
    stdout: (...data: Array<any>) => {
        console.log(data)
    }
};

export default tasks;

Editamos ahora el fichero de configuración cypress.config.ts, que tiene el siguiente aspecto inicial:

import { defineConfig } from "cypress";

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
  },
});

Definimos aparte la función setupNodeEvents y le añadimos el preprocesador de Cucumber:

import { defineConfig } from "cypress";
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";

async function setupNodeEvents(
  on: Cypress.PluginEvents,
  config: Cypress.PluginConfigOptions
): Promise<Cypress.PluginConfigOptions> {
  await addCucumberPreprocessorPlugin(on, config);
  return config;
}

export default defineConfig({
  e2e: {
    setupNodeEvents,
  },
});

Añadimos ahora el preprocesador del bundler esbuild:

import { defineConfig } from "cypress";
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
import createEsbuildPlugin from "@badeball/cypress-cucumber-preprocessor/esbuild";
import createBundler from "@bahmutov/cypress-esbuild-preprocessor/src";

async function setupNodeEvents(
  on: Cypress.PluginEvents,
  config: Cypress.PluginConfigOptions
): Promise<Cypress.PluginConfigOptions> {
  await addCucumberPreprocessorPlugin(on, config);
  on(
    "file:preprocessor",
    createBundler({ plugins: [createEsbuildPlugin(config)] })
  );
  return config;
}

export default defineConfig({
  e2e: {
    setupNodeEvents,
  },
});

Definimos las tareas. Como las hemos creado en un fichero externo, las importamos y las añadimos a la función setupNodeEvents() function:

import { defineConfig } from "cypress";
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
import createEsbuildPlugin from "@badeball/cypress-cucumber-preprocessor/esbuild";
import createBundler from "@bahmutov/cypress-esbuild-preprocessor/src";
import tasks from "./cypress/support/tasks";

async function setupNodeEvents(
  on: Cypress.PluginEvents,
  config: Cypress.PluginConfigOptions
): Promise<Cypress.PluginConfigOptions> {
  await addCucumberPreprocessorPlugin(on, config);
  on(
    "file:preprocessor",
    createBundler({ plugins: [createEsbuildPlugin(config)] })
  );
  on("task", tasks);
  return config;
}

export default defineConfig({
  e2e: {
    setupNodeEvents,
  },
});

Finalmente, añadimos la siguiente configuración a defineConfig.e2e en cypress.config.ts:

  • specPattern: le indica a Cypress dónde buscar (y con qué patrón) las features en lenguaje Gherkin.
  • supportFile: le indica a Cypress dónde se encuentra el fichero de soporte (e2e.ts).
  • baseUrl: configura la URL base del proyecto.
import { defineConfig } from "cypress";
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
import createEsbuildPlugin from "@badeball/cypress-cucumber-preprocessor/esbuild";
import createBundler from "@bahmutov/cypress-esbuild-preprocessor/src";
import tasks from "./cypress/support/tasks";

async function setupNodeEvents(
  on: Cypress.PluginEvents,
  config: Cypress.PluginConfigOptions
): Promise<Cypress.PluginConfigOptions> {
  await addCucumberPreprocessorPlugin(on, config);
  on(
    "file:preprocessor",
    createBundler({ plugins: [createEsbuildPlugin(config)] })
  );
  on("task", tasks);
  return config;
}

export default defineConfig({
  e2e: {
    setupNodeEvents,
    specPattern: "./cypress/e2e/**/*.{feature,features}",
    supportFile: "./cypress/support/e2e.ts",
	baseUrl: "https://the-internet.herokuapp.com"
  },
});

Variables de entorno

En vez de definir las variables de entorno en el fichero de configuración, es aconsejable generar un fichero cypress.env.json y definirlas dentro. Por ejemplo, definiremos una variable para el nombre de usuario y otra para la contraseña:

{
    "USERNAME": "tomsmith",
    "PASSWORD": "SuperSecretPassword!"
}

Creación de una feature

Es el momento de crear nuestra primera prueba. Empecemos generando la siguiente estructura de carpetas:

  • cypress/e2e/login/features
  • cypress/e2e/login/steps

Dentro cypress/e2e/login/features, creamos el fichero login-form.feature y le añadimos la siguiente especificación:

Feature: User is able to sign in the web application
    Scenario: User logs in successfully
        Given the user navigates to login page
        When the user provides valid credentials
        Then the main page is displayed to the authenticated user

El siguiente paso es, como podremos imaginar, implementar los pasos.

Creamos el fichero login-form.cy.ts dentro de cypress/e2e/login/steps/ folder e implementar los tres pasos que acabamos de utilizar en la feature.

El primer paso hace que visitamos la URL del login:

import { Given, When, Then } from "@badeball/cypress-cucumber-preprocessor";

const LOGIN_PAGE_URL = "login";

Given("the user navigates to login page", () => {
  cy.visit(LOGIN_PAGE_URL);
});

El segundo paso obtiene las variables de entorno USERNAME y PASSWORD desde cypress.env.json y los utiliza para cumplimentar el formulario de login, para justo después pulsar el botón de login:

When("the user provides valid credentials", () => {
  const userName = Cypress.env("USERNAME");
  const password = Cypress.env("PASSWORD");

  cy.get("#username").clear().type(userName);
  cy.get("#password").clear().type(password);

  cy.get("button").click();
});

El último paso realiza un par de comprobaciones para asegurarnos de que la página se muestra correctamente.

Then("the main page is displayed to the authenticated user", () => {
  cy.get('[class="flash success"]').should("exist").and("be.visible");
  cy.get('a[href="/logout"]').should("exist").and("be.visible");
});

El fichero completo contendrá lo siguiente:

import { Given, When, Then } from "@badeball/cypress-cucumber-preprocessor";

const LOGIN_PAGE_URL = "login";

Given("the user navigates to login page", () => {
  cy.visit(LOGIN_PAGE_URL);
});

When("the user provides valid credentials", () => {
  const userName = Cypress.env("USERNAME");
  const password = Cypress.env("PASSWORD");

  cy.get("#username").clear().type(userName);
  cy.get("#password").clear().type(password);

  cy.get("button").click();
});

Then("the main page is displayed to the authenticated user", () => {
  cy.get('[class="flash success"]').should("exist").and("be.visible");
  cy.get('a[href="/logout"]').should("exist").and("be.visible");
});

Configuración de VSCode con soporte para Cucumber

Si utilizas Visual Studio Code como editor, es muy aconsejable darle soporte para Cucumber con la extensión CucumberAutoComplete. Esta extensión permite, entre otras cosas, navegar desde la definición del paso hasta su implementación, por lo que nos puede ahorrar una cantidad significativa de tiempo durante el desarrollo. Puedes obtener la extensión aquí.

Una vez instalada, edita el fichero .vscode/settings.json y añade lo siguiente:

{
    "cucumberautocomplete.steps": [
        "cypress/e2e/*/**/steps/*.ts"
    ],
    "cucumberautocomplete.strictGherkinCompletion": false,
    "cucumberautocomplete.smartSnippets": true
}

Ejecución de la prueba

Todo debería de estar preparado para ejecutar nuestro primer test utilizando Cypress, Typescript y Cucumber. Veamos el resultado. Abrimos un terminal y ejecutamos lo siguiente:

npx cypress open

Seleccionamos la opción "E2E Testing"

Elegimos un navegador (por ejemplo, Chrome)

Hacemos click en login-form.feature para ejecutar el test y esperamos el resultado.

¡Hecho! Con esto, nuestro esqueleto está completo y podemos empezar a añadir más pruebas.

Puedes obtener el código relativo a esta entrada en este repositorio.

I’m back! Am I?

La vida da muchas vueltas. Parpadeas y ha pasado tanto tiempo que miras hacia atrás y lees una entrada de un blog escrito por un chavalín que se creía un experto programador porque se había estudiado unos cuantos patrones de diseño y había decidido compartir sus descubrimientos con los cuatro gatos que acababan cayendo en su blog porque Google así lo había decidido. Las visitas, a fin de cuentas, alimentan el ego, y por aquel entonces, ese sabor proporcionaba una impostada sensación de importancia en un mundo que se estaba volviendo digital. A día de hoy leo aquellas entradas con ternura, ya que soy consciente de que no tengo ni idea sobre nada, y que todos los días hay algo sobre lo que aprender.

A medida de que mi carrera fue virando (abandoné el desarrollo en .NET y me pasé a la calidad del software), mi motivación por compartir información que más o menos dominaba fue decreciendo, ya que la sensación de seguridad que te ofrece creer que dominas una materia se fue desvaneciendo. Si unimos todo eso a la formación de una familia, es fácil imaginarse que mantener el blog pasó a un completo segundo plano.

En cualquier caso, hace poco decidí volver a retomar mi pequeño espacio personal, pero desde otro enfoque distinto. No creo que retome .NET (si bien lo he seguido usando en proyectos internos, me he quedado demasiado descolgado como para poder “enseñar” nada), así que he decidido hacer borrón y cuenta nueva, dejando https://danielggarcia.wordpress.com tal y como se concibió incialmente. Si te suscribiste o seguiste mi blog: mil gracias por tu atención. No obstante, entiendo que si seguiste a mi antiguo yo fue porque te interesaba .NET y el desarrollo. Y debido a que esta nueva etapa pretende ser más personal y enfocada en otras materias, no me parece justo “arrastrar” a mis suscriptores a temas que probablemente no les interesen.

Gracias por todo.

Comencemos.

Para llegar a ser grande necesitarás, al menos a tres tipos de personas: alguien mejor de quien poder aprender, alguien inferior a quien poder enseñar y alguien igual con quien poder medirse.

Frank Shamrock