La bombe à retardement du calendrier : les bugs liés aux dates dans les locators de tests E2E


J’ai trouvé un bug qui n’apparaît que le 10 de chaque mois. Et tous les jours en octobre.
Le test passait le 9. Il échouait le 10. Il repassait le 11. Personne n’a fait le lien avec les dates parce que l’échec semblait aléatoire — encore un test « instable » dans une suite qui tourne des centaines de fois par semaine. Il a fallu 33 jours entre l’introduction du bug et sa première manifestation, parce que le test avait été écrit le 7 mars et que le 10 suivant était le 10 avril.
La cause profonde était un locator textuel Playwright — getByText('10') — qui correspondait à deux éléments sur la page lorsque la date courante contenait « 10 ». La plupart du temps, il n’y avait qu’un seul élément avec le texte « 10 » : un badge de comptage d’enregistrements. Le 10 de chaque mois, ou n’importe quel jour en octobre, le widget calendrier dans la barre latérale affichait aussi « 10 » — et le mode strict de Playwright levait une erreur parce que le locator correspondait à plusieurs éléments.
Le correctif tenait en un seul mot : { exact: true }.
Le locator défaillant
Voici à quoi ressemblait le 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');
});Et voici ce que Playwright rapportait le 10 avril :
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 applique le mode strict par défaut — un locator doit correspondre à exactement un élément. Quand il en correspond deux, Playwright ne devine pas lequel vous vouliez. Il échoue immédiatement avec une erreur claire. C’est le bon comportement. Le bug est dans le locator, pas dans Playwright.
Le calcul des collisions de dates
Combien de jours par an ce bug se déclenche-t-il ? Plus que vous ne le pensez.
Le locator getByText('10') effectue une correspondance par sous-chaîne par défaut. Il correspond à tout élément dont le contenu textuel contient la chaîne « 10 ». Avec un widget calendrier sur la page, voici quand les collisions se produisent :
Collisions mensuelles (le 10 du mois) : 10 janvier, 10 février, 10 mars, 10 avril, 10 mai, 10 juin, 10 juillet, 10 août, 10 septembre, 10 octobre, 10 novembre, 10 décembre = 12 jours
Collisions en octobre (tous les jours) : Le nom du mois « October » ou son abréviation « Oct » ne cause pas la collision — ce sont les cellules de date individuelles. En octobre, le calendrier affiche « 10 » comme numéro de mois dans certains formats de date, et la cellule du 10e jour est présente. Mais surtout, le calendrier affiche les dates de 1 à 31, et « 10 » apparaît comme numéro de jour. Pour notre interface spécifique, le calendrier montrait la grille de dates du mois en cours. Donc en octobre, la cellule « 10 » apparaissait comme le 10e jour d’octobre — mais nous l’avons déjà compté ci-dessus.
Laissez-moi recalculer plus soigneusement. La vraie collision se produit quand :
- Le badge de comptage affiche « 10 »
- ET un élément visible du calendrier contient aussi la sous-chaîne « 10 »
Sur notre page, le calendrier de la barre latérale affichait la date courante de façon proéminente. Le format était le numéro du jour : « 10 » le 10. Mais la grille du calendrier affichait aussi tous les jours du mois en cours. Donc « 10 » était toujours visible dans la grille du calendrier pour n’importe quel mois — parce que chaque mois a un 10.
Attendez. Ça veut dire que la collision devrait se produire tous les jours, pas seulement le 10. Laissez-moi réexaminer.
Après une investigation plus approfondie, le widget calendrier ne rendait que la semaine en cours dans la vue compacte de la barre latérale, pas la grille complète du mois. Donc « 10 » n’apparaissait dans le calendrier que lorsque le 10 tombait dans la semaine actuellement affichée. Ce qui signifiait :
- Directement le 10 : Toujours en collision (le jour courant est mis en surbrillance et affiche « 10 »)
- À ±3 jours du 10 : Parfois en collision, selon que le 10 se trouve dans la semaine affichée
- Dates d’octobre au format « 10/JJ » : L’en-tête du sélecteur de date affichait le format « 10/15 » en octobre, introduisant une autre correspondance par sous-chaîne avec « 10 »
Calcul réel sur une année calendaire :
// 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 jours par an. C’est environ un jour par semaine en moyenne. Suffisamment pour paraître intermittent. Suffisamment pour avoir l’air « instable ». Pas assez pour échouer tous les jours et forcer une investigation immédiate.
Le correctif en un mot
// 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 });Le getByText de Playwright effectue une correspondance par sous-chaîne par défaut. L’option exact: true bascule vers une correspondance exacte : le contenu textuel de l’élément doit être exactement « 10 », pas simplement le contenir. La cellule du calendrier affiche « 10 » comme texte complet, donc exact: true seul ne résout pas entièrement l’ambiguïté — le badge et la cellule du calendrier contiennent tous deux exactement « 10 ».
Le correctif complet chaîne un locator parent :
// COMPLETE FIX: scope the locator to the correct container
const countBadge = page.getByTestId('record-count-section').getByText('10', { exact: true });
await expect(countBadge).toBeVisible();En limitant la portée du getByText à un conteneur parent, le « 10 » du widget calendrier est exclu de la correspondance. Le exact: true reste une bonne pratique comme défense ceinture-et-bretelles.
Quatre autres patterns de bombes à retardement calendaires
La collision numérique avec getByText est le pattern le plus courant, mais les échecs de tests liés aux dates prennent plusieurs autres formes. Voici ceux que j’ai trouvés en conditions réelles :
Pattern 1 : Format de date dans les 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'
);Pattern 2 : Cas limites aux frontières de mois
// ❌ 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
});Pattern 3 : Sélecteurs dépendants du fuseau horaire
// ❌ 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',
}Pattern 4 : Correspondance sur du texte de date relative
// ❌ "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 weekAuditez votre suite de tests maintenant
Exécutez cette commande sur vos fichiers de tests pour trouver des bombes à retardement calendaires potentielles :
# 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"Pour chaque résultat, posez-vous la question : Ce texte pourrait-il apparaître dans une date quelque part sur la page ? Si la réponse est « peut-être » ou « je ne suis pas sûr », ajoutez { exact: true } et une portée parent. Le coût du correctif est d’un mot. Le coût de ne pas le corriger est un test qui échoue 43 jours par an et semble « instable » le reste du temps.
Une checklist d’hygiène des locators
Après avoir trouvé ce bug, j’ai mis en place ces règles pour tout nouveau code de test :
N’utilisez jamais
getByTextavec un nombre seul. Utilisez toujours{ exact: true }ou limitez la portée à un conteneur parent. Mieux encore, utilisezgetByTestIdougetByRolepour les éléments qui affichent des données numériques.N’assertez jamais sur des chaînes de dates formatées. Utilisez les attributs
datetime, les attributs data ou les test IDs à la place. Les dates formatées dépendent de la locale, du fuseau horaire, et changent tous les jours.Configurez le fuseau horaire et la locale dans la configuration Playwright. Éliminez la dérive de fuseau horaire entre le développement local et le CI.
Exécutez votre suite complète le 10, le 20 et le 30 du mois. Ces dates sont les plus susceptibles d’entrer en collision avec du texte numérique dans votre interface. Si votre suite ne tourne que sur les PR et que personne ne pousse ces jours-là, vous pourriez ne jamais voir l’échec.
Exécutez votre suite complète en octobre. Octobre (mois 10) introduit le plus large éventail de collisions de dates. Si votre CI est vert en mars mais que vous n’avez jamais exécuté la suite en octobre, vous avez du territoire calendaire non testé.
La leçon profonde
Les bugs les plus effrayants sont ceux qui n’apparaissent que certains jours et passent le reste de l’année. Ils semblent intermittents. Ils semblent instables. Ils ressemblent au genre de chose pour laquelle on ajoute un retry et on passe à autre chose.
Ils ne sont pas instables. Ils sont déterministes — le déclencheur est simplement le calendrier au lieu d’un changement de code. Un test qui échoue chaque 10 du mois est aussi déterministe qu’un test qui échoue après chaque migration de base de données. La variable est le temps, pas le hasard.
Les bombes à retardement calendaires ne sont pas des cas limites. Ce sont des inévitabilités. Si vos tests E2E utilisent la correspondance textuelle sur des valeurs numériques, ils entreront en collision avec des dates sur un cycle prévisible. La seule question est de savoir si vous les trouvez lors d’un audit ou lors d’un incident en production.
Le correctif tient en un mot. L’audit prend une heure. Le bug se cachait depuis 33 jours. Trouvez les vôtres avant qu’ils ne vous trouvent.
