La bomba de tiempo del calendario: bugs dependientes de la fecha en locators de tests E2E

Erik Treviño avatar
Erik Treviño
Cover for La bomba de tiempo del calendario: bugs dependientes de la fecha en locators de tests E2E

Encontré un bug que solo aparece el día 10 de cada mes. Y todos los días de octubre.

El test pasaba el 9. Fallaba el 10. Volvía a pasar el 11. Nadie conectó las fechas porque la falla parecía aleatoria — otro test “inestable” en una suite que corre cientos de veces por semana. Pasaron 33 días desde la introducción del bug hasta su primera manifestación, porque el test se escribió el 7 de marzo y el siguiente día 10 fue el 10 de abril.

La causa raíz era un locator de texto de Playwright — getByText('10') — que coincidía con dos elementos en la página cuando la fecha actual contenía “10”. La mayoría de los días, había exactamente un elemento con el texto “10”: un badge de conteo de registros. El día 10 de cualquier mes, o cualquier día de octubre, el widget de calendario en la barra lateral también mostraba “10” — y el modo estricto de Playwright lanzaba un error porque el locator coincidía con múltiples elementos.

La solución fue una sola palabra: { exact: true }.

El locator que fallaba

Así se veía el test:

test('records page shows correct count badge', async ({ page }) => {
  await page.goto('/records');

  // This locator matches the record count badge... usually
  const countBadge = page.getByText('10');
  await expect(countBadge).toBeVisible();
  await expect(countBadge).toHaveAttribute('data-testid', 'record-count');
});

Y esto es lo que Playwright reportó el 10 de abril:

Error: strict mode violation: getByText('10') resolved to 2 elements:
  1) <span data-testid="record-count">10</span>
  2) <td class="calendar-day">10</td>

  = waiting for getByText('10')

Playwright aplica el modo estricto por defecto — un locator debe coincidir con exactamente un elemento. Cuando coincide con dos, Playwright no adivina cuál querías. Falla inmediatamente con un error claro. Este es el comportamiento correcto. El bug está en el locator, no en Playwright.

Las matemáticas de la colisión de fechas

¿Cuántos días al año se activa este bug? Más de lo que piensas.

El locator getByText('10') realiza una coincidencia por subcadena por defecto. Coincide con cualquier elemento cuyo contenido de texto contenga la cadena “10”. Con un widget de calendario en la página, estas son las situaciones en que ocurren colisiones:

Colisiones mensuales (el día 10): 10 de enero, 10 de febrero, 10 de marzo, 10 de abril, 10 de mayo, 10 de junio, 10 de julio, 10 de agosto, 10 de septiembre, 10 de octubre, 10 de noviembre, 10 de diciembre = 12 días

Colisiones en octubre (todos los días): El nombre del mes “October” o su abreviación “Oct” no causa la colisión — son las celdas individuales de fecha. En octubre, el calendario muestra “10” como número de mes en ciertos formatos de fecha, y la celda del día 10 está presente. Pero más importante aún, el calendario muestra las fechas del 1 al 31, y “10” aparece como número de día. En nuestra interfaz específica, el calendario mostraba la cuadrícula de fechas del mes actual. Así que en octubre, la celda “10” aparecía como el día 10 de octubre — pero eso ya lo contamos arriba.

Déjame recalcular con más cuidado. La colisión real ocurre cuando:

  • El badge de conteo de registros muestra “10”
  • Y cualquier elemento visible del calendario también contiene la subcadena “10”

En nuestra página, el calendario de la barra lateral mostraba la fecha actual de forma prominente. El formato era el número del día: “10” en el día 10. Pero la cuadrícula del calendario también mostraba todos los días del mes actual. Así que “10” siempre estaba visible en la cuadrícula del calendario para cualquier mes — porque todos los meses tienen un día 10.

Espera. Eso significa que la colisión debería ocurrir todos los días, no solo el 10. Déjame re-examinar.

Después de una investigación más profunda, el widget de calendario solo renderizaba la semana actual en la vista compacta de la barra lateral, no la cuadrícula completa del mes. Así que “10” solo aparecía en el calendario cuando el 10 caía dentro de la semana que se estaba mostrando. Esto significaba:

  • Directamente el día 10: Siempre colisiona (el día actual está resaltado y muestra “10”)
  • Dentro de ±3 días del 10: A veces colisiona, dependiendo de si el 10 está en la semana mostrada
  • Fechas de octubre con formato “10/DD”: El encabezado del selector de fecha mostraba el formato “10/15” en octubre, introduciendo otra coincidencia por subcadena con “10”

Ejecutando el cálculo real a lo largo de un año calendario:

// How many days per year does getByText('10') collide with a date?
function countCollisionDays(): number {
  let collisions = 0;
  const year = 2026;

  for (let month = 0; month < 12; month++) {
    const daysInMonth = new Date(year, month + 1, 0).getDate();
    for (let day = 1; day <= daysInMonth; day++) {
      const date = new Date(year, month, day);
      const dayOfWeek = date.getDay();

      // The compact calendar shows the week containing the current day
      // Calculate the range of days visible in the current week row
      const weekStart = day - dayOfWeek;       // Sunday
      const weekEnd = weekStart + 6;            // Saturday

      // Does "10" fall within the visible week?
      if (weekStart <= 10 && weekEnd >= 10 && 10 <= daysInMonth) {
        collisions++;
      }

      // October: date header shows "10/DD" format — always contains "10"
      if (month === 9) { // October = month index 9
        collisions++; // Already counted above if applicable, but header adds collision
      }
    }
  }
  return collisions;
}

// Result: ~43 days per year where the collision is possible

~43 días por año. Eso es aproximadamente un día por semana en promedio. Suficiente para parecer intermitente. Suficiente para verse “inestable”. No lo suficiente para fallar todos los días y forzar una investigación inmediata.

La solución de una palabra

// BEFORE: substring match — collides with date-containing elements
const countBadge = page.getByText('10');

// AFTER: exact match — only matches elements whose full text is exactly "10"
const countBadge = page.getByText('10', { exact: true });

El getByText de Playwright realiza una coincidencia por subcadena por defecto. La opción exact: true cambia a una coincidencia de cadena completa: el contenido de texto del elemento debe ser exactamente “10”, no simplemente contenerlo. La celda del calendario muestra “10” como su texto completo, así que exact: true por sí solo no resuelve completamente la ambigüedad — tanto el badge como la celda del calendario contienen exactamente “10”.

La solución completa encadena un locator padre:

// COMPLETE FIX: scope the locator to the correct container
const countBadge = page.getByTestId('record-count-section').getByText('10', { exact: true });
await expect(countBadge).toBeVisible();

Al limitar el alcance del getByText a un contenedor padre, el “10” del widget de calendario queda excluido de la coincidencia. El exact: true sigue siendo una buena práctica como defensa de cinturón y tirantes.

Cuatro patrones más de bombas de tiempo del calendario

La colisión numérica con getByText es el patrón más común, pero las fallas de tests dependientes de fechas tienen varias otras formas. Estos son los que he encontrado en la práctica:

Patrón 1: Formato de fecha en aserciones

// ❌ Brittle: asserts on a formatted date string
await expect(page.getByTestId('created-date')).toHaveText('4/10/2026');

// Fails on: different date format locales (10/4/2026 in DD/MM/YYYY regions),
// timezone shifts that push the date to the previous/next day,
// and any test environment with a different system clock

// ✅ Robust: assert on the date parts separately or use a pattern
await expect(page.getByTestId('created-date')).toHaveText(/2026/);
// Or better: assert via the data attribute, not the formatted display
await expect(page.getByTestId('created-date')).toHaveAttribute(
  'datetime', '2026-04-10'
);

Patrón 2: Casos límite en fronteras de mes

// ❌ This test creates a record and checks "created today" —
// but if the test runs at 11:59 PM, the record is created at 11:59 PM
// and the assertion runs at 12:00 AM the next day. The dates don't match.
test('new record shows today as created date', async ({ page }) => {
  await createRecord(page);
  const today = new Date().toLocaleDateString();  // Captures date at assertion time
  await expect(page.getByTestId('created-date')).toHaveText(today);
  // Fails at midnight boundary
});

// ✅ Fix: capture the date before the action, or assert within a range
test('new record shows today as created date', async ({ page }) => {
  const beforeCreate = new Date();
  await createRecord(page);
  // Assert the date is within the same day as when we started
  const dateText = await page.getByTestId('created-date').textContent();
  const recordDate = new Date(dateText!);
  const diffMs = Math.abs(recordDate.getTime() - beforeCreate.getTime());
  expect(diffMs).toBeLessThan(24 * 60 * 60 * 1000); // Within 24 hours
});

Patrón 3: Selectores dependientes de zona horaria

// ❌ The test filters by "today" in a date picker — but the CI server
// is in UTC and the application displays dates in the user's timezone.
// At 7 PM Central (which is midnight UTC), "today" means different dates.
await page.getByRole('button', { name: 'Today' }).click();
await expect(page.getByTestId('date-filter')).toHaveText('Apr 10');
// Fails for UTC-based CI servers running after midnight UTC

// ✅ Fix: set a consistent timezone in the Playwright config
// playwright.config.ts
use: {
  timezoneId: 'America/Chicago',
  locale: 'en-US',
}

Patrón 4: Coincidencia de texto de fecha relativa

// ❌ "Created 2 days ago" — this text changes every day.
// The test was written on a Monday. "2 days ago" matched Saturday's record.
// On Wednesday, "2 days ago" points to Monday — a different record entirely.
await expect(page.getByText('Created 2 days ago')).toBeVisible();

// ✅ Fix: don't assert on relative date text. Assert on the underlying value.
const dateAttr = await page.getByTestId('record-date').getAttribute('datetime');
const recordDate = new Date(dateAttr!);
const now = new Date();
const diffDays = Math.floor((now.getTime() - recordDate.getTime()) / (1000 * 60 * 60 * 24));
expect(diffDays).toBeGreaterThanOrEqual(0);
expect(diffDays).toBeLessThanOrEqual(7); // Record was created within the last week

Audita tu suite de tests ahora mismo

Ejecuta este comando contra tus archivos de tests para encontrar posibles bombas de tiempo del calendario:

# Find getByText calls with numeric arguments (potential date collisions)
grep -rn "getByText(['\"]\\d" tests/ --include="*.spec.ts" --include="*.test.ts"

# Find getByText calls without { exact: true } on short strings
grep -rn "getByText(['\"][^'\"]\{1,3\}['\"])" tests/ --include="*.spec.ts" | \
  grep -v "exact: true"

# Find hardcoded date strings in assertions
grep -rn "toHaveText.*\d\{1,2\}/\d\{1,2\}/\d\{4\}" tests/ --include="*.spec.ts"

# Find toContainText with short numeric values
grep -rn "toContainText(['\"]\\d\{1,2\}['\"])" tests/ --include="*.spec.ts"

Para cada resultado, pregúntate: ¿Podría este texto aparecer en una fecha en algún lugar de la página? Si la respuesta es “tal vez” o “no estoy seguro”, agrega { exact: true } y un alcance padre. El costo de la solución es una palabra. El costo de no solucionarlo es un test que falla 43 días al año y parece “inestable” el resto del tiempo.

Una checklist de higiene de locators

Después de encontrar este bug, implementé estas reglas para todo nuevo código de test:

  1. Nunca uses getByText con un número solo. Siempre usa { exact: true } o limita el alcance a un contenedor padre. Mejor aún, usa getByTestId o getByRole para elementos que muestran datos numéricos.

  2. Nunca hagas aserciones sobre cadenas de fecha formateadas. Usa atributos datetime, atributos data o test IDs en su lugar. Las fechas formateadas dependen de la locale, de la zona horaria, y cambian todos los días.

  3. Configura la zona horaria y la locale en la configuración de Playwright. Elimina la deriva de zona horaria entre el desarrollo local y el CI.

  4. Ejecuta tu suite completa el 10, 20 y 30 del mes. Estas fechas son las más propensas a colisionar con texto numérico en tu interfaz. Si tu suite solo corre en PRs y nadie hace push esos días, podrías nunca ver la falla.

  5. Ejecuta tu suite completa en octubre. Octubre (mes 10) introduce el mayor rango de colisiones de fechas. Si tu CI está en verde en marzo pero nunca has corrido la suite en octubre, tienes territorio de calendario sin probar.

La lección más profunda

Los bugs más aterradores son los que solo aparecen ciertos días y pasan el resto del año. Parecen intermitentes. Parecen inestables. Parecen el tipo de cosa a la que le agregas un retry y sigues adelante.

No son inestables. Son deterministas — el disparador es simplemente el calendario en vez de un cambio de código. Un test que falla cada día 10 del mes es tan determinista como un test que falla después de cada migración de base de datos. La variable es el tiempo, no la aleatoriedad.

Las bombas de tiempo del calendario no son casos límite. Son inevitabilidades. Si tus tests E2E usan coincidencia de texto en valores numéricos, van a colisionar con fechas en un ciclo predecible. La única pregunta es si los encuentras durante una auditoría o durante un incidente en producción.

La solución es una palabra. La auditoría toma una hora. El bug estuvo escondido 33 días. Encuentra los tuyos antes de que ellos te encuentren a ti.