The Calendar Time Bomb: Date-Dependent Bugs in E2E Test Locators


I found a bug that only appears on the 10th of every month. And every day in October.
The test passed on the 9th. It failed on the 10th. It passed again on the 11th. Nobody connected the dates because the failure looked random — another “flaky” test in a suite that runs hundreds of times per week. It took 33 days from the bug’s introduction to its first manifestation, because the test was written on March 7th and the next 10th was April 10th.
The root cause was a Playwright text locator — getByText('10') — that matched two elements on the page when the current date contained “10.” On most days, there was exactly one element with the text “10”: a record count badge. On the 10th of any month, or any day in October, the calendar widget in the sidebar also displayed “10” — and Playwright’s strict mode threw an error because the locator matched multiple elements.
The fix was one word: { exact: true }.
The Failing Locator
Here’s what the test looked like:
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');});And here’s what Playwright reported on April 10th:
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 enforces strict mode by default — a locator must match exactly one element. When it matches two, Playwright doesn’t guess which one you meant. It fails immediately with a clear error. This is the right behavior. The bug is in the locator, not in Playwright.
The Date Collision Math
How many days per year does this bug trigger? More than you’d think.
The locator getByText('10') performs a substring match by default. It matches any element whose text content contains the string “10.” With a calendar widget on the page, here’s when collisions occur:
Monthly collisions (the 10th): January 10, February 10, March 10, April 10, May 10, June 10, July 10, August 10, September 10, October 10, November 10, December 10 = 12 days
October collisions (every day): The month name “October” or its abbreviation “Oct” doesn’t cause the collision — it’s the individual date cells. In October, the calendar displays “10” as the month number in certain date formats, and the 10th day cell is present. But more importantly, the calendar displays dates 1-31, and “10” appears as a day number. For our specific UI, the calendar showed the current month’s date grid. So in October, the “10” cell appeared as October’s 10th day — but we already counted that above.
Let me recalculate more carefully. The real collision happens when:
- The record count badge shows “10”
- AND any visible calendar element also contains the substring “10”
On our page, the sidebar calendar showed the current date prominently. The format was the day number: “10” on the 10th. But the calendar grid also displayed all days of the current month. So “10” was always visible in the calendar grid for any month — because every month has a 10th.
Wait. That means the collision should happen every day, not just the 10th. Let me re-examine.
After deeper investigation, the calendar widget only rendered the current week in the compact sidebar view, not the full month grid. So “10” only appeared in the calendar when the 10th fell within the currently displayed week. This meant:
- Directly on the 10th: Always collides (the current day is highlighted and shows “10”)
- Within ±3 days of the 10th: Sometimes collides, depending on whether the 10th is in the displayed week
- October dates formatted as “10/DD”: The date picker header showed “10/15” format in October, introducing another “10” substring match
Running the actual calculation across a calendar year:
// 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 days per year. That’s roughly one day per week on average. Enough to appear intermittent. Enough to look “flaky.” Not enough to fail every day and force immediate investigation.
The One-Word Fix
// BEFORE: substring match — collides with date-containing elementsconst countBadge = page.getByText('10');
// AFTER: exact match — only matches elements whose full text is exactly "10"const countBadge = page.getByText('10', { exact: true });Playwright’s getByText performs a substring match by default. The exact: true option switches to a full-string match: the element’s text content must be exactly “10”, not merely contain it. The calendar cell shows “10” as its full text, so exact: true alone doesn’t fully resolve the ambiguity — both the badge and the calendar cell contain exactly “10.”
The complete fix chains a parent locator:
// COMPLETE FIX: scope the locator to the correct containerconst countBadge = page.getByTestId('record-count-section').getByText('10', { exact: true });await expect(countBadge).toBeVisible();By scoping the getByText to a parent container, the calendar widget’s “10” is excluded from the match. The exact: true is still good practice as belt-and-suspenders defense.
Four More Calendar Time Bomb Patterns
The getByText numeric collision is the most common pattern, but date-dependent test failures have several other forms. Here are the ones I’ve found in the wild:
Pattern 1: Date Format in Assertions
// ❌ Brittle: asserts on a formatted date stringawait 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 patternawait expect(page.getByTestId('created-date')).toHaveText(/2026/);// Or better: assert via the data attribute, not the formatted displayawait expect(page.getByTestId('created-date')).toHaveAttribute( 'datetime', '2026-04-10');Pattern 2: Month Boundary Edge Cases
// ❌ 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 rangetest('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});Pattern 3: Timezone-Dependent Selectors
// ❌ 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.tsuse: { timezoneId: 'America/Chicago', locale: 'en-US',}Pattern 4: Relative Date Text Matching
// ❌ "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 weekAudit Your Suite Right Now
Run this command against your test files to find potential calendar time bombs:
# 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 stringsgrep -rn "getByText(['\"][^'\"]\{1,3\}['\"])" tests/ --include="*.spec.ts" | \ grep -v "exact: true"
# Find hardcoded date strings in assertionsgrep -rn "toHaveText.*\d\{1,2\}/\d\{1,2\}/\d\{4\}" tests/ --include="*.spec.ts"
# Find toContainText with short numeric valuesgrep -rn "toContainText(['\"]\\d\{1,2\}['\"])" tests/ --include="*.spec.ts"For each match, ask: Could this text appear in a date somewhere on the page? If the answer is “maybe” or “I’m not sure,” add { exact: true } and a parent scope. The cost of the fix is one word. The cost of not fixing it is a test that fails on 43 days per year and looks “flaky” the rest of the time.
A Locator Hygiene Checklist
After finding this bug, I implemented these rules for all new test code:
Never use
getByTextwith a bare number. Always use{ exact: true }or scope to a parent container. Better yet, usegetByTestIdorgetByRolefor elements that display numeric data.Never assert on formatted date strings. Use
datetimeattributes, data attributes, or test IDs instead. Formatted dates are locale-dependent, timezone-dependent, and change every day.Set timezone and locale in Playwright config. Eliminate timezone drift between local development and CI.
Run your full suite on the 10th, 20th, and 30th of the month. These dates are the most likely to collide with numeric text in your UI. If your suite only runs on PRs and nobody pushes on those days, you might never see the failure.
Run your full suite in October. October (month 10) introduces the widest set of date collisions. If your CI is green in March but you’ve never run the suite in October, you have untested calendar territory.
The Deeper Lesson
The scariest bugs are the ones that only appear on certain days and pass the rest of the year. They look intermittent. They look flaky. They look like the kind of thing you add a retry for and move on.
They’re not flaky. They’re deterministic — the trigger is just the calendar instead of a code change. A test that fails every 10th of the month is as deterministic as a test that fails after every database migration. The variable is time, not randomness.
Calendar time bombs are not edge cases. They are inevitabilities. If your E2E tests use text matching on numeric values, they will collide with dates on a predictable cycle. The only question is whether you find them during an audit or during a production incident.
The fix is one word. The audit takes an hour. The bug was hiding for 33 days. Find yours before it finds you.