Why does injecting a fake clock not fix a Go test for a local-midnight log rollover that fails only on CI?
answer
- the instant is only half the input
- a time.Time carries a location
- CI runs in UTC, your laptop does not
- the zone is a dependency too
- not every local day is 24 hours
basics
~20 sBecause the instant is only half the input. Turning a time.Time into a calendar day uses its location, and code that leans on time.Local gets a different day on a machine set to UTC than on a laptop. Inject the *time.Location as well.
solid answer
~40 sA `time.Time` is an instant plus a `*time.Location`, and every calendar question — which day is it, has midnight passed, what does `Format("2006-01-02")` print — is answered in that location. A fake clock pins the instant; if the code then calls `.Local()` or was handed a time in `time.Local`, the answer still depends on the `TZ` of the machine, and CI is almost always UTC. Make the zone an explicit dependency next to the clock: a `*time.Location` field, set in production from `time.LoadLocation("America/New_York")` and in the test to the same zone, with the fake instant constructed in a known zone. Then the test can deliberately drive 23:59 local, the DST weekend and the day after. Setting `TZ` inside the test is not a reliable substitute, because the process resolves its local zone once.
code
go · 8 linestype Rotator struct {
now func() time.Time
loc *time.Location
}
func (r *Rotator) day() string {
return r.now().In(r.loc).Format("2006-01-02")
}go deeper
Know that a time.Time carries a location and that formatting it as a date answers the question in that location, so the same instant can print two different days.
Explain where time.Local leaks into the result, and show the fix: a *time.Location field beside the clock, with the fake instant constructed in a zone the test names.
Drive the boundaries deliberately — last minute of the local day, both DST transitions, a leap day — and know why adding 24 hours is not tomorrow and why setting TZ in a test is unreliable.
Own the policy: which zone the product's day is defined in, whether binaries embed the zone database or images ship it, and how that decision is enforced so no service quietly depends on its host's configuration.
## The bug A daemon writes one file per local day and rolls over at midnight. Someone makes it testable by injecting `now func() time.Time`, the test passes on every laptop, and the nightly CI job fails — or worse, the job runs green for months and the rollover misfires once a year at 02:00. The injected clock fixed one input and left another hidden. ## A time.Time is an instant and a location `time.Time` carries a location pointer alongside the instant. The instant identifies a unique moment everywhere on Earth; the location decides what that moment is *called*. `Format`, `Year`, `Month`, `Day`, `Hour` and any "has the day changed" comparison are all answered in the value's location. So `time.Date(2026, time.March, 8, 4, 59, 0, 0, time.UTC)` is 04:59 in UTC and 23:59 on 7 March in New York. A rollover routine that formats it in one zone gets `2026-03-08`; in the other it gets `2026-03-07`. Both are correct; the test only pinned the instant. ## Where the machine leaks in Two doors. `time.Local` is the process's local zone, derived from the `TZ` environment variable or the host's configuration. Any call to `.Local()`, and any `time.Time` produced by code that used `time.Local`, is machine-dependent. Developer laptops are set to a city; CI containers are almost universally UTC. That difference is the whole failure. `time.Now()` returns a time in the local zone, so even production behaviour depends on the deployment host's zone unless the code says otherwise. ## The fix: the zone is a dependency too Treat the location the way you treated the clock — as a field, supplied once: ```go type Rotator struct { now func() time.Time loc *time.Location } func (r *Rotator) day() string { return r.now().In(r.loc).Format("2006-01-02") } ``` Production loads the zone the business actually means (`time.LoadLocation("Europe/Berlin")`, or `time.UTC` if the answer is "we roll over at UTC midnight, everywhere"). The test loads the same zone and drives the interesting instants: one minute before local midnight, one minute after, and the two transition days. Once the zone is explicit, the whole class of "works on my machine" failures disappears, because nothing in the path consults `time.Local`. ## Why setting TZ in the test is not the fix It is tempting to write `t.Setenv("TZ", "America/New_York")` and keep using `time.Local`. Two problems. The process resolves its local zone once and caches it, so a change made after anything has already looked at the local zone has no effect — which makes the technique work or not work depending on test order. And `TZ` is process-global: a test that changes it is incompatible with `t.Parallel()` and with every other test in the binary. An injected `*time.Location` is scoped to one instance and has neither problem. ## The missing zone database `time.LoadLocation` reads the IANA zone files from the host, falling back to the zone data shipped with the Go installation. A minimal container image often has neither, and `LoadLocation` returns an error — usually surfacing as a nil location and a panic in production rather than in the test. The standard-library answer is to blank-import `time/tzdata`, which embeds a copy of the zone database in the binary at a cost of a few hundred kilobytes, so `LoadLocation` succeeds wherever the binary runs. The alternative is to install the zone files in the image. Either way, this is a decision to make deliberately, not to discover from an on-call page. ## Not every local day is 24 hours The second half of the boundary problem. On the two DST transition days a local day is 23 or 25 hours long, so `t.Add(24 * time.Hour)` is not "the same time tomorrow" — it lands an hour early or an hour late, which is exactly how a rollover ends up producing two files for one day or none for another. Calendar arithmetic belongs to calendar functions: construct the next local midnight with `time.Date(y, m, d+1, 0, 0, 0, 0, loc)`, which normalises month and year ends for you, or use `AddDate(0, 0, 1)`. Duration arithmetic is for durations: timeouts, intervals, elapsed measurements. Mixing the two is the classic source of the once-a-year bug. ## What the test should actually cover With both the clock and the location injected, write table cases for the instants that matter rather than for a random Tuesday: the last minute of a local day, the first minute of the next, the spring-forward day when 02:00 does not exist locally, the autumn day when 01:30 happens twice, and a leap day. Each is a one-line `time.Date` in the fixture, runs in microseconds, and encodes the failure that would otherwise be found by the person carrying the pager.
- Why is setting the TZ environment variable inside the test an unreliable fix?The process resolves its local zone once and caches it, so a change made after anything has consulted the local zone has no effect, and whether that has happened depends on test order. TZ is also process-global, so a test that sets it cannot run in parallel with the rest. An injected *time.Location is per-instance and deterministic.
- The CI container has no zone files and time.LoadLocation fails. What do you do?Blank-import the standard library's time/tzdata package, which embeds the IANA zone database in the binary so LoadLocation succeeds anywhere at a cost of a few hundred kilobytes. The alternative is installing the zone files in the image. Decide it deliberately, because the failure otherwise shows up as a nil location in production.
- How do you compute the same wall-clock time tomorrow in a given location?Use calendar arithmetic: time.Date(y, m, d+1, h, min, s, ns, loc), which normalises month and year boundaries, or AddDate(0, 0, 1). Adding 24*time.Hour is duration arithmetic and is wrong on the two DST-transition days, when a local day is 23 or 25 hours long.
- Which instants belong in the table for a local-midnight rollover?The last minute of a local day and the first minute of the next, the spring-forward day when a local hour does not exist, the autumn day when an hour repeats, and a leap day. Each is one time.Date line in the fixture and costs microseconds, which is the whole argument for injecting the clock and the zone.
Pinning the clock is like agreeing on the exact moment a photograph was taken; you still have to agree on which city's calendar you are naming that moment in, or one of you writes down yesterday's date.
saying these in an interview costs you the question
- Assumes every local day is exactly 24 hours long
- Says pinning the instant is enough and ignores the zone
- Sets TZ inside a test and expects time.Local to change
- Ships a binary that assumes host zone files exist
- Compares two times formatted in different locations
- Blames CI flakiness rather than the machine's time zone