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í.

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.