How to Test Travel Booking Flows (Hotels, Flights, Cancellations)
Short answer
Travel E2E fails when tests pick calendar dates in the past, ignore timezone on check-in midnight, or assert confirmation banners while inventory holds expired. Seed availability per runId, freeze clock/timezone in Playwright, probe booking PNR/status rows, and cover cancellation refunds asynchronously—not just search result card counts.
Part of Testing Guides by industry.
Who this is for
OTA, corporate travel, vacation rental, and airline retail teams shipping hotel/flight search, hold-to-book, multi-room carts, and cancellation flows. Pain points: midnight boundary bugs, hold timers, GDS sandbox limits, and refund webhooks lagging UI.
Why travel booking needs different Arrange/Assert
| Layer | Pitfall | E2E fix |
|---|---|---|
| Inventory holds | Cart expires during checkout | Seed long hold or freeze timer in test env |
| Timezone | Check-in date shifts UTC | timezoneId + fixed clock in Playwright |
| Fare rules | Non-refundable fare in cart | Seed fare class metadata; probe refund policy |
| Parallel CI | Same room night booked twice | runId on inventory blocks |
| Cancellation | UI cancelled; GDS still active | Probe supplier status + refund row |
| Multi-city | Leg order wrong | Probe itinerary segments array |
Confirmation emails and PDF tickets are supplements—booking row status is authoritative.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Date picker past dates | Default "today" in CI TZ | Invalid search | Freeze clock; seed future check-in |
| Midnight check-in | Property local TZ | Off-by-one night | timezoneId: 'America/New_York' |
| Inventory hold TTL | 15-min hold expires | Sold out at pay | Extend hold in E2E env; probe hold_id |
| Double booking | Two workers same room | One succeeds one 409 | Per-run room block seed |
| Flight + hotel bundle | Partial failure rollback | Orphan hotel | Probe both legs or neither |
| Seat map | Dynamic seat taken | Random fail | Seed locked seats for runId |
| Fare change | Price drift during checkout | Stale total | Probe quoted vs charged amount |
| Cancellation window | Free cancel until 6pm | Wrong refund | Seed policy; probe refund_eligible |
| Non-refundable | Cancel blocked | UI allows anyway | Probe status remains confirmed |
| Supplier async | GDS pending → confirmed | Assert too early | expect.poll booking status |
| Multi-room | Different guest names | Wrong assignment | Probe room_guest rows |
| Currency display | FX vs charge currency | Wrong symbol | Probe settlement currency |
| Loyalty points | Post-book accrual lag | Missing points UI | Poll loyalty ledger probe |
| Timezone DST | Spring forward night | Invalid local time | Use UTC instants in seed |
| Waitlist / overbook | Upgrade path | Flaky availability | Seed deterministic inventory API |
Seed pattern (inventory + booking probe)
// app/api/test/seed-travel-inventory/route.ts
export async function POST(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { runId, propertyId, checkIn, checkOut, rooms = 1 } = await req.json();
const blockId = `hold-${runId}`;
await inventory.createHold({
blockId,
propertyId,
checkIn, // ISO instant in property TZ
checkOut,
rooms,
run_id: runId,
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1h for E2E
});
return NextResponse.json({ blockId, runId, checkIn, checkOut });
}
// app/api/test/probe-booking/route.ts
export async function GET(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const runId = new URL(req.url).searchParams.get('runId');
const booking = await db.bookings.findFirst({ where: { run_id: runId } });
return NextResponse.json({
runId,
status: booking?.status ?? 'none',
pnr: booking?.pnr ?? null,
refundStatus: booking?.refund_status ?? null,
});
}
Playwright spec — hotel book and cancel
// @Scenario: travel/hotel-refundable-cancel
import { test, expect } from '../fixtures/run';
test.use({
timezoneId: 'America/Los_Angeles',
locale: 'en-US',
});
test('refundable hotel books then cancels with refund pending', async ({ page, request, runId }) => {
await page.clock.install({ time: new Date('2026-08-01T10:00:00-07:00') });
const checkIn = '2026-08-15';
const checkOut = '2026-08-17';
await request.post('/api/test/seed-travel-inventory', {
data: { runId, propertyId: 'hotel-seed-1', checkIn, checkOut },
});
await page.goto(`/hotels/hotel-seed-1?checkIn=${checkIn}&checkOut=${checkOut}`);
await page.getByRole('button', { name: 'Reserve' }).click();
await page.getByLabel('Guest name').fill(`Guest ${runId}`);
await page.getByRole('button', { name: 'Confirm booking' }).click();
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-booking?runId=${runId}`);
return (await res.json()).status;
}, { timeout: 60_000 }).toBe('confirmed');
await page.getByRole('button', { name: 'Cancel reservation' }).click();
await page.getByRole('button', { name: 'Confirm cancellation' }).click();
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-booking?runId=${runId}`);
const body = await res.json();
return body.status === 'cancelled' && body.refundStatus === 'pending';
}, { timeout: 45_000 }).toBe(true);
});
See date, time, and timezones for clock patterns and Stripe payments when checkout uses card holds.
Flights-specific notes
- Seed fixed itinerary in sandbox GDS or stub supplier API with
runIdPNR prefix. - Assert segment order and baggage fare basis via probe JSON—not only airport codes in UI.
- 24h DOT cancel scenarios need separate policy seed from hotel refundable rules.
Hotels and vacation rentals
- Distinguish instant book vs request to book—probe host approval state.
- Cleaning fees and tax lines must match probe
line_itemstotal. - Channel manager sync lag: poll availability probe after seed, similar to Algolia index sync.
TestChimp workflow
Tag specs // @Scenario: travel/hotel-cancel for markdown linkage. /testchimp plan helps author cancellation edge cases (non-refundable, partial refund). /testchimp test keeps date and probe Assert when calendar components refactor. Use TrueCoverage on search → book → cancel funnels.
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Hard-coded "next Friday" dates | Becomes past in CI | Frozen clock + ISO seed dates |
| UI confirmation only | Supplier pending | Probe booking status |
| Shared sandbox PNR | Parallel collision | runId-scoped inventory |
| Ignore property TZ | Wrong night count | timezoneId in test.use |
| Skip cancellation specs | Revenue leakage on refunds | Probe refund_status |
| Assert email PDF | Async delivery flake | Probe booking row first |
| Real GDS in every PR | Cost + flake | Stub supplier in CI; nightly real |
| No hold extension | Expired mid-checkout | E2E env longer TTL |
External references
- Playwright clock
- Playwright timezone emulation
- IANA time zone database
- Hotel booking UX — WCAG date inputs
Example scenario
Situation: Traveler books refundable hotel for two nights then cancels before deadline.
Expected outcome: Booking confirmed then cancelled; refund initiated; inventory released for dates.
Why UI-only automation breaks: Cancellation modal shows success while supplier PNR still active—customer charged.
- Arrange: Seed inventory hold and refundable rate for runId with fixed check-in dates.
- Act: Search property; complete guest details; confirm; cancel from trips page.
- Assert: Poll probe-booking status cancelled + refund pending; optional inventory probe shows availability restored.
TestChimp workflow: // @Scenario: links hotel cancel spec to markdown; /testchimp test preserves timezone clock and probe Assert on calendar refactors.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Related
- Date, time, and timezones
- Ecommerce checkout flows
- Stripe payments
- Returns and refunds
- Calendar and scheduling
Frequently asked questions
How to pick stable hotel dates in CI?
Freeze Playwright clock and seed check-in/out ISO strings relative to frozen now. Never use bare new Date() in specs without clock.install.
Property timezone vs user browser TZ?
Set timezoneId to property region for check-in midnight cases. Probe stored instants in UTC with property TZ metadata.
How to test inventory hold expiry?
Advance clock past hold TTL in dedicated scenario; expect sold-out or re-hold flow. Probe hold row deleted or expired.
Flight sandbox without real GDS costs?
Stub supplier API returning runId PNR in CI; run one nightly job against sandbox credentials if required.
Cancellation refund async?
Poll probe refund_status pending → completed like webhook patterns. UI refund timeline is supplementary.
Multi-room different policies?
Seed mixed refundable/non-refundable rooms; probe per-room cancellation outcome—not single banner.
Bundle flight+hotel partial failure?
Probe transactional rollback: neither confirmed or compensating cancel on succeeded leg. Critical scenario for markdown plan.
TestChimp with travel flows?
/testchimp plan authors hold/cancel matrices; // @Scenario links specs; TrueCoverage prioritizes book and cancel paths users hit in ExploreChimp.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.