Cypress: variables de entorno - de Cypress.env() a cy.env() y Cypress.expose()

Variables de entorno en Cypress: saqué credenciales del código, Cypress.env() quedó deprecado y migré a cy.env() y Cypress.expose().

VS Code con el árbol de archivos mostrando cypress.env.json, cypress.env.example.json y cypress.config.ts modificado, y el resumen de Cypress: 61 de 63 tests en verde.
El árbol muestra los archivos nuevos (cypress.env.json y su template) y los modificados; el resumen, 61 en verde y los 2 fallos intencionales de siempre — ya sin el warning de Cypress.env().

Este iba a ser un post corto. La idea era simple: sacar las credenciales y las URLs que tenía hardcodeadas en el framework y pasarlas a variables de entorno, como se hace en cualquier proyecto serio. Lo hice, corrió todo en verde… y cuando miré la salida de la terminal, Cypress me tiró un warning que me cambió el post:

Cypress.env() is deprecated. This allows any browser code to read values from Cypress.env(). This is insecure and will be removed in a future major version.

O sea: la API que acababa de usar para resolver el problema ya está deprecada, y encima por insegura. Así que este post terminó siendo dos cosas: cómo saco config y secretos del código, y cómo migré a la forma nueva (cy.env() y Cypress.expose()) que Cypress introdujo en la 15.10.


El punto de partida: todo hardcodeado

Antes de tocar nada, así estaba el framework. Las credenciales del login vivían en un fixture versionado:

// cypress/fixtures/users.json
{
  "validUser": {
    "username": "admin",
    "password": "serenity"
  }
}

Y las de la API, directamente como valores por defecto en un custom command:

Cypress.Commands.add('apiLogin', (username = 'admin', password = 'password123') => {
  // ...
})

Son credenciales de una demo pública, no un secreto de verdad. Pero el punto es el hábito: si esto fuera un proyecto real, ese password estaría viajando al repositorio en cada commit. El objetivo del post es aprender el mecanismo con datos que no importan, para tenerlo automatizado cuando sí importen.

¿Qué es esto de "distintos ambientes"?

Cuando uno escucha "variables de entorno" en testing, casi siempre aparece la palabra ambientes. La idea es esta: el mismo suite de tests corre contra distintos servidores según en qué etapa estés.

  • Local: la app corriendo en tu máquina.
  • QA / staging: donde el equipo prueba antes de subir a producción.
  • Producción: el server real.

Cada ambiente tiene otra URL, otras credenciales y a veces otra API. Las variables de entorno son lo que te permite apuntar a uno u otro sin tocar el código: cambiás una variable y el mismo test corre contra otro server.

Voy a ser honesto acá, porque es la regla con la que escribo estos posts: yo no tengo tres ambientes reales. Mi aplicación bajo prueba es una demo pública (demo.serenity.is). No voy a inventar un staging falso para que el post quede más lindo. Lo que sí puedo mostrar es el mecanismo completo, dejarlo preparado para el día que exista un QA, y explicar por qué está estructurado así.

Primer intento: cypress.env.json + Cypress.env()

La forma clásica —y la que aparece en la mayoría de los tutoriales— es esta. Se crea un archivo cypress.env.json con los valores, y se ignora en git para que los secretos no lleguen al repo:

// cypress.env.json  (ignorado por git)
{
  "user": "admin",
  "password": "serenity",
  "apiUser": "admin",
  "apiPassword": "password123"
}

En el .gitignore:

# Cypress env vars — credenciales/secretos, NO subir al repo.
cypress.env.json

Y como el archivo no está en el repo, cualquiera que clone el proyecto no lo va a tener. Por eso se versiona un template con la estructura pero sin valores reales:

// cypress.env.example.json  (este SÍ va al repo)
{
  "user": "usuario_de_serenity",
  "password": "password_de_serenity",
  "apiUser": "usuario_de_la_api",
  "apiPassword": "password_de_la_api"
}

Después, en el código, esos valores se leían con Cypress.env():

const user = Cypress.env('user')
const pass = Cypress.env('password')

Corrí el suite. Todo verde. Y ahí, arriba de todo en la terminal, apareció el warning.

El giro: Cypress me dice que eso ya está muerto

El mensaje completo:

The allowCypressEnv configuration option is enabled. This allows any browser code to read values from Cypress.env(). This is insecure and will be removed in a future major version.

Cypress corre el código de tus tests dentro del navegador. No es un detalle menor: cuando usás Cypress.env(), Cypress hidrata todas las variables de entorno en el contexto del browser — incluso las que ningún test llega a leer. Eso significa que cualquier código que se ejecute en esa página (la app bajo prueba, un script de terceros) podría, en teoría, leer tus secretos. Filtrás más de lo que quisiste.

Esto es, además, un buen punto de comparación con los otros frameworks que ya documenté:

  • En Selenium (Java) el test corre en la JVM, no en el browser. Usás System.getenv(), un config.properties o profiles de Maven. El código del test nunca llega al navegador, así que no hay nada que "filtrar".
  • En Playwright el test corre en Node, no en el browser. Usás process.env (con un .env y dotenv) o el bloque use del config. Misma historia: el proceso de test está afuera del browser.

O sea: este problema de seguridad es propio de la arquitectura de Cypress, porque es el único de los tres que ejecuta el test adentro del navegador. Entenderlo así fue lo que me hizo entender por qué existe la API nueva.

La API nueva: cy.env() y Cypress.expose()

Desde Cypress 15.10 la migración separa dos casos que antes iban todos juntos en Cypress.env():

cy.env() — para secretos. Los valores quedan en el proceso de Node y no se serializan al browser. Solo se exponen las variables que pedís explícitamente. Es un comando asíncrono, así que se usa con .then():

cy.env(['user', 'password']).then(({ user, password }) => {
  // acá uso las credenciales, dentro del callback
})

Cypress.expose() — para config pública no sensible. URLs, feature flags, versiones de API. Es síncrono y lo declarás en el config:

// cypress.config.ts
export default defineConfig({
  expose: {
    apiUrl: 'https://restful-booker.herokuapp.com'
  },
  allowCypressEnv: false,
  e2e: { /* ... */ }
})

Y lo leés directo, sin .then():

const apiUrl = Cypress.expose('apiUrl')

allowCypressEnv: false es el que cierra la puerta: con eso, cualquier Cypress.env() que quede suelto ya no tira un warning, tira un error. Es la forma de asegurarte de que no dejaste nada de la API vieja. En Cypress 16 la opción desaparece porque Cypress.env() ya no va a existir.

La decisión de qué va en cada lado fue directa:

Dato Clasificación API
apiUrl Config pública Cypress.expose()
user, password Secreto cy.env()
apiUser, apiPassword Secreto cy.env()

La migración (y el detalle que me hizo renegar)

El cambio de Cypress.expose() fue trivial: un reemplazo sincrónico, uno por uno.

El de cy.env() no, porque es asíncrono. Todo el código que leía credenciales de forma directa tuvo que reestructurarse para meterse dentro de un .then(). Ejemplo real, el login de la API.

Antes:

Cypress.Commands.add('apiLogin', (username = 'admin', password = 'password123') => {
  const apiUrl = Cypress.env('apiUrl')
  return cy.request('POST', `${apiUrl}/auth`, { username, password })
    .its('body.token')
    .should('be.a', 'string')
})

Después:

Cypress.Commands.add('apiLogin', (username?: string, password?: string) => {
  const apiUrl = Cypress.expose('apiUrl') as string

  const resolveCreds =
    username && password
      ? cy.wrap({ user: username, pass: password }, { log: false })
      : cy.env(['apiUser', 'apiPassword']).then((e) => ({
          user: e.apiUser as string,
          pass: e.apiPassword as string
        }))

  return resolveCreds.then(({ user, pass }) =>
    cy.request('POST', `${apiUrl}/auth`, { username: user, password: pass })
      .its('body.token')
      .should('be.a', 'string')
  )
})

No pude usar cy.env() como valor por defecto de un parámetro (un default de función se evalúa de forma síncrona, y cy.env() es un comando encolado). Lo resolví normalizando: si me pasan credenciales explícitas las envuelvo con cy.wrap, y si no, las leo con cy.env(). Los dos caminos terminan en el mismo .then().

Lo mismo pasó en el spec de la API: los POST /auth que antes tenían las credenciales inline ahora arrancan con cy.env(['apiUser', 'apiPassword']).then(...). Más anidamiento, sí, pero los secretos ya no tocan el browser.

¿De dónde salen las variables? La precedencia

Algo que conviene tener claro, porque es fuente de confusión. Tanto cy.env() como el expose leen de varias fuentes, y hay un orden de prioridad. De menor a mayor:

  1. El bloque del cypress.config.ts.
  2. El archivo cypress.env.json.
  3. El flag --env en la CLI.
  4. Las variables CYPRESS_* del sistema operativo.

La de más abajo pisa a la de arriba. Esto es exactamente lo que hace posible el multi-ambiente: los defaults viven en el config, y cuando corrés en CI o contra otro server, inyectás por CLI o por variables de entorno del sistema sin tocar un solo archivo del repo.

El resultado

Después de la migración corrí el suite completo:

       Spec                                Tests  Passing  Failing
  api/restful-booker.cy.ts                   16       16        -
  auth/login.cy.ts                            8        8        -
  clientes/clientes-data.cy.ts                2        -        2
  clientes/clientes-interacciones.cy.ts       7        7        -
  clientes/clientes.cy.ts                     6        6        -
  dashboard/assertions.cy.ts                 10       10        -
  intercept/intercept-stubbing.cy.ts          7        7        -
  ui-avanzada/ui-avanzada.cy.ts               7        7        -

  63 tests — 61 passing, 2 failing
VS Code con el árbol de archivos mostrando cypress.env.json, cypress.env.example.json y cypress.config.ts modificado, y el resumen de Cypress: 61 de 63 tests en verde.
El árbol muestra los archivos nuevos (cypress.env.json y su template) y los modificados; el resumen, 61 en verde y los 2 fallos intencionales de siempre — ya sin el warning de Cypress.env().

Los dos fallos que quedan son los intencionales de siempre: la validación de datos contra Excel, donde documento a propósito las diferencias reales entre mi archivo y la grilla (Alfred vs Alfreds, Anna vs Ana, United Kingdom vs UK). Todo lo que toca variables de entorno —login por UI, auth de API, los POST /auth— pasó en verde.

Y, lo más importante para este post: el warning de Cypress.env desapareció. Ya no queda una sola llamada a la API vieja en el proyecto.

Qué sigue

Las variables de entorno no son un tema aislado: son la base de lo que viene. Cuando arme el CI/CD con GitHub Actions, las credenciales no van a poder vivir en un cypress.env.json local —ese archivo no está en el repo— así que van a tener que inyectarse como secretos del pipeline vía variables CYPRESS_*. Ahí es donde toda esta plomería se vuelve obligatoria y no opcional.

El próximo paso, sin embargo, es el reporting (Mochawesome + Allure), para que las corridas dejen algo más presentable que texto en la terminal.