Tutorial

タイムスタンプ テスト戦略: 完全ガイド

はじめに

時間に依存するコードのテストは難しいことで知られています。時間は継続的に流れ、タイムゾーンは変化し、夏時間移行などの特殊なケースによって複雑なシナリオが作成されます。このチュートリアルでは、時間のモック化、エッジ ケースのテスト、タイムゾーン間での信頼性の確保など、タイムスタンプ関連のコードをテストするための包括的な戦略を提供します。

タイムスタンプのテストが難しい理由

主要な課題

  1. 時間は進み続けます - 異なる時間にテストを実行すると、異なる結果が得られます
  2. タイムゾーンの複雑さ - DST 移行、オフセット変更、過去のタイムゾーン データ
  3. 特殊なケース - うるう秒、年の境界、無効な日付
  4. 非同期操作 - タイマー、遅延、時間依存の副作用
  5. 環境の依存関係 - システムのタイムゾーン、ロケール設定

よくある問題

// ❌ Non-deterministic test - fails at certain times
test('event is in the future', () => {
  const event = new Date('2024-12-31T23:59:59Z');
  expect(event > new Date()).toBe(true); // Fails after Dec 31, 2024!
});

// ❌ Timezone-dependent test - fails in different timezones
test('gets current day', () => {
  const day = new Date().getDay();
  expect(day).toBe(2); // Only passes on Tuesdays in local timezone!
});

戦略 1: 模擬時間

Jest を使用した JavaScript

依存関係をインストールします:

npm install --save-dev jest @sinonjs/fake-timers

基本的な時間モック

describe('Timestamp Tests with Mocked Time', () => {
  beforeEach(() => {
    // Set fake time to a fixed date
    jest.useFakeTimers();
    jest.setSystemTime(new Date('2024-01-15T12:00:00Z'));
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('getCurrentTimestamp returns mocked time', () => {
    const timestamp = Date.now();
    expect(timestamp).toBe(new Date('2024-01-15T12:00:00Z').getTime());
  });

  test('time advances with runTimersToTime', () => {
    const start = Date.now();

    jest.advanceTimersByTime(1000); // Advance 1 second

    const end = Date.now();
    expect(end - start).toBe(1000);
  });
});

上級: スケジュールされた操作のテスト

function scheduleReport(callback, delayMs) {
  setTimeout(() => {
    const timestamp = new Date().toISOString();
    callback({ timestamp, report: 'Generated' });
  }, delayMs);
}

test('schedules report correctly', () => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2024-01-15T12:00:00Z'));

  const callback = jest.fn();
  scheduleReport(callback, 5000);

  // Fast-forward time
  jest.advanceTimersByTime(5000);

  expect(callback).toHaveBeenCalledWith({
    timestamp: '2024-01-15T12:00:05.000Z',
    report: 'Generated'
  });

  jest.useRealTimers();
});

pytest とフリーズガンを使用した Python

依存関係をインストールします:

pip install pytest freezegun

基本的な時間の凍結

import pytest
from datetime import datetime
from freezegun import freeze_time

@freeze_time("2024-01-15 12:00:00")
def test_current_timestamp():
    """Test with frozen time."""
    now = datetime.now()
    assert now.year == 2024
    assert now.month == 1
    assert now.day == 15
    assert now.hour == 12

@freeze_time("2024-01-15 12:00:00")
def test_timestamp_calculation():
    """Test calculations with frozen time."""
    from datetime import timedelta

    now = datetime.now()
    future = now + timedelta(hours=1)

    assert future.hour == 13
    assert (future - now).total_seconds() == 3600

タイムトラベルのテスト

from freezegun import freeze_time
from datetime import datetime, timedelta

def test_time_travel():
    """Test by moving through time."""
    initial_time = datetime(2024, 1, 15, 12, 0, 0)

    with freeze_time(initial_time) as frozen_time:
        assert datetime.now() == initial_time

        # Move forward 1 hour
        frozen_time.move_to(initial_time + timedelta(hours=1))
        assert datetime.now().hour == 13

        # Move forward 1 day
        frozen_time.move_to(initial_time + timedelta(days=1))
        assert datetime.now().day == 16

時間インターフェイスを使用する

Go では、テスト可能なタイム コードに依存関係の挿入を使用します。

package timeutil

import "time"

// TimeProvider interface allows mocking
type TimeProvider interface {
    Now() time.Time
}

// RealTime uses actual system time
type RealTime struct{}

func (RealTime) Now() time.Time {
    return time.Now()
}

// MockTime allows setting fixed time
type MockTime struct {
    CurrentTime time.Time
}

func (m *MockTime) Now() time.Time {
    return m.CurrentTime
}

// EventScheduler uses TimeProvider
type EventScheduler struct {
    timer TimeProvider
}

func (es *EventScheduler) IsEventInFuture(eventTime time.Time) bool {
    return eventTime.After(es.timer.Now())
}

// Test file
func TestEventScheduler(t *testing.T) {
    mockTime := &MockTime{
        CurrentTime: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC),
    }

    scheduler := &EventScheduler{timer: mockTime}

    futureEvent := time.Date(2024, 1, 15, 13, 0, 0, 0, time.UTC)
    pastEvent := time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)

    if !scheduler.IsEventInFuture(futureEvent) {
        t.Error("Future event should be in future")
    }

    if scheduler.IsEventInFuture(pastEvent) {
        t.Error("Past event should not be in future")
    }
}

戦略 2: DST 移行をテストする

Spring Forward のテスト

import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';

describe('DST Spring Forward Tests', () => {
  test('handles missing hour correctly', () => {
    // March 10, 2024, 2:00 AM doesn't exist in New York
    const timezone = 'America/New_York';

    // Try to create 2:30 AM (missing hour)
    const missingHour = new Date('2024-03-10T02:30:00');
    const utcTime = zonedTimeToUtc(missingHour, timezone);
    const localTime = utcToZonedTime(utcTime, timezone);

    // Should be adjusted to 3:30 AM
    expect(localTime.getHours()).toBe(3);
    expect(localTime.getMinutes()).toBe(30);
  });

  test('duration calculation on spring forward day', () => {
    const start = new Date('2024-03-10T00:00:00-05:00'); // EST
    const end = new Date('2024-03-10T23:59:59-04:00');   // EDT

    const hours = (end - start) / 3600000;
    expect(hours).toBeCloseTo(23, 0); // Day is only 23 hours
  });
});

フォールバックのテスト

import pytest
import pytz
from datetime import datetime

def test_fall_back_duplicate_hour():
    """Test handling of duplicate hour during fall back."""
    ny_tz = pytz.timezone('America/New_York')

    # November 3, 2024, 1:30 AM occurs twice
    # First occurrence (EDT)
    first = ny_tz.localize(datetime(2024, 11, 3, 1, 30), is_dst=True)

    # Second occurrence (EST)
    second = ny_tz.localize(datetime(2024, 11, 3, 1, 30), is_dst=False)

    # Should be 1 hour apart
    diff = (second - first).total_seconds()
    assert diff == 3600  # 1 hour

def test_fall_back_day_duration():
    """Test that fall back day is 25 hours."""
    ny_tz = pytz.timezone('America/New_York')

    start = ny_tz.localize(datetime(2024, 11, 3, 0, 0, 0))
    end = ny_tz.localize(datetime(2024, 11, 3, 23, 59, 59))

    duration_hours = (end - start).total_seconds() / 3600
    assert duration_hours > 24  # Day is longer than 24 hours

Strategy 3: Test Timezone Conversions

パラメータ化されたテスト

import pytest
import pytz
from datetime import datetime

@pytest.mark.parametrize("utc_time,timezone,expected_hour", [
    ("2024-01-15 12:00:00", "America/New_York", 7),   # EST: UTC-5
    ("2024-01-15 12:00:00", "Europe/London", 12),      # GMT: UTC+0
    ("2024-01-15 12:00:00", "Asia/Tokyo", 21),         # JST: UTC+9
    ("2024-01-15 12:00:00", "Australia/Sydney", 23),   # AEDT: UTC+11
])
def test_timezone_conversion(utc_time, timezone, expected_hour):
    """Test UTC to timezone conversion."""
    utc = pytz.UTC
    tz = pytz.timezone(timezone)

    dt_utc = datetime.strptime(utc_time, "%Y-%m-%d %H:%M:%S").replace(tzinfo=utc)
    dt_local = dt_utc.astimezone(tz)

    assert dt_local.hour == expected_hour

戦略 4: エッジケースをテストする

年の境界

describe('Year Boundary Tests', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  test('handles new year transition', () => {
    // Set time to 1 second before new year
    jest.setSystemTime(new Date('2023-12-31T23:59:59Z'));

    const beforeYear = new Date().getFullYear();
    expect(beforeYear).toBe(2023);

    // Advance 2 seconds
    jest.advanceTimersByTime(2000);

    const afterYear = new Date().getFullYear();
    expect(afterYear).toBe(2024);
  });

  test('calculates days correctly across year boundary', () => {
    const dec31 = new Date('2023-12-31T12:00:00Z');
    const jan1 = new Date('2024-01-01T12:00:00Z');

    const days = (jan1 - dec31) / (1000 * 60 * 60 * 24);
    expect(days).toBe(1);
  });
});

うるう年

import pytest
from datetime import datetime

@pytest.mark.parametrize("year,is_leap", [
    (2020, True),   # Divisible by 4
    (2021, False),  # Not divisible by 4
    (2000, True),   # Divisible by 400
    (1900, False),  # Divisible by 100 but not 400
])
def test_leap_year_detection(year, is_leap):
    """Test leap year detection."""
    try:
        # Feb 29 exists only in leap years
        datetime(year, 2, 29)
        assert is_leap
    except ValueError:
        assert not is_leap

def test_leap_year_calculations():
    """Test calculations involving leap years."""
    # 2020 is a leap year (366 days)
    year_start = datetime(2020, 1, 1)
    year_end = datetime(2020, 12, 31, 23, 59, 59)

    days = (year_end - year_start).days
    assert days == 365  # .days doesn't include the last partial day

    # 2021 is not a leap year (365 days)
    year_start = datetime(2021, 1, 1)
    year_end = datetime(2021, 12, 31, 23, 59, 59)

    days = (year_end - year_start).days
    assert days == 364

戦略 5: 統合テスト

リアルタイム タイムゾーン データベースを使用したテスト

// Test that uses actual timezone data
describe('Real Timezone Tests', () => {
  test('correctly handles all US DST transitions in 2024', () => {
    const transitions = [
      { date: '2024-03-10T07:00:00.000Z', type: 'spring forward' },
      { date: '2024-11-03T06:00:00.000Z', type: 'fall back' }
    ];

    transitions.forEach(({ date, type }) => {
      const transitionTime = new Date(date);

      // Test that we can detect the transition
      const before = new Date(transitionTime.getTime() - 3600000);
      const after = new Date(transitionTime.getTime() + 3600000);

      const beforeOffset = before.getTimezoneOffset();
      const afterOffset = after.getTimezoneOffset();

      if (type === 'spring forward') {
        expect(beforeOffset).toBeGreaterThan(afterOffset);
      } else {
        expect(beforeOffset).toBeLessThan(afterOffset);
      }
    });
  });
});

ベストプラクティス

1. 内部では常に UTC を使用する

class EventManager {
  createEvent(localTime, timezone) {
    // Store in UTC
    const utcTime = zonedTimeToUtc(localTime, timezone);
    return {
      utc_timestamp: utcTime.getTime(),
      display_timezone: timezone
    };
  }

  displayEvent(event, timezone) {
    // Convert to display timezone only when needed
    const localTime = utcToZonedTime(event.utc_timestamp, timezone);
    return localTime;
  }
}

// Test
test('events store and display correctly', () => {
  const manager = new EventManager();

  // Create event in New York time
  const event = manager.createEvent(
    new Date('2024-01-15T15:00:00'),
    'America/New_York'
  );

  // Display in Tokyo time
  const tokyoTime = manager.displayEvent(event, 'Asia/Tokyo');

  // Should be next day in Tokyo (14 hours ahead)
  expect(tokyoTime.getDate()).toBe(16);
});

2. テストデータの境界

import pytest
from datetime import datetime

def test_timestamp_boundaries():
    """Test minimum and maximum timestamp values."""
    # Unix timestamp epoch
    epoch = datetime.fromtimestamp(0)
    assert epoch.year == 1970

    # Year 2038 problem (32-bit signed int overflow)
    max_32bit = datetime.fromtimestamp(2147483647)
    assert max_32bit.year == 2038

    # Negative timestamps (before epoch)
    before_epoch = datetime.fromtimestamp(-86400)  # 1 day before epoch
    assert before_epoch.year == 1969

3. テストフィクスチャを使用する

import pytest
from datetime import datetime
import pytz

@pytest.fixture
def fixed_time():
    """Provide a fixed time for tests."""
    return datetime(2024, 1, 15, 12, 0, 0, tzinfo=pytz.UTC)

@pytest.fixture
def dst_transition_dates():
    """Provide DST transition dates."""
    return {
        'spring_forward': datetime(2024, 3, 10, 2, 0, 0),
        'fall_back': datetime(2024, 11, 3, 2, 0, 0)
    }

def test_with_fixtures(fixed_time, dst_transition_dates):
    """Use fixtures in tests."""
    assert fixed_time.year == 2024
    assert len(dst_transition_dates) == 2

よくある落とし穴

避けてください:

  • 現在時刻でのテスト (モックなしの new Date())
  • テストが特定のタイムゾーンで実行されることを想定
  • テストでの DST 移行の無視
  • 無効になるハードコーディングされた日付
  • エッジケース(うるう年、年の境界)をテストしていない

実行してください:

  • テストでは常に模擬時間を使う
  • 複数のタイムゾーンにわたるテスト
  • テスト ケースに DST 移行日を含めます
  • 可能な場合は相対日付を使用してください
  • 典型的なケースと特殊なケースの両方をテストする

概要

主要なテスト戦略

  1. モックタイム - Jest の偽のタイマー、フリーズガン、または依存関係の注入を使用します。
  2. DST のテスト - スプリングフォワードとフォールバックのシナリオを含めます
  3. タイムゾーンのテスト - 複数のタイムゾーンにわたるコンバージョンを確認します
  4. エッジケースのテスト - 年の境界、閏年、無効な日付
  5. 内部的に UTC を使用 - UTC で保存し、表示のみに変換します

推奨ツール

JavaScript:

  • @sinonjs/fake-timers を使った冗談
  • タイムゾーンテスト用の「date-fns-tz」
  • 包括的な日時処理用の luxon

パイソン:

  • テストフレームワーク用の「pytest」
  • 時間を嘲笑するための「フリーズガン」
  • タイムゾーンテスト用の「pytz」

行く:

  • 時間インターフェイスによる依存関係の注入
  • パラメータ化のためのテーブル駆動テスト

関連リソース