Kalendertidsbomben: datumberoende buggar i E2E-testlokatorer

Erik Treviño avatar
Erik Treviño
Cover for Kalendertidsbomben: datumberoende buggar i E2E-testlokatorer

Jag hittade en bugg som bara dyker upp den 10:e varje månad. Och varje dag i oktober.

Testet passerade den 9:e. Det fallerade den 10:e. Det passerade igen den 11:e. Ingen kopplade ihop datumen eftersom felet såg slumpmässigt ut — ännu ett “flakit” test i en svit som körs hundratals gånger per vecka. Det tog 33 dagar från buggens introduktion till dess första manifestation, eftersom testet skrevs den 7 mars och nästa 10:e var den 10 april.

Grundorsaken var en Playwright-textlokator — getByText('10') — som matchade två element på sidan när det aktuella datumet innehöll “10”. De flesta dagar fanns det exakt ett element med texten “10”: en badge för antal poster. Den 10:e varje månad, eller vilken dag som helst i oktober, visade kalenderwidgeten i sidofältet också “10” — och Playwrights strikta läge kastade ett fel eftersom lokatorn matchade flera element.

Fixen var ett enda ord: { exact: true }.

Den fallerade lokatorn

Så här såg testet ut:

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');
});

Och här är vad Playwright rapporterade den 10 april:

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 tillämpar strikt läge som standard — en lokator måste matcha exakt ett element. När den matchar två gissar Playwright inte vilket du menade. Det fallerar omedelbart med ett tydligt felmeddelande. Det här är rätt beteende. Buggen ligger i lokatorn, inte i Playwright.

Matematiken bakom datumkollisioner

Hur många dagar per år utlöses den här buggen? Fler än du tror.

Lokatorn getByText('10') utför en substrängsökning som standard. Den matchar alla element vars textinnehåll innehåller strängen “10”. Med en kalenderwidget på sidan, så här ser kollisionerna ut:

Månatliga kollisioner (den 10:e): 10 januari, 10 februari, 10 mars, 10 april, 10 maj, 10 juni, 10 juli, 10 augusti, 10 september, 10 oktober, 10 november, 10 december = 12 dagar

Oktoberkollisioner (varje dag): Månadsnamnet “October” eller dess förkortning “Oct” orsakar inte kollisionen — det är de individuella datumcellerna. I oktober visar kalendern “10” som månadsnummer i vissa datumformat, och den 10:e dagens cell finns där. Men ännu viktigare, kalendern visar datum 1-31, och “10” dyker upp som ett dagnummer. I vårt specifika gränssnitt visade kalendern det aktuella månadens datumrutnät. Så i oktober dök cellen “10” upp som oktobers 10:e dag — men det räknade vi redan ovan.

Låt mig räkna om mer noggrant. Den verkliga kollisionen sker när:

  • Badgen för antal poster visar “10”
  • OCH något synligt kalenderelement också innehåller substrängen “10”

På vår sida visade sidofältets kalender det aktuella datumet prominent. Formatet var dagnumret: “10” den 10:e. Men kalenderrutnätet visade också alla dagar i den aktuella månaden. Så “10” var alltid synligt i kalenderrutnätet för vilken månad som helst — eftersom varje månad har en 10:e.

Vänta. Det betyder att kollisionen borde ske varje dag, inte bara den 10:e. Låt mig undersöka igen.

Efter djupare undersökning renderade kalenderwidgeten bara den aktuella veckan i den kompakta sidofältsvyn, inte hela månadens rutnät. Så “10” dök bara upp i kalendern när den 10:e föll inom den visade veckan. Det innebar:

  • Direkt den 10:e: Kolliderar alltid (den aktuella dagen är markerad och visar “10”)
  • Inom ±3 dagar från den 10:e: Kolliderar ibland, beroende på om den 10:e finns i den visade veckan
  • Oktoberdatum formaterade som “10/DD”: Datumväljarens rubrik visade formatet “10/15” i oktober, vilket introducerade ytterligare en substrängsträff med “10”

Här är den faktiska beräkningen över ett kalenderår:

// 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 dagar per år. Det är ungefär en dag per vecka i genomsnitt. Tillräckligt för att verka intermittent. Tillräckligt för att se “flakit” ut. Inte tillräckligt för att fallera varje dag och tvinga fram en omedelbar utredning.

Enordsfixen

// 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 });

Playwrights getByText utför en substrängsökning som standard. Alternativet exact: true byter till en exakt strängmatchning: elementets textinnehåll måste vara exakt “10”, inte bara innehålla det. Kalendercellen visar “10” som sin fulla text, så exact: true ensamt löser inte helt tvetydigheten — både badgen och kalendercellen innehåller exakt “10”.

Den fullständiga fixen kedjar en föräldralokator:

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

Genom att begränsa getByText till en föräldracontainer exkluderas kalenderwidgetens “10” från matchningen. exact: true är fortfarande god praxis som ett livrem-och-hängslen-försvar.

Fyra fler kalendertidsbombmönster

Den numeriska getByText-kollisionen är det vanligaste mönstret, men datumberoende testfel har flera andra former. Här är de jag har hittat i verkligheten:

Mönster 1: Datumformat i assertions

// ❌ 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'
);

Mönster 2: Kantfall vid månadsgränser

// ❌ 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
});

Mönster 3: Tidszonsberoende selektorer

// ❌ 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',
}

Mönster 4: Matchning på relativ datumtext

// ❌ "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

Granska din testsvit nu

Kör det här kommandot mot dina testfiler för att hitta potentiella kalendertidsbomber:

# 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"

För varje träff, fråga dig: Kan den här texten dyka upp i ett datum någonstans på sidan? Om svaret är “kanske” eller “jag är inte säker”, lägg till { exact: true } och en föräldrabegränsning. Kostnaden för fixen är ett ord. Kostnaden för att inte fixa det är ett test som fallerar 43 dagar per år och ser “flakit” ut resten av tiden.

En checklista för lokatorhygien

Efter att ha hittat den här buggen införde jag dessa regler för all ny testkod:

  1. Använd aldrig getByText med ett ensamt tal. Använd alltid { exact: true } eller begränsa till en föräldracontainer. Ännu bättre, använd getByTestId eller getByRole för element som visar numerisk data.

  2. Gör aldrig assertions på formaterade datumsträngar. Använd datetime-attribut, data-attribut eller test-ID:n istället. Formaterade datum beror på locale, tidszon och ändras varje dag.

  3. Ställ in tidszon och locale i Playwright-konfigurationen. Eliminera tidszonsdrift mellan lokal utveckling och CI.

  4. Kör hela din testsvit den 10:e, 20:e och 30:e varje månad. Dessa datum är mest benägna att kollidera med numerisk text i ditt gränssnitt. Om din svit bara körs på PR:ar och ingen pushar dessa dagar, kanske du aldrig ser felet.

  5. Kör hela din testsvit i oktober. Oktober (månad 10) introducerar den bredaste uppsättningen av datumkollisioner. Om din CI är grön i mars men du aldrig har kört sviten i oktober, har du otestat kalenderterritorium.

Den djupare lärdomen

De läskigaste buggarna är de som bara dyker upp vissa dagar och passerar resten av året. De ser intermittenta ut. De ser flakiga ut. De ser ut som den typen av sak man lägger till en retry för och går vidare.

De är inte flakiga. De är deterministiska — utlösaren är helt enkelt kalendern istället för en kodändring. Ett test som fallerar varje 10:e i månaden är lika deterministiskt som ett test som fallerar efter varje databasmigrering. Variabeln är tid, inte slumpmässighet.

Kalendertidsbomber är inte kantfall. De är oundvikligheter. Om dina E2E-tester använder textmatchning på numeriska värden kommer de att kollidera med datum på en förutsägbar cykel. Den enda frågan är om du hittar dem under en granskning eller under en produktionsincident.

Fixen är ett ord. Granskningen tar en timme. Buggen gömde sig i 33 dagar. Hitta dina innan de hittar dig.