Date, Time, and Timezone Testing: A Complete Guide for Global Apps

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 PM means noon in 12-hour time, but 12:00 in 24-hour time is also noon — this one is consistent
  • 12:00 AM means midnight in 12-hour time, but midnight in 24-hour time is 00:00 — easy to get wrong
  • 12:30 AM means 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" — unambiguous

Displaying 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 automatically

Calendar 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 ZONE in 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.

Read more