A bomba-relógio do calendário: bugs dependentes de data em locators de testes E2E


Encontrei um bug que só aparece no dia 10 de cada mês. E todos os dias de outubro.
O teste passava no dia 9. Falhava no dia 10. Passava de novo no dia 11. Ninguém conectou as datas porque a falha parecia aleatória — mais um teste “instável” em uma suite que roda centenas de vezes por semana. Foram 33 dias desde a introdução do bug até sua primeira manifestação, porque o teste foi escrito em 7 de março e o próximo dia 10 era 10 de abril.
A causa raiz era um locator de texto do Playwright — getByText('10') — que correspondia a dois elementos na página quando a data atual continha “10”. Na maioria dos dias, havia exatamente um elemento com o texto “10”: um badge de contagem de registros. No dia 10 de qualquer mês, ou qualquer dia de outubro, o widget de calendário na barra lateral também exibia “10” — e o modo estrito do Playwright lançava um erro porque o locator correspondia a múltiplos elementos.
A correção foi uma única palavra: { exact: true }.
O locator que falhava
Veja como o teste era:
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');
});E aqui está o que o Playwright reportou em 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')O Playwright aplica o modo estrito por padrão — um locator deve corresponder a exatamente um elemento. Quando corresponde a dois, o Playwright não adivinha qual você queria. Ele falha imediatamente com um erro claro. Esse é o comportamento correto. O bug está no locator, não no Playwright.
A matemática da colisão de datas
Quantos dias por ano esse bug é acionado? Mais do que você imagina.
O locator getByText('10') realiza uma correspondência por substring por padrão. Ele corresponde a qualquer elemento cujo conteúdo de texto contenha a string “10”. Com um widget de calendário na página, veja quando as colisões ocorrem:
Colisões mensais (o dia 10): 10 de janeiro, 10 de fevereiro, 10 de março, 10 de abril, 10 de maio, 10 de junho, 10 de julho, 10 de agosto, 10 de setembro, 10 de outubro, 10 de novembro, 10 de dezembro = 12 dias
Colisões em outubro (todos os dias): O nome do mês “October” ou sua abreviação “Oct” não causa a colisão — são as células individuais de data. Em outubro, o calendário exibe “10” como número do mês em certos formatos de data, e a célula do dia 10 está presente. Mas mais importante, o calendário exibe as datas de 1 a 31, e “10” aparece como número de dia. Na nossa interface específica, o calendário mostrava a grade de datas do mês atual. Então em outubro, a célula “10” aparecia como o dia 10 de outubro — mas isso já contamos acima.
Deixe-me recalcular com mais cuidado. A colisão real acontece quando:
- O badge de contagem de registros mostra “10”
- E qualquer elemento visível do calendário também contém a substring “10”
Na nossa página, o calendário da barra lateral mostrava a data atual de forma proeminente. O formato era o número do dia: “10” no dia 10. Mas a grade do calendário também exibia todos os dias do mês atual. Então “10” estava sempre visível na grade do calendário para qualquer mês — porque todo mês tem um dia 10.
Espera. Isso significa que a colisão deveria acontecer todos os dias, não apenas no dia 10. Deixe-me reexaminar.
Após uma investigação mais profunda, o widget de calendário só renderizava a semana atual na visualização compacta da barra lateral, não a grade completa do mês. Então “10” só aparecia no calendário quando o dia 10 caía dentro da semana sendo exibida. Isso significava:
- Diretamente no dia 10: Sempre colide (o dia atual está destacado e mostra “10”)
- Dentro de ±3 dias do 10: Às vezes colide, dependendo de o dia 10 estar na semana exibida
- Datas de outubro formatadas como “10/DD”: O cabeçalho do seletor de data mostrava o formato “10/15” em outubro, introduzindo outra correspondência por substring com “10”
Executando o cálculo real ao longo de um ano calendário:
// 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 dias por ano. Isso é aproximadamente um dia por semana em média. O suficiente para parecer intermitente. O suficiente para parecer “instável”. Não o suficiente para falhar todo dia e forçar uma investigação imediata.
A correção de uma palavra
// 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 });O getByText do Playwright realiza uma correspondência por substring por padrão. A opção exact: true muda para uma correspondência de string completa: o conteúdo de texto do elemento deve ser exatamente “10”, não apenas contê-lo. A célula do calendário mostra “10” como seu texto completo, então exact: true sozinho não resolve completamente a ambiguidade — tanto o badge quanto a célula do calendário contêm exatamente “10”.
A correção completa encadeia um locator pai:
// COMPLETE FIX: scope the locator to the correct container
const countBadge = page.getByTestId('record-count-section').getByText('10', { exact: true });
await expect(countBadge).toBeVisible();Ao limitar o escopo do getByText a um contêiner pai, o “10” do widget de calendário fica excluído da correspondência. O exact: true ainda é uma boa prática como defesa de cinto e suspensórios.
Mais quatro padrões de bombas-relógio do calendário
A colisão numérica com getByText é o padrão mais comum, mas falhas de testes dependentes de data têm várias outras formas. Estes são os que encontrei na prática:
Padrão 1: Formato de data em asserções
// ❌ 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'
);Padrão 2: Casos extremos em fronteiras de mês
// ❌ 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
});Padrão 3: Seletores dependentes de fuso horário
// ❌ 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',
}Padrão 4: Correspondência em texto de data 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 weekAudite sua suite de testes agora mesmo
Execute este comando contra seus arquivos de testes para encontrar potenciais bombas-relógio do calendário:
# 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, pergunte-se: Esse texto poderia aparecer em uma data em algum lugar da página? Se a resposta for “talvez” ou “não tenho certeza”, adicione { exact: true } e um escopo pai. O custo da correção é uma palavra. O custo de não corrigir é um teste que falha em 43 dias por ano e parece “instável” no resto do tempo.
Um checklist de higiene de locators
Depois de encontrar esse bug, implementei estas regras para todo novo código de teste:
Nunca use
getByTextcom um número sozinho. Sempre use{ exact: true }ou limite o escopo a um contêiner pai. Melhor ainda, usegetByTestIdougetByRolepara elementos que exibem dados numéricos.Nunca faça asserções em strings de data formatadas. Use atributos
datetime, atributos data ou test IDs no lugar. Datas formatadas dependem de locale, de fuso horário, e mudam todo dia.Configure o fuso horário e a locale na configuração do Playwright. Elimine a deriva de fuso horário entre desenvolvimento local e CI.
Execute sua suite completa nos dias 10, 20 e 30 do mês. Essas datas são as mais propensas a colidir com texto numérico na sua interface. Se sua suite só roda em PRs e ninguém faz push nesses dias, você pode nunca ver a falha.
Execute sua suite completa em outubro. Outubro (mês 10) introduz o maior conjunto de colisões de datas. Se seu CI está verde em março mas você nunca rodou a suite em outubro, você tem território de calendário não testado.
A lição mais profunda
Os bugs mais assustadores são os que só aparecem em certos dias e passam no resto do ano. Eles parecem intermitentes. Eles parecem instáveis. Eles parecem o tipo de coisa para a qual você adiciona um retry e segue em frente.
Eles não são instáveis. São determinísticos — o gatilho é simplesmente o calendário em vez de uma mudança de código. Um teste que falha todo dia 10 do mês é tão determinístico quanto um teste que falha depois de toda migração de banco de dados. A variável é o tempo, não a aleatoriedade.
Bombas-relógio do calendário não são casos extremos. São inevitabilidades. Se seus testes E2E usam correspondência de texto em valores numéricos, eles vão colidir com datas em um ciclo previsível. A única pergunta é se você os encontra durante uma auditoria ou durante um incidente em produção.
A correção é uma palavra. A auditoria leva uma hora. O bug ficou escondido por 33 dias. Encontre os seus antes que eles encontrem você.
