Tests pasan en local y fallan en CI: variables y CRLF

Ejecutas la suite en tu equipo y todo está en verde. Subes el cambio, el flujo de CI se pone en rojo y el error no apunta a ninguna línea que hayas tocado:

 FAIL  src/pagos/route.test.ts > POST /api/pagos > crea un pago
AssertionError: expected 503 to be 201 // Object.is equality

 FAIL  src/importar.test.ts > fixture ventas.csv > tiene encabezado y 7 filas
AssertionError: expected [ Array(1) ] to have a length of 8 but got 1

Este artículo explica dos causas muy comunes de "pasa en local, falla en CI" en proyectos Node.js con Vitest o Jest: variables de entorno leídas en el momento de importar un módulo y saltos de línea distintos entre Windows y Linux. Incluye cómo reproducir el fallo de CI en tu propia máquina antes de subir nada.

Por qué pasa

Causa 1: process.env se lee una sola vez, al importar

Mira este manejador de ruta (por ejemplo, un route handler de Next.js o cualquier módulo de Node.js):

// src/pagos/route.ts
const PAYMENT_KEY = process.env.PAYMENT_KEY; // se evalúa UNA vez, al importar el módulo

export async function POST(request: Request): Promise<Response> {
  if (!PAYMENT_KEY) {
    return Response.json({ error: 'Pagos no configurados' }, { status: 503 });
  }
  // ...
}

Un módulo ES se evalúa la primera vez que alguien lo importa, y los import estáticos de un archivo de test se resuelven antes de que se ejecute el cuerpo de ese archivo. Por eso este intento de controlar la variable no sirve:

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { POST } from './route'; // aquí se lee PAYMENT_KEY

beforeEach(() => {
  vi.stubEnv('PAYMENT_KEY', 'clave-de-prueba'); // llega tarde: la constante ya se fijó
});

La constante quedó con el valor que tenía el proceso al arrancar. En tu equipo esa variable existe, porque la define tu terminal o un archivo .env que carga tu configuración de pruebas. En CI no existe, y el manejador responde 503 en todos los tests que esperaban un pago correcto.

La documentación de Vitest indica que, por defecto, solo carga automáticamente desde los archivos .env las variables con prefijo VITE_. Lo comprobé con Vitest 5.0.3: una variable PAYMENT_KEY escrita en .env no aparecía en process.env dentro de los tests, y una VITE_... sí. Si en tu proyecto la variable llega a los tests, es porque algo más la carga (tu shell, un setup con dotenv, loadEnvConfig de @next/env o la opción test.env), y ese "algo" no existe igual en CI.

El problema también funciona al revés: un test como "responde 503 si falta la clave" pasa en CI y falla en tu equipo, porque ahí la clave sí está definida. Depender del .env de cada máquina hace que el resultado dependa de quién ejecute los tests.

Causa 2: el test solo entiende \r\n

Un test lee un CSV de ejemplo y lo parte en líneas con split('\r\n'). El repositorio guarda el archivo con \n. En Windows es habitual que core.autocrlf esté en true (compruébalo con git config --show-origin --get core.autocrlf), y entonces Git convierte los saltos de línea al hacer checkout: el archivo llega con \r\n y el test pasa. En el runner de Linux no hay conversión, el archivo conserva \n, el split no encuentra nada que cortar y devuelve un solo elemento con todo el contenido. Según cómo esté escrita la aserción, el test falla (como arriba) o, peor, pasa sin comprobar nada:

const rows = raw.split('\r\n').filter(Boolean).slice(1); // en Linux: []
rows.forEach((row) => {
  expect(row.split(',')).toHaveLength(3); // nunca se ejecuta
});

Un bucle sobre una lista vacía no ejecuta ninguna aserción, y el test queda en verde.

Solución

  1. Reproduce CI en local. Ejecuta la suite sin las variables que solo existen en tu equipo. En Git Bash o Linux:

    env -u PAYMENT_KEY npx vitest run

    En PowerShell (afecta solo a la sesión actual):

    Remove-Item Env:PAYMENT_KEY -ErrorAction SilentlyContinue
    npx vitest run

    Si tu configuración carga un .env, renómbralo temporalmente. Con este paso se reproduce el 503 sin esperar al pipeline.

  2. Lee la variable cuando la necesites, no al importar.

    const getPaymentKey = () => process.env.PAYMENT_KEY;
    
    export async function POST(request: Request): Promise<Response> {
      if (!getPaymentKey()) {
        return Response.json({ error: 'Pagos no configurados' }, { status: 503 });
      }
      // ...
    }

    Otra opción es recibir la configuración por parámetro (inyección de dependencias) y leer process.env solo en un punto de entrada.

  3. Define las variables dentro del test, no en el entorno de cada máquina. vi.stubEnv cambia el valor en process.env y en import.meta.env; vi.unstubAllEnvs() restaura los valores originales:

    import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
    import { POST } from './route';
    
    afterEach(() => {
      vi.unstubAllEnvs();
    });
    
    describe('POST /api/pagos', () => {
      beforeEach(() => {
        vi.stubEnv('PAYMENT_KEY', 'clave-de-prueba');
      });
    
      it('crea un pago', async () => {
        const res = await POST(new Request('http://localhost/api/pagos', {
          method: 'POST',
          body: JSON.stringify({ monto: 10 }),
        }));
        expect(res.status).toBe(201);
      });
    });
    
    describe('sin clave configurada', () => {
      it('responde 503', async () => {
        vi.stubEnv('PAYMENT_KEY', undefined); // el valor puede ser undefined
        // ...
      });
    });

    Si prefieres no escribir el afterEach en cada archivo, Vitest tiene la opción unstubEnvs: true en test, que llama a vi.unstubAllEnvs() antes de cada test (por defecto está en false).

  4. Si no puedes cambiar el módulo, reinicia el registro de módulos y vuelve a importarlo después de fijar la variable:

    let POST: (request: Request) => Promise<Response>;
    
    beforeEach(async () => {
      vi.resetModules();
      vi.stubEnv('PAYMENT_KEY', 'clave-de-prueba');
      ({ POST } = await import('./route')); // se evalúa de nuevo, ya con la variable
    });

    La documentación aclara que los imports de nivel superior no se pueden reevaluar, por eso hace falta el import() dinámico. En Jest el equivalente es jest.resetModules() (o jest.isolateModules) más una asignación a process.env.

  5. Parte las líneas aceptando ambos separadores y exige que el test compruebe algo:

    const lines = raw.split(/\r?\n/).filter(Boolean);
    
    it('todas las filas tienen 3 columnas', () => {
      expect.hasAssertions(); // falla si no se ejecutó ninguna aserción
      lines.slice(1).forEach((row) => {
        expect(row.split(',')).toHaveLength(3);
      });
    });
  6. Fija los saltos de línea de los fixtures con un archivo .gitattributes en la raíz del repositorio:

    *.csv text eol=lf

    Con eol=lf, el archivo se queda con \n en el directorio de trabajo, sea cual sea core.autocrlf. Para corregir los archivos que ya estaban guardados con otro formato, ejecuta git add --renormalize . y revisa el resultado con git status antes de confirmar.

Cómo verificar que quedó resuelto

Ejecuta la suite en las cuatro combinaciones posibles: con y sin la variable, y con el fixture en LF y en CRLF.

PAYMENT_KEY=valor-local npx vitest run
env -u PAYMENT_KEY npx vitest run

Repite ambos comandos después de convertir el fixture de formato (por ejemplo, con un editor que permita elegir LF o CRLF para el archivo). En un proyecto de ejemplo con 10 tests, las cuatro ejecuciones terminaron con "10 passed". Antes del arreglo, la ejecución sin variable fallaba en 6 de 8 tests, y con el fixture en LF el test del split('\r\n') fallaba y otro pasaba sin ninguna aserción.

Variantes y errores relacionados

La variable sí es necesaria en CI

Si un test de integración necesita un secreto real, defínelo en el flujo de trabajo con env: a partir de secrets. Ten en cuenta que, según la documentación de GitHub, los secretos no se pasan al runner cuando el flujo lo dispara un fork; ahí la variable no estará disponible. Para tests unitarios, usa valores de prueba en lugar de secretos.

Next.js y los archivos .env

La documentación de Next.js indica que en el entorno test no se carga .env.local, para que los resultados no dependan de cada persona. Puedes versionar un .env.test con valores falsos y cargarlo con loadEnvConfig de @next/env en tu configuración de pruebas.

El cache de módulos entre tests

Si usas vi.resetModules() y el módulo mantiene estado (conexiones, singletons), cada import() crea una instancia nueva. Es lo que buscas aquí, pero recuérdalo si compartes instancias entre tests.

Un fixture ya confirmado con CRLF

Cambiar .gitattributes no reescribe por sí solo lo que está en el repositorio. Por eso existe git add --renormalize: aplica de nuevo el filtro de normalización a todos los archivos versionados.

Resumen

  • Una constante de módulo que lee process.env se fija al importar; vi.stubEnv en beforeEach llega tarde.
  • Lee la variable dentro de una función o inyecta la configuración, y en los tests usa vi.stubEnv con vi.unstubAllEnvs().
  • Reproduce CI ejecutando la suite sin las variables de tu equipo (env -u VARIABLE o Remove-Item Env:VARIABLE).
  • Parte líneas con /\r?\n/, usa expect.hasAssertions() y fija los fixtures con *.csv text eol=lf.
  • Prueba siempre las combinaciones con y sin variable, y con LF y CRLF.

Referencias

Comentarios