timezone-test-matrix-builder
Builds a timezone, daylight saving time (DST), and leap year / leap second test matrix from wherever a codebase reads or formats dates and times. Finds time-handling code (grep for datetime / Date / Instant / time.time / timezone), sorts each spot into storage, business-logic, display, cron, or billing, picks the edge cases that matter (DST spring-forward / fall-back, ambiguous local time, leap day Feb 29, ISO 8601 / RFC 3339 round-trip, zone-database updates), and emits per-spot test stubs wired to the language's fake-clock (mock-time) library. Use when a codebase needs timezone, DST, and leap-year test coverage derived from its own date/time usage.
Install with skills.sh (any agent)
npx skills add testland/qa --skill timezone-test-matrix-buildertimezone-test-matrix-builder
Overview
Time-related bugs are scattered across the codebase - storage, display, business logic, scheduled jobs. The test matrix needs to systematically exercise the canonical edge cases at each touchpoint.
When to use
Step 1 - Inventory time touchpoints
# Generic
grep -rn 'datetime\|Date\|Instant\|time.time\|moment\.\|dayjs\|chrono' \
--include='*.{py,js,ts,java,kt,rb,go,rs,cs}' .
# Per-language
grep -rn 'datetime.now\|datetime.utcnow\|Date.now\|Instant.now' .
grep -rn 'tz\|timezone\|zoneinfo\|ZoneId' .
grep -rn 'cron\|schedule' .Categorise each match:
| Category | Examples | Test needs |
|---|---|---|
| Storage | DB columns; serialised dates | RFC 3339 round-trip per iso-8601-vs-rfc-3339-reference |
| Business logic | Age calculation; duration; expiry | DST, leap-day, monotonic |
| Display | User-facing dates | Per-user-tz formatting |
| Cron / scheduled | Periodic jobs | DST transition behaviour per dst-transition-reference |
| Billing | Period boundaries | DST + month-end + leap year |
| Audit / logging | Timestamp emission | Monotonic; leap-second tolerance |
| External API | Third-party datetime strings | Tolerant parsing |
Step 2 - Per-category test catalog
For each touchpoint, pull the test cases matching its category from references/test-catalog.md, which lists the storage, business-logic, cron, billing, and display tests to exercise.
Step 3 - Per-language test harness
| Language | Fake-clock skill |
|---|---|
| Python | freezegun-python |
| JS (general) | sinon-fake-timers-js |
| JS (Jest) | jest-fake-timers |
| Ruby | timecop-ruby |
| JVM (Java / Kotlin) | mockclock-jvm |
| C / native binary | libfaketime-c |
Step 4 - Build the matrix
For each (category, touchpoint, language) cell, generate test stubs:
# tests/time/matrix.yaml
matrix:
- touchpoint: BillingService.createCharge
category: billing
tests:
- dst-fall-back
- leap-year-feb-29
- month-end-rollover
- timezone-multi-tenant
language: java
harness: mockclock-jvm
- touchpoint: ScheduledTask.runDaily
category: cron
tests:
- dst-spring-forward
- dst-fall-back
- leap-day
language: ruby
harness: timecop-ruby
# ...Step 5 - Emit per-cell test files
# tests/time/test_billing_service.py
import pytest
from freezegun import freeze_time
from billing import BillingService
@freeze_time("2024-02-29T00:00:00Z")
def test_billing_handles_leap_day():
charge = BillingService.create_charge_for_month(2024, 2)
assert charge.days_in_period == 29
@freeze_time("2025-02-28T00:00:00Z")
def test_billing_handles_non_leap_february():
charge = BillingService.create_charge_for_month(2025, 2)
assert charge.days_in_period == 28
@freeze_time("2026-11-01T05:30:00Z") # Just past fall-back in NY
def test_billing_period_spans_dst_fall_back():
# Period from Nov 1 00:00 to Nov 2 00:00 in New_York
# is 25 hours of UTC due to fall-back
period = BillingService.month_period(year=2026, month=11, zone="America/New_York")
assert period.duration.total_seconds() == 30 * 24 * 3600 + 3600 # 1 extra hourStep 6 - Run and validate the generated tests
Verify before recording coverage: run the emitted files (pytest tests/time/, mvn test, etc.) and assert every DST and leap-day case produces its expected pass (or the expected failure for a known-bug reproduction). If a case errors instead of asserting, the fake-clock wiring is wrong - fix the harness mapping (Step 3) or add the missing TZ / zone for local-time cases - and re-run until the matrix is green.
Step 7 - Coverage doc
# Time Test Matrix Coverage
## Touchpoints covered
| Service | Category | Tests | File |
|---|---|---|---|
| BillingService | billing | leap-day, dst-fall-back, month-end | tests/time/test_billing.py |
| ScheduledTask | cron | dst-spring-forward, leap-day | tests/time/test_cron.py |
| API serialiser | storage | rfc-3339-round-trip | tests/time/test_api_format.py |
## Coverage gaps
- BillingService - leap-second tolerance: deferred (low likelihood)
- Display layer: per-user-TZ rendering - manual QA only
## How to add a new touchpoint
1. Run inventory grep (Step 1).
2. Categorise (Step 2).
3. Update matrix.yaml.
4. Generate test from template (per Step 5).Worked example
Adding leap-day coverage to a Python BillingService.create_charge_for_month: inventory (Step 1) categorises it as billing, whose catalog rows call for month-end-across-leap-year, DST-window, and multi-tenant-timezone tests. Python maps to freezegun-python (Step 3), so the emitted tests/time/test_billing_service.py (Step 5) freezes 2024-02-29 asserting days_in_period == 29 and 2025-02-28 asserting 28. Running the file (Step 6) confirms both pass, and the coverage doc (Step 7) then records BillingService's billing category as covered - the leap-year February boundary is now exercised on every run.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Test only the happy path | Time bugs are edge cases | DST + leap-day mandatory |
| Live system time in tests | Annual / quarterly flakes | Always fake-clock |
| One mega-test for all time edge cases | Failures opaque | Per-category, per-touchpoint |
| Skip storage round-trip | Schema drift / serialiser bug hides | RFC 3339 round-trip everywhere |
| Test in UTC only | Misses local-zone DST / display bugs | Per-zone testing |
| Hardcoded dates that age | Re-write needed annually | Use relative dates or fake clock |
| No coverage doc | Gaps invisible | Step 7 |
| Ignore display-layer | Real users see wrong dates | Even if manual, document the manual coverage |
References
Per-category test catalog
View source (opens in new window)Per-category test catalog
The test cases each touchpoint category should exercise. Pick the rows matching the category assigned during inventory (Step 1).
Storage tests
| Test | Pattern |
|---|---|
| Round-trip RFC 3339 | parse → emit → parse → assert equal |
| Round-trip via JSON | serialise object → deserialise → assert |
| Microsecond precision preserved | .123456Z survives DB store/load |
| Zone information preserved or normalized to UTC | Document the policy |
Business-logic tests
| Test | Pattern |
|---|---|
| DST spring-forward | Schedule at 02:30 local on transition day; verify behaviour |
| DST fall-back | Same 01:30 local appearing twice; verify ordering |
| Leap day Feb 29 | "1 year from Feb 29 2024" → Feb 28 2025 (Per ICU) |
| Year-end rollover | "Tomorrow" on Dec 31 |
| Month-end | Jan 31 + 1 month = Feb 28 / 29 (per library) |
| Negative durations | Operations on "5 minutes ago" |
| Leap second tolerance | Code uses monotonic time per leap-second-reference |
Cron tests
| Test | Pattern |
|---|---|
| Daily 02:30 EST cron on spring-forward | Doesn't fire OR fires at 03:30 (per cron spec) |
| Daily 01:30 EST cron on fall-back | Fires once vs twice (per spec) |
| Monthly on Feb 29 (non-leap year) | Fires on Feb 28 OR not at all |
| Weekly cron crossing DST | 1-hour offset for one week |
Billing tests
| Test | Pattern |
|---|---|
| Billing on month-end across leap year | Feb 28 vs Feb 29 handling |
| Billing window across DST | Hour gain / loss in the period |
| Pro-ration calculation | Across DST boundary |
| Multi-tenant timezone variance | Same wall-clock hour ≠ same UTC |
Display tests
| Test | Pattern |
|---|---|
| Per-user TZ formatting | User in Asia/Tokyo sees 09:00 JST; user in Europe/London sees 00:00 GMT |
| ISO 8601 vs human-readable | Localised display ≠ wire format |
| 24h vs 12h convention | Per user locale |
| Relative time ("2 hours ago") | Per system locale |
Related skills
dotnet-faketime
Wraps .NET's TimeProvider abstraction (System.TimeProvider, introduced .NET 8) and FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing: SetUtcNow, Advance, AutoAdvanceAmount, CreateTimer, Delay, and the pre-.NET-8 ISystemClock migration path. Use when testing C# or F# code that reads the current time, uses timers, or awaits Task.Delay.
dst-transition-reference
Pure-reference catalog of Daylight Saving Time (DST) transition patterns and their canonical bug classes. Covers the spring-forward (skipped hour: 02:00 → 03:00 local) and fall-back (repeated hour: 02:00 → 01:00 local) transitions, the historical irregularity of DST (different jurisdictions, transitions on different dates, some regions abolish DST or never adopted it), the IANA timezone database (tz / Olson DB) as the canonical source, and the testable behaviors DST creates (duplicate / missing local timestamps, cron jobs that fire 0 or 2 times, billing periods that miss / double-count, recurring meetings on transition days). Per-jurisdiction DST-rule tables and refreshable per-region test-data fixtures live in references/. Use when designing or auditing time-handling code or test cases.
freezegun-python
Wraps freezegun (github.com/spulec/freezegun), the Python time-mocking library: @freeze_time decorator / context manager, freeze_time(...).start() + stop(), tick / move_to / tz_offset, and integration with datetime.now / time.time / time.localtime. Use when testing Python code that calls datetime / time.
iso-8601-vs-rfc-3339-reference
Pure-reference catalog of the ISO 8601 vs RFC 3339 distinction. Covers the relationship (RFC 3339 is a strict subset of ISO 8601 designed for internet protocols), the syntactic differences (RFC 3339 disallows ISO 8601's '+02' offset short-form requires '+02:00'; RFC 3339 mandates a date-time separator T or space; ISO 8601 allows much more), the canonical date-time string format (YYYY-MM-DDTHH:MM:SS[.fff]±HH:MM or Z), per-language parser behaviour (Python isoformat, Java Instant.parse, JS Date.parse non-spec), and serialisation rules for APIs. Use when choosing a wire format, parsing third-party datetimes, or auditing time-string handling.
jest-fake-timers
Wraps Jest's built-in modern fake-timers (built on @sinonjs/fake-timers since Jest 27): jest.useFakeTimers(), jest.setSystemTime(), jest.advanceTimersByTime(), jest.runAllTimers(), and jest.useRealTimers() for selective restoration. Use when testing JS/TS code in Jest where setTimeout / setInterval / Date / Date.now need deterministic control.
leap-second-reference
Pure-reference catalog of leap-second mechanics and the bugs they cause: the 23:59:60 UTC insertion (announced ~6 months ahead by IERS Bulletin C; 27 inserted 1972-2016; abolished by 2035 per CGPM 2022), the Google/AWS leap-smear alternative, and the four bug classes a real insertion exposes - time_t non-monotonicity, negative durations, NTP cascading, and cross-node clock skew - each with a monotonic-clock fix and a freezegun simulation. Use when auditing time-sensitive code (financial timestamping, distributed logs, NTP-driven schedulers) for second-by-second progress assumptions; for the far more common daylight-saving-time transition hazards, use dst-transition-reference instead.
libfaketime-c
Wraps libfaketime (github.com/wolfcw/libfaketime), the LD_PRELOAD library that fakes the clock for any binary by intercepting time() / gettimeofday() / clock_gettime(). Covers absolute-date mode (FAKETIME='2026-12-31 23:59:00'), relative offset (FAKETIME='-1d'), advance-rate (FAKETIME='@2026-12-31 23:59:00 x5' for 5x speed), per-process scope via LD_PRELOAD, and FAKETIME_NO_CACHE for high-resolution mocking. Use when you need to fake time, mock the clock, or freeze time for C/C++ or any native binary that needs deterministic wall-clock time.
mockclock-jvm
Wraps Java's java.time.Clock + InstantSource dependency-injection pattern for testing time-sensitive code. Covers Clock.fixed(instant, zone), Clock.offset(baseClock, duration), Clock.systemDefaultZone() for production, the InstantSource interface (Java 17+), and the recommended dependency-injection pattern (constructor-inject Clock instead of calling Instant.now() directly). Use when you need to wire the clock-injection pattern (Clock.fixed, Clock.offset, MutableClock, InstantSource, Spring @Bean) into JVM (Java / Kotlin / Scala) production or test code. For pure DST transition reference (skipped or repeated hours, IANA DB, cron-double-fire bug classes) without a clock-injection need, use dst-transition-reference instead.
sinon-fake-timers-js
Wraps Sinon's standalone @sinonjs/fake-timers library for JS/TS testing: install(), tick() / tickAsync(), setSystemTime(), restore(); covers timers (setTimeout / setInterval / requestAnimationFrame), Date / performance.now() / hrtime, and the toFake option for selective override. Runner-agnostic - drives the clock directly in Mocha, AVA, Jasmine, node:test, or the browser. Use when JS/TS code needs deterministic timer or clock control and the test runner does not already expose this library behind its own built-in fake-timer API.
timecop-ruby
Wraps timecop (github.com/travisjeffery/timecop), the Ruby time-mocking gem: Timecop.freeze, Timecop.travel, Timecop.scale (time-speedup), Timecop.return (cleanup), and RSpec-friendly helpers. Use when testing Ruby/Rails code that calls Time / Date / DateTime.