|

Testing Date Pickers and Calendars in Playwright

Date pickers and calendar widgets are quietly one of the flakiest things to automate. They render dates dynamically based on “today”, they hide the actual <input> behind a styled popup, and the cell you want to click might live in a different month than the one currently shown. In this guide you’ll learn reliable patterns for Playwright date picker testing in TypeScript: typing into native inputs, navigating month-by-month, freezing “today” with the clock API, and writing helpers that never break when the calendar advances to a new month.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why Date Pickers Break Automated Tests

Most calendar bugs in test suites come from three sources. First, the widget renders relative to the system clock, so a test that hardcodes “click June 15” passes in June and fails in July. Second, the visible date grid only shows one month at a time, so blindly clicking a day cell fails when the target date is in a future or past month. Third, many UI libraries (React-DatePicker, MUI, Ant Design, Flatpickr) render the popup in a portal at the end of <body>, so the calendar cells are not DOM children of the input you clicked.

The fix is to treat the calendar like any other stateful component: control the inputs you can, navigate deterministically, and freeze the clock so “today” is always the same. Let’s build up these techniques one at a time.

Strategy 1: Skip the Calendar and Fill the Native Input

The single most underused technique is to ignore the calendar popup entirely. Native HTML <input type="date"> fields, and many custom widgets, accept a typed value directly. This is the fastest and least flaky approach because it skips all the month navigation. A native date input expects an ISO yyyy-mm-dd string passed to fill().

import { test, expect } from '@playwright/test';

test('fill a native date input directly', async ({ page }) => {
  await page.goto('https://example.com/booking');

  const dateInput = page.getByLabel('Check-in date');

  // Native <input type="date"> accepts an ISO yyyy-mm-dd string.
  await dateInput.fill('2026-07-15');

  // Assert the committed value rather than the rendered popup.
  await expect(dateInput).toHaveValue('2026-07-15');
});

If the widget is a custom one backed by a text input that ignores fill() (some libraries only react to real keystrokes), use pressSequentially() to type character by character and trigger the library’s keydown handlers, then commit with Enter.

test('type into a custom text-based date field', async ({ page }) => {
  const input = page.getByPlaceholder('MM/DD/YYYY');

  await input.click();
  await input.pressSequentially('07/15/2026', { delay: 50 });
  await input.press('Enter');

  await expect(input).toHaveValue('07/15/2026');
});

Always prefer this path when the application accepts it. Reserve clicking through the calendar grid for cases where the input is truly read-only and the popup is the only way to pick a date.

Strategy 2: Navigate the Calendar Month by Month

When you must use the popup, never assume the target month is visible. Read the calendar’s current month/year header, compare it to your target, and click the “next” or “previous” arrow until they match. This loop is the heart of every robust calendar helper.

import { type Page, expect } from '@playwright/test';

const MONTHS = [
  'January', 'February', 'March', 'April', 'May', 'June',
  'July', 'August', 'September', 'October', 'November', 'December',
];

async function pickDate(page: Page, target: Date) {
  await page.getByLabel('Departure date').click(); // open the popup

  const header = page.locator('.calendar__header-title');
  const wantLabel = `${MONTHS[target.getMonth()]} ${target.getFullYear()}`;

  // Navigate until the header matches the target month/year.
  for (let i = 0; i < 36; i++) {
    const current = (await header.innerText()).trim();
    if (current === wantLabel) break;

    const currentDate = parseHeader(current);       // helper below
    const goForward = currentDate < target;
    await page.getByRole('button', {
      name: goForward ? 'Next month' : 'Previous month',
    }).click();
  }

  // Click the day cell, scoped to active-month cells only.
  await page
    .locator('.calendar__day:not(.is-outside-month)')
    .getByText(String(target.getDate()), { exact: true })
    .click();
}

function parseHeader(label: string): Date {
  const [monthName, year] = label.split(' ');
  return new Date(Number(year), MONTHS.indexOf(monthName), 1);
}

Two details make this reliable. The :not(.is-outside-month) filter prevents clicking the greyed-out leading/trailing days that many calendars show to fill the grid (clicking “1” could otherwise hit the previous month’s spillover cell). And the bounded for loop guards against an infinite loop if a header never matches because of a locale or formatting mismatch.

Prefer Roles and Accessible Names

Well-built calendars expose day cells through the grid role with an accessible name like “15 July 2026”. When that’s available, getByRole('gridcell') is far more stable than CSS class selectors, which change with every library version.

// Most accessible date grids label each cell with a full date string.
await page.getByRole('gridcell', { name: '15 July 2026' }).click();

// Or target the button inside the cell when the name is just the day number:
await page
  .getByRole('gridcell', { name: '15', exact: true })
  .getByRole('button')
  .click();

Strategy 3: Freeze “Today” with the Clock API

The biggest source of calendar flakiness is the moving definition of “now”. A test that picks “tomorrow” computes a different date every day, and date pickers that disable past dates behave differently depending on when the suite runs. Playwright’s page.clock API lets you pin the browser’s clock to a fixed instant before navigation, so the calendar always opens on the same month and “today” never moves.

test('date picker with a frozen clock', async ({ page }) => {
  // Pin the browser clock BEFORE navigating so the widget initialises
  // with a deterministic "today".
  await page.clock.setFixedTime(new Date('2026-07-01T09:00:00'));

  await page.goto('https://example.com/booking');
  await page.getByLabel('Departure date').click();

  // The calendar reliably opens on July 2026 every run.
  await expect(page.locator('.calendar__header-title')).toHaveText('July 2026');

  // "Today" is always 1 July 2026, so this assertion is stable.
  await expect(page.getByRole('gridcell', { name: '1', exact: true }))
    .toHaveClass(/is-today/);
});

Use setFixedTime() when you only need a stable “now” and the page does not depend on time advancing. If the widget runs timers (for example, an auto-refreshing “available slots” list), use page.clock.install() with an { time } option instead, which also lets you fast-forward with page.clock.fastForward(). Crucially, set the clock before page.goto() so the date library reads the patched clock during initialisation.

Strategy 4: A Reusable Relative-Date Helper

With a frozen clock in place, you can safely compute dates relative to “today” without nondeterminism. A small helper keeps tests readable and centralises the navigation logic so a library upgrade only changes one file.

import { test, expect, type Page } from '@playwright/test';

function addDays(base: Date, days: number): Date {
  const d = new Date(base);
  d.setDate(d.getDate() + days);
  return d;
}

class DatePicker {
  constructor(private page: Page, private fieldLabel: string) {}

  async select(target: Date) {
    await this.page.getByLabel(this.fieldLabel).click();
    const header = this.page.getByRole('heading', { level: 2 }); // month/year title
    const want = target.toLocaleDateString('en-US', {
      month: 'long', year: 'numeric',
    });

    for (let i = 0; i < 36; i++) {
      if ((await header.innerText()).trim() === want) break;
      await this.page.getByRole('button', { name: 'Next month' }).click();
    }

    await this.page
      .getByRole('gridcell', { name: String(target.getDate()), exact: true })
      .click();
  }
}

test('book a stay seven days from today', async ({ page }) => {
  const today = new Date('2026-07-01T09:00:00');
  await page.clock.setFixedTime(today);
  await page.goto('https://example.com/booking');

  const checkIn = new DatePicker(page, 'Check-in date');
  await checkIn.select(addDays(today, 7)); // always 8 July 2026

  await expect(page.getByLabel('Check-in date')).toHaveValue('2026-07-08');
});

Because the clock is frozen, addDays(today, 7) resolves to the same calendar date on every run, on every machine, in every timezone configured for the project. That is the combination that finally makes date selection deterministic.

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Handling Date Ranges and Disabled Days

Range pickers (check-in / check-out) require selecting two cells in the same open popup, and you should assert that disabled days cannot be chosen. Playwright’s toBeDisabled() and toHaveAttribute() assertions verify business rules like “no past dates” or “minimum two-night stay”.

test('past dates are disabled and unclickable', async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-07-10T09:00:00'));
  await page.goto('https://example.com/booking');
  await page.getByLabel('Check-in date').click();

  // A day before "today" should be disabled.
  const pastDay = page.getByRole('gridcell', { name: '5', exact: true });
  await expect(pastDay).toHaveAttribute('aria-disabled', 'true');

  // force: true lets us attempt the click to prove nothing happens.
  await pastDay.click({ force: true });
  await expect(page.getByLabel('Check-in date')).toHaveValue('');

  // A valid future day is selectable.
  await page.getByRole('gridcell', { name: '20', exact: true }).click();
  await expect(page.getByLabel('Check-in date')).toHaveValue('2026-07-20');
});

For two-input range pickers, drive each field with its own helper instance and assert the resulting summary text, which is more meaningful than checking individual cell states.

Choosing the Right Approach for Playwright Date Picker Testing

The table below summarises when to reach for each technique. Start at the top and move down only when the widget forces you to.

TechniqueBest forKey APIFlakiness risk
Fill native input<input type=”date”> or editable text fieldslocator.fill()Lowest
Type with keystrokesCustom inputs needing real keydown eventspressSequentially()Low
Navigate calendar gridRead-only inputs, popup-only widgetsgetByRole(‘gridcell’)Medium
Freeze the clockAny relative-date or “today” logicpage.clock.setFixedTime()Removes time flakiness
Assert disabled daysMin/max date and range rulestoBeDisabled() / toHaveAttribute()Low

Best Practices Checklist

  • Prefer filling the underlying input over clicking the calendar whenever the app allows it.
  • Always freeze the clock with page.clock.setFixedTime() before page.goto() for any relative-date test.
  • Target day cells by getByRole('gridcell') and accessible names, not brittle CSS classes.
  • Filter out spillover cells from adjacent months before clicking a day number.
  • Bound your month-navigation loop so a header mismatch fails fast instead of hanging.
  • Assert the committed input value or summary, not the transient popup state.

Put together, these patterns turn calendars from a recurring source of red builds into ordinary, deterministic components. Solid Playwright date picker testing comes down to controlling time, navigating intentionally, and asserting on committed values rather than the visual popup.

FAQ

How do I select a date in Playwright without clicking the calendar?

For a native <input type="date">, call locator.fill('2026-07-15') with an ISO yyyy-mm-dd string and assert with toHaveValue(). For custom text inputs that only react to keystrokes, use pressSequentially() followed by press('Enter'). This skips month navigation and is the most reliable approach.

How can I make date picker tests deterministic regardless of when they run?

Freeze the browser clock with page.clock.setFixedTime(new Date('2026-07-01T09:00:00')) before calling page.goto(). The calendar then always opens on the same month and “today” never moves, so relative dates like “tomorrow” or “7 days out” resolve to the same value on every run and in every timezone.

Why does clicking a day cell sometimes select the wrong month?

Many calendars render greyed-out spillover days from the previous and next month to fill the grid, so a plain text match on “1” can hit the wrong cell. Scope your locator to active-month cells (for example :not(.is-outside-month) or a gridcell with a full accessible date name) before clicking, and always navigate the header to the correct month first.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.