Scheduling Integration Testing Guide
How to Test Calendly Booking Flows with Playwright: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing Calendly booking with Playwright. Standalone pages, embedded iframes, timezone detection, custom intake forms, availability edge cases, and the cross-origin pitfalls that silently break scheduling tests.
“Calendly powers over 10 million meetings per month across hundreds of thousands of organizations, making it the most widely embedded scheduling widget on the web.”
1. Why Testing Calendly Booking Is Complex
Calendly looks simple from the outside. A user picks a date, picks a time, fills in their name and email, and confirms. But under the surface, Calendly is a multi-step, cross-origin, timezone-aware scheduling engine that presents several structural challenges for automated testing.
First, when Calendly is embedded in your application, the entire booking widget runs inside an iframe hosted on calendly.com. Your Playwright tests cannot interact with it using ordinary page locators. You need to cross a frame boundary, and the iframe content is controlled by Calendly, not you. Second, the booking flow is inherently multi-step: select a date from the calendar, select an available time slot, fill in contact details and any custom questions, then confirm. Each step triggers a re-render inside the iframe, and the transitions between steps have variable loading times depending on Calendly's API response speed.
Third, timezone handling is a constant source of subtle bugs. Calendly detects the visitor's timezone via the browser and displays available slots in that local time. If your CI server runs in UTC but your Calendly account is configured in US Eastern, the time slots your test sees will differ from what a developer sees locally. Fourth, Calendly supports custom intake questions (text fields, dropdowns, radio buttons, phone numbers) that vary per event type, so your test must handle dynamic form structures. Fifth, the date picker disables past dates and dates with no availability, which means your test cannot hard-code a specific date without risking a failure when that date passes or fills up.
A reliable Calendly test suite accounts for all five of these surfaces. The sections below walk through each scenario with runnable Playwright TypeScript code and equivalent Assrt plain-English descriptions.
Standalone Calendly Booking Flow
Open Calendly link
Select date
Calendar picker
Select time slot
Available times
Fill details
Name, email, questions
Confirm booking
Redirect or confirmation
Embedded Iframe Booking Flow
Load your page
Calendly iframe loads
Cross-origin
Select date in iframe
Select time in iframe
Fill form in iframe
Confirm in iframe
Parent page callback
postMessage event
Calendly Booking Sequence
2. Setting Up Your Test Environment
Before you write a single test, configure a dedicated Calendly account and event type for testing. Using your production Calendly account will create real bookings on your calendar and pollute your scheduling data. Create a separate Calendly account with a test event type that has predictable availability windows.
Test Event Type Configuration
Create a 30-minute event type in your test account with the following settings. Set availability to every weekday, 9:00 AM to 5:00 PM, in a fixed timezone (for example, America/New_York). Add at least one custom question (a text field asking "Company name") so you can test intake form handling. Enable the redirect on confirmation to a URL you control. Disable email notifications to avoid inbox clutter during test runs.
Calendly Test Account Setup Checklist
- Create a dedicated Calendly account for testing (separate from production)
- Create a 30-minute event type with weekday availability, 9 AM to 5 PM
- Set a fixed timezone (e.g. America/New_York) on the event type
- Add at least one custom intake question (text field: "Company name")
- Configure a post-booking redirect URL to a page you control
- Disable email notifications to avoid inbox clutter during test runs
- Create a second event type with zero availability for edge case tests
- Generate a Calendly API token for test cleanup and teardown
Environment Variables
Playwright Configuration for Cross-Origin Iframes
Calendly iframes are served from calendly.com, which is a different origin than your application. Playwright handles cross-origin iframes out of the box, but you need to be aware that the iframe content loads asynchronously. Do not attempt to interact with elements inside the Calendly iframe until the iframe has finished loading its initial view.
Helper Utilities
Because Calendly tests depend on selecting future available dates, you need a utility that finds the next available weekday. Hard-coding a date like "April 15" will fail once that date passes.
// test-utils/calendly-helpers.ts
export function getNextWeekday(): Date {
const date = new Date();
date.setDate(date.getDate() + 1);
while (date.getDay() === 0 || date.getDay() === 6) {
date.setDate(date.getDate() + 1);
}
return date;
}
export function formatCalendlyDate(date: Date): string {
return date.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
}3. Scenario: Standalone Booking Happy Path
Standalone Booking: Date, Time, Details, Confirm
ModerateThe standalone flow is when the user navigates directly to a calendly.com link (for example, calendly.com/your-org/30-minute-meeting). This is the simplest Calendly test because there is no iframe boundary to cross. The entire page is the Calendly application.
Goal
Navigate to the Calendly event page, select the next available date, pick the first available time slot, fill in name and email, and confirm the booking. Assert the confirmation screen is displayed.
Playwright Implementation
Key Details
The calendar renders date cells as buttons. Disabled buttons represent past dates or dates with no availability. By selecting the first enabled button, you avoid hard-coding a specific date. The time slot buttons appear only after a date is selected, so always wait for them to become visible before clicking. The confirmation screen includes the invitee email, which serves as your primary assertion anchor.
Standalone Booking: Playwright vs Assrt
import { test, expect } from '@playwright/test';
test('standalone booking: happy path', async ({ page }) => {
const email = `test+${Date.now()}@assrt.ai`;
await page.goto(process.env.CALENDLY_EVENT_URL!);
await page.waitForSelector('[data-container="calendar"]', {
timeout: 15_000,
});
const availableDate = page.locator(
'button[data-container="date-cell"]:not([disabled])'
).first();
await availableDate.click();
const timeSlot = page.getByTestId('time-button').first();
await expect(timeSlot).toBeVisible({ timeout: 10_000 });
await timeSlot.click();
await page.getByRole('button', { name: /next/i }).click();
await page.getByLabel(/^name/i).fill('Jane Tester');
await page.getByLabel(/email/i).fill(email);
await page.getByRole('button', { name: /schedule event/i }).click();
await expect(
page.getByRole('heading', { name: /confirmed/i })
).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(email)).toBeVisible();
});4. Scenario: Embedded Iframe Booking on Your Page
Embedded Calendly Widget Inside Your Application
ComplexWhen you embed Calendly on your own site using their inline embed script, the entire booking widget runs inside an iframe from calendly.com. This is the most common production setup and also the hardest to test. Every interaction must go through Playwright's frameLocator to reach inside the cross-origin iframe.
The Embed Structure
Calendly's inline embed injects a <div class="calendly-inline-widget"> containing an iframe whose src points to calendly.com. The iframe loads the same scheduling UI you see on the standalone page, but now it is sandboxed inside your page's DOM. Playwright's frameLocator lets you target elements inside this iframe by matching on the iframe's src attribute.
Playwright Implementation
Listening for the Parent Page Callback
Calendly's embed SDK fires postMessage events to the parent window when certain actions happen. The most important event is calendly.event_scheduled, which fires after a successful booking. You can listen for this event in your test to verify the parent page received the callback.
// After navigating to the embed page, set up a listener
const scheduledEvent = page.evaluate(() => {
return new Promise<any>((resolve) => {
window.addEventListener('message', (e) => {
if (e.data?.event === 'calendly.event_scheduled') {
resolve(e.data);
}
});
});
});
// ... complete the booking flow ...
// After confirming, verify the postMessage was received
const eventData = await scheduledEvent;
expect(eventData.event).toBe('calendly.event_scheduled');
expect(eventData.payload.invitee.uri).toBeTruthy();5. Scenario: Custom Intake Form Fields
Booking with Custom Questions and Required Fields
ModerateCalendly event types can include custom questions: text inputs, multi-line text areas, radio buttons, dropdowns, checkboxes, and phone number fields. These appear on the details step alongside the standard name and email fields. If your event type requires a phone number or a custom "Company name" field, the booking will fail unless your test fills them in. This is a frequent source of test failures when someone adds a required question to the event type without updating the test.
Playwright Implementation
test('booking with custom intake form fields', async ({ page }) => {
const email = `intake+${Date.now()}@assrt.ai`;
await page.goto(process.env.CALENDLY_EVENT_URL!);
// Select date and time (same pattern as happy path)
const availableDate = page.locator(
'button[data-container="date-cell"]:not([disabled])'
).first();
await availableDate.click();
await page.getByTestId('time-button').first().click();
await page.getByRole('button', { name: /next/i }).click();
// Fill standard fields
await page.getByLabel(/^name/i).fill('Intake Tester');
await page.getByLabel(/email/i).fill(email);
// Fill custom text question
await page.getByLabel(/company name/i).fill('Assrt Inc.');
// Fill custom phone number field (if configured)
const phoneField = page.getByLabel(/phone/i);
if (await phoneField.isVisible()) {
await phoneField.fill('+1 555 123 4567');
}
// Fill custom dropdown (if configured)
const dropdown = page.getByLabel(/how did you hear/i);
if (await dropdown.isVisible()) {
await dropdown.selectOption({ label: 'Search engine' });
}
// Fill custom radio button (if configured)
const radio = page.getByLabel(/team size/i);
if (await radio.first().isVisible()) {
await page.getByLabel('1-10').check();
}
// Submit
await page.getByRole('button', { name: /schedule event/i }).click();
// Assert confirmation
await expect(
page.getByRole('heading', { name: /confirmed/i })
).toBeVisible({ timeout: 15_000 });
});Handling Validation Errors
If you try to submit without filling a required custom field, Calendly displays inline validation errors. A good defensive test submits the form with a required field left blank, asserts the validation error appears, fills the field, then submits again. This verifies that your event type's validation configuration actually works and that users are not silently blocked from booking.
6. Scenario: Timezone Handling and Verification
Timezone mismatches are one of the most common production bugs in scheduling integrations. A user in Tokyo books what they think is a 2:00 PM slot, but the host sees it as 1:00 AM because the timezone conversion went wrong somewhere. Calendly handles this correctly on its side, but your integration may not. Your test needs to verify that the timezone displayed in the booking widget matches expectations and that the confirmed time is what the user intended.
Timezone Detection and Display Verification
ComplexPlaywright Implementation
test.describe('timezone handling', () => {
test('displays times in the browser timezone', async ({ browser }) => {
// Create a context with a specific timezone
const context = await browser.newContext({
timezoneId: 'Asia/Tokyo',
locale: 'en-US',
});
const page = await context.newPage();
await page.goto(process.env.CALENDLY_EVENT_URL!);
// Wait for the calendar to load
await page.waitForSelector('[data-container="calendar"]', {
timeout: 15_000,
});
// Verify the timezone indicator shows Japan Standard Time
const tzLabel = page.locator('[data-container="timezone"]');
await expect(tzLabel).toContainText(/Japan|JST|Tokyo/i);
// Select a date and verify time slots are in JST range
const availableDate = page.locator(
'button[data-container="date-cell"]:not([disabled])'
).first();
await availableDate.click();
const firstTimeSlot = page.getByTestId('time-button').first();
await expect(firstTimeSlot).toBeVisible({ timeout: 10_000 });
// Time should be displayed in Japan timezone format
const timeText = await firstTimeSlot.textContent();
expect(timeText).toBeTruthy();
// Calendly shows times like "9:00am" or "2:30pm"
expect(timeText).toMatch(/\d{1,2}:\d{2}(am|pm)/i);
await context.close();
});
test('times differ when timezone changes', async ({ browser }) => {
// Book with US Eastern timezone
const eastContext = await browser.newContext({
timezoneId: 'America/New_York',
locale: 'en-US',
});
const eastPage = await eastContext.newPage();
await eastPage.goto(process.env.CALENDLY_EVENT_URL!);
await eastPage.waitForSelector('[data-container="calendar"]');
const eastDate = eastPage.locator(
'button[data-container="date-cell"]:not([disabled])'
).first();
await eastDate.click();
const eastTime = await eastPage
.getByTestId('time-button')
.first()
.textContent();
// Now check the same event with Pacific timezone
const westContext = await browser.newContext({
timezoneId: 'America/Los_Angeles',
locale: 'en-US',
});
const westPage = await westContext.newPage();
await westPage.goto(process.env.CALENDLY_EVENT_URL!);
await westPage.waitForSelector('[data-container="calendar"]');
const westDate = westPage.locator(
'button[data-container="date-cell"]:not([disabled])'
).first();
await westDate.click();
const westTime = await westPage
.getByTestId('time-button')
.first()
.textContent();
// The displayed times should differ by 3 hours
// (Eastern is UTC-5, Pacific is UTC-8 during standard time)
expect(eastTime).not.toBe(westTime);
await eastContext.close();
await westContext.close();
});
});The key insight is that Playwright's timezoneId context option controls what Intl.DateTimeFormat returns inside the browser. Calendly reads this to detect the visitor's timezone. By creating two browser contexts with different timezones and loading the same event page, you can verify that Calendly correctly adjusts the displayed time slots. This test catches the bug where your embed page overrides or ignores the browser timezone.
7. Scenario: No Availability State
Fully Booked Date and No Availability Handling
ModerateWhen all time slots for a given date are booked, or when the event type has no availability for an entire month, Calendly displays an empty state. Your application needs to handle this gracefully. If your embed page shows a loading spinner indefinitely because it never receives a calendly.event_scheduled callback, that is a bug your test should catch.
Testing with a Fully Booked Event Type
The cleanest approach is to create a second event type in your test Calendly account with zero availability. Set its schedule to have no open hours on any day. When your test navigates to that event type, Calendly will display the "no times available" message.
test('no availability: shows empty state message', async ({ page }) => {
// Use an event type configured with zero availability
await page.goto(
'https://calendly.com/test-account/no-availability-event'
);
// Wait for the calendar to load
await page.waitForSelector('[data-container="calendar"]', {
timeout: 15_000,
});
// Assert there are no enabled date buttons
const enabledDates = page.locator(
'button[data-container="date-cell"]:not([disabled])'
);
await expect(enabledDates).toHaveCount(0, { timeout: 10_000 });
// Calendly shows a message when no times are available
await expect(
page.getByText(/no times available/i)
).toBeVisible();
});
test('no availability: embedded widget shows fallback UI', async ({ page }) => {
// Navigate to your embed page pointing at the no-availability event
await page.goto('/schedule-unavailable');
const calendlyFrame = page.frameLocator(
'iframe[src*="calendly.com"]'
);
// Verify the iframe loaded
await expect(
calendlyFrame.locator('[data-container="calendar"]')
).toBeVisible({ timeout: 20_000 });
// Assert no enabled dates
const enabledDates = calendlyFrame.locator(
'button[data-container="date-cell"]:not([disabled])'
);
await expect(enabledDates).toHaveCount(0, { timeout: 10_000 });
// Your parent page should display a fallback message or
// contact link when Calendly has no availability
await expect(
page.getByText(/contact us directly/i)
).toBeVisible();
});Testing a Single Fully Booked Day
To test the case where a specific date has no remaining slots (rather than the entire event type having zero availability), you can use the Calendly API to pre-book all slots for a target date in your test setup, then verify that clicking that date shows no available times. This requires API access and should be part of your test setup and teardown, canceling the bookings after the test completes.
8. Scenario: Post-Booking Redirect Verification
Redirect After Booking Confirmation
StraightforwardCalendly supports configuring a redirect URL that the user is sent to after confirming a booking. This is commonly used to redirect to a thank-you page, an onboarding flow, or a page that captures additional information. When the event type has a redirect URL configured, Calendly navigates the browser (or the parent frame) to that URL after the booking confirmation screen. Your test needs to verify that the redirect actually fires and that the destination page loads correctly.
Standalone Redirect
test('post-booking redirect to thank-you page', async ({ page }) => {
const email = `redirect+${Date.now()}@assrt.ai`;
await page.goto(process.env.CALENDLY_EVENT_URL!);
// Complete the booking flow
const availableDate = page.locator(
'button[data-container="date-cell"]:not([disabled])'
).first();
await availableDate.click();
await page.getByTestId('time-button').first().click();
await page.getByRole('button', { name: /next/i }).click();
await page.getByLabel(/^name/i).fill('Redirect Tester');
await page.getByLabel(/email/i).fill(email);
await page.getByRole('button', { name: /schedule event/i }).click();
// Wait for the confirmation screen first
await expect(
page.getByRole('heading', { name: /confirmed/i })
).toBeVisible({ timeout: 15_000 });
// Then wait for the redirect to your configured URL
await page.waitForURL(/\/booking-confirmed/, { timeout: 30_000 });
// Assert the thank-you page loaded correctly
await expect(
page.getByRole('heading', { name: /thank you/i })
).toBeVisible();
// Verify query parameters passed by Calendly
const url = new URL(page.url());
expect(url.searchParams.get('invitee_email')).toBe(email);
});Embedded Redirect
When Calendly is embedded, the redirect behavior depends on how the embed is configured. By default, the redirect navigates the top-level page (your application), not just the iframe. If your embed uses the parentDomain parameter, you can also listen for the calendly.event_scheduled postMessage and handle the redirect in your own JavaScript. Test both paths to make sure one or the other fires reliably.
test('embedded redirect navigates parent page', async ({ page }) => {
const email = `embed-redirect+${Date.now()}@assrt.ai`;
await page.goto('/schedule');
const calendlyFrame = page.frameLocator('iframe[src*="calendly.com"]');
// Complete booking inside iframe
await expect(
calendlyFrame.locator('[data-container="calendar"]')
).toBeVisible({ timeout: 20_000 });
await calendlyFrame.locator(
'button[data-container="date-cell"]:not([disabled])'
).first().click();
await calendlyFrame.getByTestId('time-button').first().click();
await calendlyFrame.getByRole('button', { name: /next/i }).click();
await calendlyFrame.getByLabel(/^name/i).fill('Embed Redirect');
await calendlyFrame.getByLabel(/email/i).fill(email);
await calendlyFrame
.getByRole('button', { name: /schedule event/i })
.click();
// The redirect should navigate the parent page
await page.waitForURL(/\/booking-confirmed/, { timeout: 30_000 });
await expect(
page.getByRole('heading', { name: /thank you/i })
).toBeVisible();
});9. Common Pitfalls That Break Calendly Test Suites
Hard-Coded Dates
The single most common failure in Calendly tests is a hard-coded date. A test that clicks "April 15" will pass today and fail next week when April 15 becomes a past date and Calendly disables the button. Always select dates dynamically by finding the first enabled date cell. If you must target a specific day of the week, compute it from today's date.
Iframe Selector Fragility
Calendly updates its embed widget periodically. The internal DOM structure, class names, and data attributes can change without notice. Using frameLocator('iframe[src*="calendly.com"]') to find the iframe is stable because the src domain will not change. But inside the iframe, prefer getByRole, getByLabel, and getByTestId over CSS class selectors. Calendly provides data-container attributes on key elements, which are more stable than class names.
Race Conditions on Calendar Load
The Calendly calendar fetches availability data from its API after the initial page render. If your test clicks a date cell before the availability data has loaded, the click may land on a cell that becomes disabled a moment later. Always wait for the [data-container="calendar"] element to be visible and then assert the target date cell is not disabled before clicking.
Timezone Drift in CI
CI servers typically run in UTC. If your test expects to see a 9:00 AM slot but the event type's availability is 9:00 AM to 5:00 PM Eastern, the UTC visitor will see times starting at 2:00 PM. Worse, if it is currently 6:00 PM Eastern (11:00 PM UTC), the CI server may see zero availability for the current day. Always set timezoneId in your Playwright config or browser context to a timezone that matches your Calendly account's availability window.
Event Cleanup
Every successful test creates a real booking in your Calendly test account. These accumulate over time and can exhaust your availability slots, causing subsequent tests to fail because no times remain. Add a teardown step that cancels bookings created by the test run using the Calendly API. Tag test bookings with a recognizable email pattern like `test+${timestamp}@assrt.ai` so you can identify and cancel them programmatically.
// global-teardown.ts
import { request } from '@playwright/test';
export default async function globalTeardown() {
const api = await request.newContext({
baseURL: 'https://api.calendly.com',
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.CALENDLY_API_TOKEN}`,
},
});
// List recent scheduled events
const res = await api.get('/scheduled_events', {
params: {
user: process.env.CALENDLY_USER_URI!,
min_start_time: new Date(
Date.now() - 24 * 60 * 60 * 1000
).toISOString(),
status: 'active',
},
});
const { collection } = await res.json();
// Cancel events created by test email addresses
for (const event of collection) {
const inviteesRes = await api.get(
`${event.uri}/invitees`
);
const invitees = await inviteesRes.json();
const isTestBooking = invitees.collection.some(
(inv: any) => inv.email.includes('@assrt.ai')
);
if (isTestBooking) {
await api.post(`${event.uri}/cancellation`, {
data: { reason: 'Automated test cleanup' },
});
}
}
}Flaky Date Navigation
When the current month has no availability (for example, all remaining days are weekends), Calendly automatically navigates to the next month. But if your test tries to click a date before the month navigation animation completes, the click can land on the wrong element. Wait for the calendar to settle by asserting the presence of an enabled date cell before interacting with it. A short toBeVisible assertion with a timeout is more reliable than any fixed delay.
10. Writing These Scenarios in Plain English with Assrt
Every scenario above is 30 to 60 lines of Playwright TypeScript. Multiply that by the six or more scenarios you actually need and you have hundreds of lines of test code that will break the first time Calendly renames a data attribute or restructures their calendar component. Assrt lets you describe the scenario in plain English, generates the equivalent Playwright code, and regenerates the selectors automatically when the underlying widget changes.
The standalone happy path from Section 3 looks like this in Assrt:
# scenarios/calendly-standalone-booking.assrt
describe: Happy path standalone Calendly booking
given:
- I am on the Calendly event page
- I use a fresh random email
steps:
- wait for the calendar to load
- click the first available date
- click the first available time slot
- click "Next"
- fill in name with "Jane Tester"
- fill in email with my random email
- click "Schedule Event"
expect:
- the confirmation heading is visible within 15 seconds
- my email is displayed on the confirmation screenThe embedded iframe scenario from Section 4:
# scenarios/calendly-embedded-booking.assrt
describe: Embedded Calendly booking inside our schedule page
given:
- I am on the /schedule page
- the Calendly widget has loaded inside the iframe
steps:
- inside the Calendly iframe, click the first available date
- inside the Calendly iframe, click the first available time slot
- inside the Calendly iframe, click "Next"
- inside the Calendly iframe, fill name with "Embed Tester"
- inside the Calendly iframe, fill email with a fresh random email
- inside the Calendly iframe, click "Schedule Event"
expect:
- the confirmation heading appears inside the iframe
- the parent page receives a calendly.event_scheduled messageThe timezone verification scenario from Section 6:
# scenarios/calendly-timezone.assrt
describe: Calendly shows correct times for visitor timezone
given:
- my browser timezone is set to Asia/Tokyo
steps:
- go to the Calendly event page
- wait for the calendar to load
expect:
- the timezone label shows "Japan" or "JST" or "Tokyo"
- the first available time slot shows a valid time formatAssrt compiles each of these files into the Playwright TypeScript code you saw in the earlier sections, committed to your repo as real tests you can read, run, and modify. When Calendly updates their DOM structure or renames an internal attribute, Assrt detects the failure, analyzes the new DOM, and opens a pull request with updated locators. Your plain-English scenario file stays unchanged.
Start with the standalone happy path. Once it is green in your CI, add the embedded iframe scenario, then timezone verification, then the intake form test, then the no-availability edge case, then the redirect flow. In a single afternoon you can build complete Calendly booking coverage that would take a week to write and maintain by hand.
Related Guides
How to Test Acuity Scheduling Embed
Step-by-step guide to testing Acuity Scheduling embeds with Playwright. Covers iframe...
How to Test Cal.com Booking
A practical, scenario-by-scenario guide to testing Cal.com booking flows with Playwright....
How to Fix Flaky Tests
Root causes and proven fixes for unreliable tests.
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.