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
-
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 runEn PowerShell (afecta solo a la sesión actual):
Remove-Item Env:PAYMENT_KEY -ErrorAction SilentlyContinue npx vitest runSi tu configuración carga un
.env, renómbralo temporalmente. Con este paso se reproduce el 503 sin esperar al pipeline. -
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.envsolo en un punto de entrada. -
Define las variables dentro del test, no en el entorno de cada máquina.
vi.stubEnvcambia el valor enprocess.envy enimport.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
afterEachen cada archivo, Vitest tiene la opciónunstubEnvs: trueentest, que llama avi.unstubAllEnvs()antes de cada test (por defecto está enfalse). -
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 esjest.resetModules()(ojest.isolateModules) más una asignación aprocess.env. -
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); }); }); -
Fija los saltos de línea de los fixtures con un archivo
.gitattributesen la raíz del repositorio:*.csv text eol=lfCon
eol=lf, el archivo se queda con\nen el directorio de trabajo, sea cual seacore.autocrlf. Para corregir los archivos que ya estaban guardados con otro formato, ejecutagit add --renormalize .y revisa el resultado congit statusantes 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.envse fija al importar;vi.stubEnvenbeforeEachllega tarde. - Lee la variable dentro de una función o inyecta la configuración, y en los tests usa
vi.stubEnvconvi.unstubAllEnvs(). - Reproduce CI ejecutando la suite sin las variables de tu equipo (
env -u VARIABLEoRemove-Item Env:VARIABLE). - Parte líneas con
/\r?\n/, usaexpect.hasAssertions()y fija los fixtures con*.csv text eol=lf. - Prueba siempre las combinaciones con y sin variable, y con LF y CRLF.
Comentarios
Publicar un comentario