Tutorial
タイムスタンプ テスト戦略: 完全ガイド
はじめに
時間に依存するコードのテストは難しいことで知られています。時間は継続的に流れ、タイムゾーンは変化し、夏時間移行などの特殊なケースによって複雑なシナリオが作成されます。このチュートリアルでは、時間のモック化、エッジ ケースのテスト、タイムゾーン間での信頼性の確保など、タイムスタンプ関連のコードをテストするための包括的な戦略を提供します。
タイムスタンプのテストが難しい理由
主要な課題
- 時間は進み続けます - 異なる時間にテストを実行すると、異なる結果が得られます
- タイムゾーンの複雑さ - DST 移行、オフセット変更、過去のタイムゾーン データ
- 特殊なケース - うるう秒、年の境界、無効な日付
- 非同期操作 - タイマー、遅延、時間依存の副作用
- 環境の依存関係 - システムのタイムゾーン、ロケール設定
よくある問題
// ❌ 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 移行日を含めます
- 可能な場合は相対日付を使用してください
- 典型的なケースと特殊なケースの両方をテストする
概要
主要なテスト戦略
- モックタイム - Jest の偽のタイマー、フリーズガン、または依存関係の注入を使用します。
- DST のテスト - スプリングフォワードとフォールバックのシナリオを含めます
- タイムゾーンのテスト - 複数のタイムゾーンにわたるコンバージョンを確認します
- エッジケースのテスト - 年の境界、閏年、無効な日付
- 内部的に UTC を使用 - UTC で保存し、表示のみに変換します
推奨ツール
JavaScript:
@sinonjs/fake-timersを使った冗談- タイムゾーンテスト用の「date-fns-tz」
- 包括的な日時処理用の
luxon
パイソン:
- テストフレームワーク用の「pytest」
- 時間を嘲笑するための「フリーズガン」
- タイムゾーンテスト用の「pytz」
行く:
- 時間インターフェイスによる依存関係の注入
- パラメータ化のためのテーブル駆動テスト