Testing Time-Dependent Code: Strategies and Best Practices
Ever written a test that passed at 2pm but failed at 3pm? Or worse—a test that works perfectly on your machine but breaks in CI because the servers are in different timezones? Yeah, me too. Time-dependent code is genuinely one of the trickiest things to test well.
The problem is that time keeps moving (annoying, I know). Tests that pass today might fail tomorrow. Timestamps in tests become stale. And reproducing that bug that only happens during DST transitions? Good luck with that.
Let's talk about how to actually test this stuff without losing your mind.
The Challenge
Why is testing time-dependent code so painful? Let me count the ways.
Why Time Makes Testing Hard
1. Non-Determinism
// ❌ BAD: Test depends on current time
test('user session expires after 1 hour', () => {
const session = createSession();
// This test will fail in 1 hour!
expect(session.isExpired()).toBe(false);
});
2. Long Wait Times
// ❌ BAD: Test takes 1 hour to run
test('cache expires after 1 hour', async () => {
cache.set('key', 'value', { ttl: 3600 });
// Can't wait 1 hour in every test run!
await sleep(3600000);
expect(cache.get('key')).toBeUndefined();
});
3. DST Transitions
// ❌ BAD: Test only fails twice a year
test('handles DST spring forward', () => {
const date = new Date('2024-03-10T02:30:00');
// Only detects issues during actual DST transition
expect(isValidTime(date)).toBe(true);
});
4. Timezone Dependencies
// ❌ BAD: Test passes in one timezone, fails in another
test('formats date correctly', () => {
const date = new Date('2024-01-15T19:00:00Z');
// Fails for developers in different timezones
expect(formatDate(date)).toBe('January 15, 2024');
});
Time Mocking Strategies
Alright, enough complaining. How do we actually fix this? There are three main strategies, and you'll probably use all of them.
Strategy 1: Inject Time Dependencies
First rule of testable time-dependent code: don't call Date.now()
directly. Ever.
// ❌ BAD: Hard to test
class Session {
constructor(expiresIn) {
this.expiresAt = Date.now() + expiresIn;
}
isExpired() {
return Date.now() > this.expiresAt;
}
}
// ✅ GOOD: Inject clock
class Session {
constructor(expiresIn, clock = Date) {
this.clock = clock;
this.expiresAt = clock.now() + expiresIn;
}
isExpired() {
return this.clock.now() > this.expiresAt;
}
}
// Test with fake clock
class FakeClock {
constructor(time = 0) {
this.time = time;
}
now() {
return this.time;
}
advance(ms) {
this.time += ms;
}
}
test('session expires after 1 hour', () => {
const clock = new FakeClock(1000);
const session = new Session(3600000, clock);
expect(session.isExpired()).toBe(false);
// Advance time by 1 hour
clock.advance(3600000);
expect(session.isExpired()).toBe(true);
});
Strategy 2: Use Fake Timers
Jest Fake Timers:
// Enable fake timers
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T19:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
test('debounces API calls', () => {
const callback = jest.fn();
const debounced = debounce(callback, 1000);
// Call multiple times
debounced();
debounced();
debounced();
expect(callback).not.toHaveBeenCalled();
// Fast-forward time by 1 second
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});
test('retries failed requests', async () => {
let attempts = 0;
const mockFetch = jest.fn().mockImplementation(() => {
attempts++;
if (attempts < 3) {
return Promise.reject(new Error('Network error'));
}
return Promise.resolve({ ok: true });
});
const promise = retryWithBackoff(mockFetch, 3, 1000);
// Fast-forward through retries
await jest.advanceTimersByTimeAsync(1000); // 1st retry
await jest.advanceTimersByTimeAsync(2000); // 2nd retry (exponential backoff)
const result = await promise;
expect(result.ok).toBe(true);
expect(attempts).toBe(3);
});
test('expires cache after TTL', () => {
const cache = new Cache();
cache.set('key', 'value', { ttl: 3600000 }); // 1 hour
expect(cache.get('key')).toBe('value');
// Advance 59 minutes
jest.advanceTimersByTime(59 * 60 * 1000);
expect(cache.get('key')).toBe('value'); // Still valid
// Advance 1 more minute (total 1 hour)
jest.advanceTimersByTime(60 * 1000);
expect(cache.get('key')).toBeUndefined(); // Expired
});
Sinon.js for JavaScript:
const sinon = require('sinon');
describe('Time-dependent features', () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers(new Date('2024-01-15T19:00:00Z'));
});
afterEach(() => {
clock.restore();
});
test('schedules next execution', () => {
const callback = sinon.spy();
const scheduler = new Scheduler();
scheduler.scheduleDaily(callback, '09:00');
// Advance to 9:00 AM next day
clock.tick(14 * 60 * 60 * 1000); // 14 hours
expect(callback.calledOnce).toBe(true);
});
test('handles timeout', () => {
const promise = fetchWithTimeout('https://api.example.com', 5000);
// Advance 5 seconds
clock.tick(5000);
return expect(promise).rejects.toThrow('Timeout');
});
});
Strategy 3: Freeze Time
Python freezegun:
from freezegun import freeze_time
import datetime
@freeze_time("2024-01-15 19:00:00")
def test_user_age():
user = User(birthdate=datetime.date(1990, 1, 15))
assert user.age == 34
@freeze_time("2024-01-15 19:00:00")
def test_session_expiry():
session = create_session(expires_in=3600) # 1 hour
# Still valid
assert session.is_valid() is True
# Move time forward
with freeze_time("2024-01-15 20:00:01"):
assert session.is_valid() is False
def test_time_progression():
# Start at specific time
with freeze_time("2024-01-15 19:00:00") as frozen_time:
assert datetime.datetime.now() == datetime.datetime(2024, 1, 15, 19, 0, 0)
# Advance by 1 hour
frozen_time.tick(delta=datetime.timedelta(hours=1))
assert datetime.datetime.now() == datetime.datetime(2024, 1, 15, 20, 0, 0)
Ruby Timecop:
require 'timecop'
describe 'Session' do
it 'expires after 1 hour' do
Timecop.freeze(Time.new(2024, 1, 15, 19, 0, 0)) do
session = Session.new(expires_in: 3600)
expect(session.expired?).to be false
# Move forward 1 hour
Timecop.travel(Time.now + 3600)
expect(session.expired?).to be true
end
end
end
PHP Carbon:
use Carbon\Carbon;
class SessionTest extends TestCase
{
public function test_session_expires()
{
Carbon::setTestNow(Carbon::create(2024, 1, 15, 19, 0, 0));
$session = new Session(3600); // 1 hour
$this->assertFalse($session->isExpired());
// Move forward 1 hour
Carbon::setTestNow(Carbon::now()->addHour());
$this->assertTrue($session->isExpired());
// Clean up
Carbon::setTestNow();
}
}
Testing DST Transitions
DST is where things get really interesting. Want to test a time that literally doesn't exist? Or one that exists twice? Welcome to my nightmare.
Spring Forward (Time Gap)
describe('DST spring forward', () => {
test('handles non-existent time', () => {
jest.setSystemTime(new Date('2024-03-10T06:30:00Z')); // 1:30 AM EST
const event = {
scheduledAt: '2024-03-10T02:30:00', // Doesn't exist!
timezone: 'America/New_York'
};
// Should detect and adjust
const result = scheduleEvent(event);
expect(result.adjustedTime).toBe('2024-03-10T03:30:00'); // Pushed to 3:30 AM EDT
});
test('date arithmetic across spring forward', () => {
const { DateTime } = require('luxon');
const before = DateTime.fromObject(
{ year: 2024, month: 3, day: 9, hour: 2, minute: 0 },
{ zone: 'America/New_York' }
);
const after = before.plus({ days: 1 });
// 2:00 AM EST + 1 day = 3:00 AM EDT (not 2:00 AM)
expect(after.hour).toBe(3);
expect(after.offsetNameShort).toBe('EDT');
// Duration is 23 hours, not 24
const duration = after.diff(before, 'hours');
expect(duration.hours).toBe(23);
});
});
Fall Back (Time Overlap)
describe('DST fall back', () => {
test('disambiguates overlapping times', () => {
// November 3, 2024, 1:30 AM occurs twice
const firstOccurrence = {
time: '2024-11-03T01:30:00',
offset: '-04:00', // EDT
timezone: 'America/New_York'
};
const secondOccurrence = {
time: '2024-11-03T01:30:00',
offset: '-05:00', // EST
timezone: 'America/New_York'
};
const first = parseScheduledTime(firstOccurrence);
const second = parseScheduledTime(secondOccurrence);
// Same wall clock time, different UTC
expect(first.utc).not.toBe(second.utc);
expect(second.utc - first.utc).toBe(3600); // 1 hour difference
});
test('date arithmetic across fall back', () => {
const { DateTime } = require('luxon');
const before = DateTime.fromObject(
{ year: 2024, month: 11, day: 2, hour: 1, minute: 30 },
{ zone: 'America/New_York' }
);
const after = before.plus({ days: 1 });
// 1:30 AM EDT + 1 day = 1:30 AM EST (same wall time)
expect(after.hour).toBe(1);
expect(after.minute).toBe(30);
// Duration is 25 hours, not 24
const duration = after.diff(before, 'hours');
expect(duration.hours).toBe(25);
});
});
Testing Across Timezones
Use UTC in Tests
describe('Timezone-aware features', () => {
beforeEach(() => {
// Always use UTC in tests
jest.setSystemTime(new Date('2024-01-15T19:00:00Z'));
});
test('formats time in user timezone', () => {
const timestamp = Date.now() / 1000;
const nyTime = formatInTimezone(timestamp, 'America/New_York');
const londonTime = formatInTimezone(timestamp, 'Europe/London');
const tokyoTime = formatInTimezone(timestamp, 'Asia/Tokyo');
expect(nyTime).toContain('2:00 PM');
expect(londonTime).toContain('7:00 PM');
expect(tokyoTime).toContain('4:00 AM'); // Next day
});
test('converts between timezones correctly', () => {
const event = {
time: '2024-01-15T14:00:00',
timezone: 'America/New_York'
};
const utc = convertToUTC(event);
expect(utc).toBe('2024-01-15T19:00:00Z');
});
});
Test Multiple Timezones
describe('Multi-timezone support', () => {
const testCases = [
{
timezone: 'America/New_York',
input: '2024-01-15T14:00:00',
expectedUTC: '2024-01-15T19:00:00Z'
},
{
timezone: 'Europe/London',
input: '2024-01-15T19:00:00',
expectedUTC: '2024-01-15T19:00:00Z'
},
{
timezone: 'Asia/Tokyo',
input: '2024-01-16T04:00:00',
expectedUTC: '2024-01-15T19:00:00Z'
},
{
timezone: 'Australia/Sydney',
input: '2024-01-16T06:00:00',
expectedUTC: '2024-01-15T19:00:00Z'
}
];
test.each(testCases)(
'converts $timezone to UTC',
({ timezone, input, expectedUTC }) => {
const result = convertToUTC({ time: input, timezone });
expect(result).toBe(expectedUTC);
}
);
});
Property-Based Testing
Here's my favorite way to catch weird edge cases I'd never think to test manually: property-based testing. Instead of writing specific test cases, you define properties that should always be true.
Test Time Invariants
const fc = require('fast-check');
describe('Timestamp properties', () => {
test('converting to UTC and back preserves the moment', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 2147483647 }), // Unix timestamp
(timestamp) => {
const date = new Date(timestamp * 1000);
const utc = date.toISOString();
const back = new Date(utc);
expect(Math.floor(back.getTime() / 1000)).toBe(timestamp);
}
)
);
});
test('adding duration then subtracting returns original', () => {
fc.assert(
fc.property(
fc.date(),
fc.integer({ min: 0, max: 86400000 }), // Duration up to 1 day
(date, duration) => {
const start = date.getTime();
const after = new Date(start + duration);
const back = new Date(after.getTime() - duration);
expect(back.getTime()).toBe(start);
}
)
);
});
test('timestamp ordering is preserved', () => {
fc.assert(
fc.property(
fc.date(),
fc.date(),
(date1, date2) => {
const t1 = date1.getTime();
const t2 = date2.getTime();
if (t1 < t2) {
expect(date1 < date2).toBe(true);
} else if (t1 > t2) {
expect(date1 > date2).toBe(true);
} else {
expect(date1.getTime()).toBe(date2.getTime());
}
}
)
);
});
});
Integration Testing
Test with Real Time Advancing
describe('Rate limiting (integration)', () => {
test('allows requests within rate limit', async () => {
const limiter = new RateLimiter({ maxRequests: 5, window: 1000 });
// 5 requests should succeed
for (let i = 0; i < 5; i++) {
const allowed = await limiter.checkLimit('user123');
expect(allowed).toBe(true);
}
// 6th request should fail
const blocked = await limiter.checkLimit('user123');
expect(blocked).toBe(false);
// Wait for window to reset (use real delay in integration tests)
await new Promise(resolve => setTimeout(resolve, 1100));
// Should allow requests again
const allowed = await limiter.checkLimit('user123');
expect(allowed).toBe(true);
}, 5000); // Increase timeout for real delays
});
Database Time Functions
describe('Database timestamp queries', () => {
beforeEach(async () => {
await db.query('DELETE FROM events');
});
test('queries events by timestamp range', async () => {
// Insert test data
await db.query(`
INSERT INTO events (id, name, occurred_at)
VALUES
(1, 'Event 1', '2024-01-15T18:00:00Z'),
(2, 'Event 2', '2024-01-15T19:00:00Z'),
(3, 'Event 3', '2024-01-15T20:00:00Z')
`);
// Query range
const events = await db.query(`
SELECT * FROM events
WHERE occurred_at BETWEEN '2024-01-15T18:30:00Z' AND '2024-01-15T19:30:00Z'
ORDER BY occurred_at
`);
expect(events.length).toBe(1);
expect(events[0].name).toBe('Event 2');
});
test('uses database NOW() function', async () => {
// Insert with NOW()
await db.query(`
INSERT INTO events (id, name, occurred_at)
VALUES (1, 'Event', NOW())
`);
const event = await db.query('SELECT * FROM events WHERE id = 1');
// Check occurred_at is recent (within 1 second)
const now = Date.now();
const eventTime = new Date(event[0].occurred_at).getTime();
expect(Math.abs(now - eventTime)).toBeLessThan(1000);
});
});
Snapshot Testing with Time
Normalize Timestamps
describe('API responses', () => {
test('returns user profile', () => {
jest.setSystemTime(new Date('2024-01-15T19:00:00Z'));
const response = getUserProfile(123);
// Snapshot with normalized time
expect({
...response,
createdAt: '<timestamp>',
lastLogin: '<timestamp>'
}).toMatchSnapshot();
});
test('event list with dynamic dates', () => {
const events = getUpcomingEvents();
// Normalize all timestamps
const normalized = events.map(event => ({
...event,
scheduledAt: '<timestamp>',
createdAt: '<timestamp>'
}));
expect(normalized).toMatchSnapshot();
});
});
CI/CD Considerations
Set Timezone in CI
# GitHub Actions
name: Test
on: [push]
jobs:
test:
runs-on: ubuntu-latest
env:
TZ: UTC # Force UTC timezone
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm test
# GitLab CI
test:
image: node:18
variables:
TZ: "UTC"
script:
- npm test
Test Across Multiple Timezones
# Test matrix with different timezones
test:
strategy:
matrix:
timezone:
- UTC
- America/New_York
- Europe/London
- Asia/Tokyo
env:
TZ: ${{ matrix.timezone }}
runs-on: ubuntu-latest
steps:
- run: npm test
Best Practices
1. Use Fake Timers by Default
// Global test setup (jest.setup.js)
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T19:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
2. Test Edge Cases
describe('Datetime edge cases', () => {
const edgeCases = [
{ name: 'Leap year Feb 29', date: '2024-02-29T00:00:00Z' },
{ name: 'Year 2038 boundary', date: '2038-01-19T03:14:07Z' },
{ name: 'Epoch zero', date: '1970-01-01T00:00:00Z' },
{ name: 'Negative timestamp', date: '1969-12-31T23:59:59Z' },
{ name: 'Millisecond precision', date: '2024-01-15T19:00:00.123Z' }
];
test.each(edgeCases)('handles $name', ({ date }) => {
const parsed = parseISO(date);
const formatted = formatISO(parsed);
expect(formatted).toBe(date);
});
});
3. Isolate Tests
// ❌ BAD: Tests affect each other
let currentTime = Date.now();
test('test 1', () => {
currentTime += 1000; // Modifies shared state
});
test('test 2', () => {
// Depends on test 1 running first!
expect(currentTime).toBeGreaterThan(Date.now());
});
// ✅ GOOD: Each test is independent
test('test 1', () => {
const clock = new FakeClock(1000);
clock.advance(1000);
expect(clock.now()).toBe(2000);
});
test('test 2', () => {
const clock = new FakeClock(5000);
expect(clock.now()).toBe(5000);
});
4. Document Time Behavior
/**
* Schedules a job to run at a specific time
*
* @param {Function} callback - Job to execute
* @param {number} timestamp - Unix timestamp (seconds) when to run
*
* @example
* // Schedule job for January 15, 2024, 7:00 PM UTC
* scheduleJob(() => console.log('Running'), 1705341600);
*
* @test-note Uses Date.now() internally. Mock time in tests using jest.setSystemTime()
*/
function scheduleJob(callback, timestamp) {
const delay = timestamp * 1000 - Date.now();
setTimeout(callback, delay);
}
5. Test Monotonicity
test('timestamps always increase', () => {
const clock = new Clock();
const t1 = clock.now();
const t2 = clock.now();
const t3 = clock.now();
// Time should never go backward
expect(t2).toBeGreaterThanOrEqual(t1);
expect(t3).toBeGreaterThanOrEqual(t2);
});
Conclusion
Testing time-dependent code doesn't have to be painful. Really. Here's what you need to remember:
Core Strategies: Inject time dependencies (pass clock objects around), use fake timers to control time in tests, and explicitly test the weird stuff—DST transitions, timezone conversions, leap years, the whole nine yards.
Tools That Actually Work: JavaScript? Jest fake timers or Sinon. Python? Freezegun. Ruby? Timecop. PHP? Carbon's setTestNow(). Java? Clock.fixed(). They all do basically the same thing—let you control time in your tests.
My Rules: Always use UTC in tests. Set system time explicitly. Test the edge cases (because that's where the bugs hide). Make tests deterministic and isolated. Save real time for integration tests only.
The best codebases I've worked with didn't get time testing right by accident—they took it seriously from day one. Start treating time as a dependency you inject, start using fake timers everywhere, and suddenly those flaky time-based tests become rock solid.
Further Reading
- Complete Guide to Unix Timestamps - Understand time representation
- Handling Daylight Saving Time - Test DST logic correctly
- Timezone Conversion Best Practices - Test timezone conversions
- Common Timestamp Pitfalls - Avoid testing mistakes
- Working with Timestamps in Python - Test Python datetime code with freezegun
Need help testing time-dependent code? Contact us for consultation.