Date, Time, and Timezone Testing: A Complete Guide for Global Apps
Dates and times are where internationalization bugs go to hide. They lurk in timezone conversions, emerge during daylight saving time transitions, and surface in calendar format mismatches that no developer's local test environment ever triggers. This guide covers the full scope of date and time testing for applications that serve users in multiple countries.
Date Format Variations
The most visible date problem is format order. The date that an American writes as 03/04/2025 means March 4th. A British user reads the same string as April 3rd. A Swedish user, following ISO 8601, would write 2025-03-04.
| Locale | Format | Example |
|---|---|---|
en-US |
MM/DD/YYYY | 03/04/2025 |
en-GB |
DD/MM/YYYY | 04/03/2025 |
de-DE |
DD.MM.YYYY | 04.03.2025 |
ja-JP |
YYYY年MM月DD日 | 2025年03月04日 |
zh-CN |
YYYY/MM/DD | 2025/03/04 |
ko-KR |
YYYY년 MM월 DD일 | 2025년 03월 04일 |
| ISO 8601 | YYYY-MM-DD | 2025-03-04 |
The practical implication: never display raw date strings from your database to users. Always format dates using a locale-aware formatter. Always parse user-provided date strings with knowledge of the user's locale.
12-Hour vs 24-Hour Clock
The United States and a handful of other countries use a 12-hour clock with AM/PM. Most of the world uses a 24-hour clock. This seems simple until you consider the edge cases:
12:00 PMmeans noon in 12-hour time, but12:00in 24-hour time is also noon — this one is consistent12:00 AMmeans midnight in 12-hour time, but midnight in 24-hour time is00:00— easy to get wrong12:30 AMmeans 00:30 in 24-hour time — 30 minutes past midnight, not 12:30 at noon
// Testing time format display
import { format } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
describe('time format display', () => {
const midnight = new Date('2025-03-04T00:00:00Z');
const noon = new Date('2025-03-04T12:00:00Z');
const halfPastMidnight = new Date('2025-03-04T00:30:00Z');
test('midnight displays as 12:00 AM in 12h format', () => {
// Assuming UTC display for test isolation
expect(formatTime(midnight, 'en-US')).toBe('12:00 AM');
});
test('midnight displays as 00:00 in 24h format', () => {
expect(formatTime(midnight, 'de-DE')).toBe('00:00');
});
test('noon displays as 12:00 PM in 12h format', () => {
expect(formatTime(noon, 'en-US')).toBe('12:00 PM');
});
test('noon displays as 12:00 in 24h format', () => {
expect(formatTime(noon, 'de-DE')).toBe('12:00');
});
test('00:30 UTC displays as 12:30 AM not 12:30 PM', () => {
expect(formatTime(halfPastMidnight, 'en-US')).toBe('12:30 AM');
});
});The Golden Rule: Store UTC, Display Local
Every date and time value in your database should be stored as UTC. This is not optional. Storing local times leads to ambiguous records (the same wall-clock time can occur twice during a DST fallback), failed daylight saving conversions, and bugs that only appear in specific timezones.
The display layer converts UTC to the user's local timezone. This conversion must happen at display time, not at write time.
// WRONG: storing local time
const createdAt = new Date().toLocaleString(); // "3/4/2025, 2:30:00 PM" — locale-specific string, timezone ambiguous
// RIGHT: storing UTC ISO 8601
const createdAt = new Date().toISOString(); // "2025-03-04T14:30:00.000Z" — unambiguousDisplaying UTC in the User's Timezone
import { formatInTimeZone } from 'date-fns-tz';
export function displayDateTime(utcTimestamp, userTimezone, locale) {
const date = new Date(utcTimestamp);
// Format using the user's timezone
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: userTimezone,
timeZoneName: 'short',
}).format(date);
}
// Tests
describe('displayDateTime', () => {
const utcTimestamp = '2025-03-04T14:30:00.000Z';
test('displays in New York time', () => {
const result = displayDateTime(utcTimestamp, 'America/New_York', 'en-US');
expect(result).toBe('March 4, 2025, 9:30 AM EST');
});
test('displays in Berlin time', () => {
const result = displayDateTime(utcTimestamp, 'Europe/Berlin', 'de-DE');
expect(result).toBe('4. März 2025, 15:30 MEZ');
});
test('displays in Tokyo time', () => {
const result = displayDateTime(utcTimestamp, 'Asia/Tokyo', 'ja-JP');
expect(result).toBe('2025年3月4日 23:30 JST');
});
});Daylight Saving Time Testing
DST transitions are the most dangerous timezone edge cases. Twice a year, the clocks change — and your application must handle the resulting ambiguities correctly.
The Spring Forward (Gap)
When clocks spring forward (e.g., US Eastern time moves from 1:59 AM to 3:00 AM on the second Sunday in March), the times between 2:00 AM and 2:59 AM do not exist. If your code tries to construct a Date object for 2025-03-09T02:30:00 in America/New_York, the result is implementation-defined — it may silently shift to 3:30 AM.
The Fall Back (Ambiguity)
When clocks fall back (e.g., US Eastern moves from 1:59 AM back to 1:00 AM on the first Sunday in November), the times between 1:00 AM and 1:59 AM occur twice — once in EDT (UTC-4) and once in EST (UTC-5). A wall-clock time like 2025-11-02T01:30:00 in America/New_York is ambiguous; it could be either UTC-4 or UTC-5.
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
describe('DST transition handling', () => {
test('spring-forward gap: 2:30 AM does not exist in ET on 2025-03-09', () => {
// Attempting to create this time should either throw or shift
// The correct behavior depends on your application: reject it or normalize it
const nonExistentTime = '2025-03-09T02:30:00';
const tz = 'America/New_York';
// date-fns-tz will shift the non-existent time to the next valid time
const utcDate = zonedTimeToUtc(nonExistentTime, tz);
const inET = utcToZonedTime(utcDate, tz);
// The normalized result should be 3:30 AM, not 2:30 AM
expect(inET.getHours()).toBe(3);
expect(inET.getMinutes()).toBe(30);
});
test('fall-back ambiguity: 1:30 AM occurs twice on 2025-11-02', () => {
// The FIRST occurrence is in EDT (UTC-4): UTC 05:30
const firstOccurrence = new Date('2025-11-02T05:30:00Z');
const tz = 'America/New_York';
const localFirst = utcToZonedTime(firstOccurrence, tz);
// The SECOND occurrence is in EST (UTC-5): UTC 06:30
const secondOccurrence = new Date('2025-11-02T06:30:00Z');
const localSecond = utcToZonedTime(secondOccurrence, tz);
// Both display as 1:30 AM locally — this is the ambiguity
expect(localFirst.getHours()).toBe(1);
expect(localSecond.getHours()).toBe(1);
// But they are different UTC moments
expect(firstOccurrence.getTime()).not.toBe(secondOccurrence.getTime());
});
test('scheduled job at 2:30 AM should not run twice or be skipped during DST', () => {
// This is a business logic test: verify your scheduler handles DST
// by testing the UTC times it generates around the transition
const scheduler = new CronScheduler({ tz: 'America/New_York' });
const nextRuns = scheduler.getNextRuns('30 2 * * *', { count: 10 });
// Around the fall-back, verify no time is duplicated
const uniqueTimes = new Set(nextRuns.map(d => d.getTime()));
expect(uniqueTimes.size).toBe(nextRuns.length);
// Around the spring-forward, verify the gap time is handled
// (the 2:30 AM run should shift to 3:30 AM or be skipped, per your policy)
const dstTransitionDate = new Date('2025-03-09T00:00:00Z');
const runsAroundDST = nextRuns.filter(d => {
const diff = Math.abs(d.getTime() - dstTransitionDate.getTime());
return diff < 24 * 60 * 60 * 1000;
});
expect(runsAroundDST.length).toBeGreaterThan(0);
});
});Countries That Don't Observe DST
Not all countries observe daylight saving time. Arizona (US) does not. Most of Africa does not. China, Japan, and South Korea do not. Russia abolished DST in 2014. Testing against a diverse set of IANA timezone identifiers is important — do not assume that UTC+X is a valid substitute for an IANA timezone name, because offset-based timezones do not carry DST rules.
// WRONG: using offset-based timezone
const tz = 'UTC+5'; // This is always UTC+5, never adjusts for DST
// RIGHT: using IANA timezone identifier
const tz = 'Asia/Karachi'; // Pakistan Standard Time, always UTC+5, no DST — but explicit
const tz2 = 'America/New_York'; // Eastern Time, adjusts for DST automaticallyCalendar System Differences
The Gregorian calendar is not universal. Several calendar systems are in active use:
| Calendar | Used in | Notes |
|---|---|---|
| Gregorian | Global (default) | ISO 8601 standard |
| Islamic (Hijri) | Saudi Arabia, Islamic contexts | Lunar, ~11 days shorter than Gregorian year |
| Hebrew | Israel (religious/civil) | Lunisolar calendar |
| Persian (Jalali) | Iran, Afghanistan | Solar Hijri |
| Ethiopian | Ethiopia, Eritrea | 13 months, ~7 years behind Gregorian |
| Buddhist | Thailand | Same structure as Gregorian, +543 years |
The Intl.DateTimeFormat API supports multiple calendar systems through its calendar option:
const date = new Date('2025-03-04T00:00:00Z');
// Gregorian
new Intl.DateTimeFormat('en-US', { calendar: 'gregory' }).format(date);
// → "3/4/2025"
// Islamic calendar
new Intl.DateTimeFormat('ar-SA', { calendar: 'islamic' }).format(date);
// → "٤/٩/١٤٤٦ هـ"
// Buddhist calendar (used in Thailand)
new Intl.DateTimeFormat('th-TH', { calendar: 'buddhist' }).format(date);
// → "4/3/2568" (2025 + 543)
// Hebrew calendar
new Intl.DateTimeFormat('he-IL', { calendar: 'hebrew' }).format(date);
// → "ד׳ באדר א׳ תשפ״ה"If your application operates in Saudi Arabia, Thailand, or Israel, your date display tests must include these calendar systems.
Week Start Day Differences
Most countries start the week on Monday. The United States, Canada, Mexico, Japan, and several others start on Sunday. Saudi Arabia starts on Saturday.
This affects calendar widgets, week-of-year calculations, and any UI that displays "this week" date ranges.
describe('week start day', () => {
test('en-US week starts on Sunday', () => {
const firstDayOfWeek = getFirstDayOfWeek('en-US');
expect(firstDayOfWeek).toBe(0); // 0 = Sunday
});
test('de-DE week starts on Monday', () => {
const firstDayOfWeek = getFirstDayOfWeek('de-DE');
expect(firstDayOfWeek).toBe(1); // 1 = Monday
});
test('weekly report "this week" includes correct days for each locale', () => {
// For a report generated on Wednesday 2025-03-05:
// en-US "this week" = Sun Mar 2 - Sat Mar 8
// de-DE "this week" = Mon Mar 3 - Sun Mar 9
const wednesday = new Date('2025-03-05T12:00:00Z');
const usWeek = getWeekRange(wednesday, 'en-US');
expect(usWeek.start).toEqual(new Date('2025-03-02T00:00:00')); // Sunday
const deWeek = getWeekRange(wednesday, 'de-DE');
expect(deWeek.start).toEqual(new Date('2025-03-03T00:00:00')); // Monday
});
});Relative Time Display
"Posted 2 hours ago" is a common UI pattern that requires both locale-aware formatting and careful timezone handling.
export function formatRelativeTime(utcTimestamp, locale) {
const now = Date.now();
const then = new Date(utcTimestamp).getTime();
const diffSeconds = Math.round((now - then) / 1000);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (Math.abs(diffSeconds) < 60) return rtf.format(-diffSeconds, 'second');
if (Math.abs(diffSeconds) < 3600) return rtf.format(-Math.round(diffSeconds / 60), 'minute');
if (Math.abs(diffSeconds) < 86400) return rtf.format(-Math.round(diffSeconds / 3600), 'hour');
return rtf.format(-Math.round(diffSeconds / 86400), 'day');
}
describe('formatRelativeTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-03-04T12:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
test('2 hours ago in English', () => {
const twoHoursAgo = '2025-03-04T10:00:00Z';
expect(formatRelativeTime(twoHoursAgo, 'en-US')).toBe('2 hours ago');
});
test('2 hours ago in German', () => {
const twoHoursAgo = '2025-03-04T10:00:00Z';
expect(formatRelativeTime(twoHoursAgo, 'de-DE')).toBe('vor 2 Stunden');
});
test('yesterday in English uses "yesterday" not "1 day ago"', () => {
const yesterday = '2025-03-03T12:00:00Z';
expect(formatRelativeTime(yesterday, 'en-US')).toBe('yesterday');
});
});Testing the Temporal API
The TC39 Temporal API is the modern replacement for JavaScript's Date object. It provides explicit timezone support, calendar system awareness, and unambiguous handling of DST transitions. If your team is adopting Temporal, here is how timezone and DST tests look:
import { Temporal } from '@js-temporal/polyfill';
describe('Temporal API timezone handling', () => {
test('ZonedDateTime preserves timezone through DST', () => {
// Create a time in Eastern Time
const et = Temporal.ZonedDateTime.from({
year: 2025, month: 3, day: 10, hour: 10, minute: 30,
timeZone: 'America/New_York',
});
// After DST, the UTC offset should have changed
expect(et.offset).toBe('-04:00'); // EDT, not EST
// Same wall-clock time one week before (still in EST)
const etBefore = Temporal.ZonedDateTime.from({
year: 2025, month: 3, day: 2, hour: 10, minute: 30,
timeZone: 'America/New_York',
});
expect(etBefore.offset).toBe('-05:00'); // EST
});
test('PlainDateTime is explicitly timezone-free', () => {
// PlainDateTime cannot be compared directly to a moment in time
// This makes the ambiguity explicit rather than silently wrong
const dt = Temporal.PlainDateTime.from('2025-03-04T14:30:00');
expect(dt.toString()).toBe('2025-03-04T14:30:00');
// You cannot call dt.toInstant() without supplying a timezone
});
});Comprehensive Test Checklist
Before releasing date/time features:
Storage:
- All timestamps stored as UTC in the database
- No local timezone strings stored as raw data
- Timestamp columns use timezone-aware types (e.g.,
TIMESTAMP WITH TIME ZONEin Postgres)
Display:
- Date format adapts to user locale (MM/DD vs DD/MM vs YYYY-MM-DD)
- 12h vs 24h clock respects locale preference
- Displayed times use the user's timezone, not the server's
- Calendar system tested if Islamic/Hebrew/Buddhist locales are in scope
- Week start day correct for each locale's calendar widgets
DST:
- Spring-forward gap handled (non-existent times normalized or rejected)
- Fall-back ambiguity handled (UTC times, not wall-clock times, are authoritative)
- Scheduled jobs tested around DST transition dates
- IANA timezone names used, not UTC offset strings
Relative time:
- "X ago" text uses Intl.RelativeTimeFormat, not hardcoded English
- "Yesterday" / "last week" uses locale-aware phrasing
Input:
- Date pickers emit locale-aware dates that parse unambiguously
- User-entered date strings parsed with explicit format or locale knowledge
Timezone bugs are the engineering equivalent of landmines: invisible until someone steps on one, expensive to fix after the fact. A disciplined set of tests covering UTC storage, locale-aware display, and DST transitions will prevent the majority of production incidents — and spare your on-call engineer a very confusing 2 AM alert.