Blog / How to Test Time-Dependent and Timezone Features End-to-End (Without Flaky Tests)
How-To

How to Test Time-Dependent and Timezone Features End-to-End (Without Flaky Tests)

Time-dependent tests flake because they read the clock instead of controlling it. How to fake time, cover the DST and timezone cases, and keep the suite green.

There is a local time, in most of the United States, that does not exist. On the second Sunday of March the clock jumps from 1:59 straight to 3:00, so the 2:00 hour never happens (NIST). Construct a 2:30 AM timestamp for that date and it maps to no real instant.

Green all week, red on a Sunday, green again by Monday. When a suite does that, the useful question is not what broke. It is which input moved. Nothing landed in the repository over the weekend, so it was the clock, the one input most suites never took control of. Everything below is how to take it.

What you’ll learn

  • Why time-dependent tests flake, and the four shapes that cover almost all of them
  • The one principle that fixes it, which is to make the clock an injectable dependency you freeze or fast-forward
  • The DST and timezone edge cases that actually break in production, and how to reach each one
  • Four ways to fake time compared, from the OS process clock down to an injectable app clock
  • Where Pie picks up a time-dependent flow, and the one part of it Pie does not do

Why Time and Timezone Tests Flake

Time-dependent tests flake because they treat the clock as an input they read rather than one they control. The moment a test calls new Date() or now() and lets the real system answer, its outcome is coupled to the exact second it ran, the timezone of the machine it ran on, and the calendar date in the world outside CI. Four shapes cover almost all of it, and they turn up throughout the usual flaky test reports.

  • The moving clock: A test asserts an expiry, a countdown, or an “N minutes ago” label using the live time. It passes at 10:00:00 and fails at 10:00:01 when the value ticks over between the setup line and the assertion.
  • The runner’s timezone: Local development machines and CI runners disagree on their default timezone. Date math that looks correct in America/Los_Angeles produces an off-by-one date under UTC, so the test passes locally and fails in CI, or the reverse.
  • DST transitions: Twice a year, a region’s offset shifts. A duration test that spans the spring-forward is an hour short; one that spans the fall-back is an hour long. The IANA Time Zone Database that encodes these rules shipped seven releases in 2022 and two in 2024, so the ground truth itself moves under you.
  • Real waits: A test for a delayed job sleeps for the delay, then asserts. On a loaded runner the job lands late, the assertion fires early, and the test is red on timing alone.

None of these are application bugs. Noah Sussman’s 2012 catalogue Falsehoods programmers believe about time runs to dozens of assumptions that hold on your laptop and break somewhere on Earth, from “time zones always differ by a whole hour” to “the offsets between two time zones will remain constant.” A test that reads the real clock inherits every one of them, which is what makes these failures non-deterministic tests rather than bugs you can reproduce on demand.

Control the Clock Instead of Reading It

One principle fixes every failure above. Make the current time an injectable dependency, so the test supplies it instead of the operating system. It is almost like the database handle you already pass around instead of opening a connection wherever you happen to need one.

Code that calls a global Date.now() deep in a function is untestable by construction, because the test has no seam to control. Code that receives its clock can be handed any instant you like, frozen or advanced on command.

None of this is new. The Java standard library ships java.time.Clock so production can inject the system clock while tests inject Clock.fixed(), and the class documentation says outright that the point of the abstraction is testing. The same seam works in any language. Pass a now function, resolve a clock from a container, or read time from a single module you can stub.

// Untestable: time is read from a hidden global.
function isTrialExpired(user) {
  return Date.now() > user.trialEndsAt;
}

// Testable: the clock is an argument the caller controls.
function isTrialExpired(user, clock = Date) {
  return clock.now() > user.trialEndsAt;
}

// In a test, "now" is whatever you say it is:
const frozen = { now: () => new Date('2026-03-01T12:00:00Z').getTime() };
expect(isTrialExpired(user, frozen)).toBe(false);

Once the clock is a dependency, freezing it removes the moving-target flake entirely, and advancing it lets you reach any future state without waiting. Every technique in the rest of this guide is a different way to install that seam at a different layer of the stack.

Tired of the Sunday-Only Failure?

Point Pie at the flow. Reach the expired state without the wait.

Talk to the team

Four DST and Timezone Cases Worth Testing Directly

The cases worth an explicit test are the ones where human calendar rules violate the assumption that time is a smooth, monotonic number. Daylight saving and irregular offsets create instants that are missing, doubled, or shifted, and naive date math walks straight into each one. Reach all four by installing a clock at the exact boundary.

  1. The non-existent time, at the spring-forward: Clocks jump from 1:59 to 3:00, so the 2:00 hour never occurs and a local timestamp of 2:30 AM that day maps to no real instant. Assert your code rejects, adjusts, or safely resolves it instead of crashing or silently inventing a value.
  2. The ambiguous time, at the fall-back: The 1:00 hour occurs twice, so 1:30 AM maps to two distinct instants. A “schedule at 1:30 AM” feature has to decide which one, and a duration crossing the boundary has to count the repeated hour once. Test both readings.
  3. Non-hour offsets: Not every timezone is a whole number of hours from UTC. Nepal runs at UTC+5:45 and India at UTC+5:30, fifteen minutes apart. Code that assumes offsets are integers, or that a “day” is always 24 hours, breaks for those users. Run at least one case in a fractional-offset zone.
  4. Date-line and calendar oddities: When Samoa switched sides of the international date line, it skipped December 30, 2011 entirely, jumping from Thursday the 29th to Saturday the 31st (CBS News). Days can go missing, and signed 32-bit timestamps overflow at 03:14:07 UTC on January 19, 2038. You will not test every oddity, but a “days between two dates” function deserves one deliberately weird pair.

The value of these tests is that they fail in CI, in seconds, on a laptop in July, instead of in production on the one night a year the transition actually happens.

Four Ways to Fake Time, and What Each One Controls

There are four common ways to fake time, and what separates them is the layer they sit at. The operating system, the language runtime’s timers, the browser inside an end-to-end framework, or your own application code. Each controls a different scope and asks for a different amount of setup.

ApproachScope it controlsNeeds app changesControls timezoneBest layer
OS-level (libfaketime, TZ env)Whole process clockNoYes (TZ)Integration / backend
In-process fake timers (Sinon, Jest)Date + timers in the runtimeNoNoUnit / component
Framework clock API (Playwright, Cypress)Date + timers in the pageNoYes (context)End-to-end
Injectable app clockOnly where you inject itYesYesAny, cleanest

Each row is a different seam, and they are not substitutes for one another.

  • OS level: libfaketime intercepts the system calls a process uses to read the date, so a whole backend process runs at a time you pick with no code change. The TZ environment variable pins that same process’s timezone, which POSIX has specified for decades.
  • Language runtime: Sinon’s fake timers and Jest’s timer mocks swap out Date, setTimeout and friends inside the runtime, so a unit test advances an hour in under a millisecond. Jest’s implementation is Sinon’s underneath.
  • End-to-end framework: Playwright’s clock API installs a fake clock inside the page and overrides Date, the timer functions, requestAnimationFrame and performance. Cypress does the same job with cy.clock and cy.tick, which by default cover setTimeout, clearTimeout, setInterval, clearInterval and Date. Either one is what makes a countdown or an expiry banner controllable from the test.
  • Your own code: The injectable clock is the most work and the only one that survives a change of runtime, framework, or language.

TLDR, most mature suites run two of these. An injectable clock where they own the code, and a framework or OS faker where they do not.

Testing a 14-Day Trial Expiry in Five Steps, Without the Wait

Put the principle to work on the canonical case, a 14-day free trial that has to expire and lock the account. The naive version waits, which is why it is both slow and flaky. The deterministic version fast-forwards a fake clock and never waits at all. Five steps.

  1. Install the clock before the app loads: In Playwright, await page.clock.install({ time: new Date('2026-03-01T09:00:00Z') }) fixes the page’s notion of now to the moment the trial starts. Keep the Z. Without it the string is parsed in the runner’s local timezone, which is the exact thing this test exists to stop.
  2. Create the state at the fixed time, and know which clock owns it: Sign the user up. If the trial start is computed in the browser, it is now anchored to the frozen clock. In the usual case the server writes that timestamp and runs the entitlement check, and page.clock never reaches the server, so signing up under the frozen clock still records real server time and the reload in step 4 asks a backend whose clock never moved. For that case, age the record through the test-only backend hook described after the code block, and use the fake clock for the UI that reacts to it.
  3. Fast-forward past the window in milliseconds: await page.clock.fastForward(14 * 24 * 60 * 60 * 1000) jumps the clock fourteen days ahead in one call. Reach for the readable string form instead and you hit a wall, because it takes two digits per segment, so a value like '336:00:00' is not valid input and fourteen days has no string representation at all. Pass the number.
  4. Trigger the check: Reload or navigate so the app re-evaluates entitlement against the advanced clock, exactly as it would after fourteen real days.
  5. Assert the locked state: Confirm the trial-expired screen renders and the gated feature is blocked. Then reset and assert the pre-expiry state still works, so you have covered both sides of the boundary.
// Deterministic trial-expiry test, no real waiting.
// Works as written when the trial clock lives in the page. If the server owns
// trialEndsAt, replace the fastForward with a test-only hook that ages the record.
await page.clock.install({ time: new Date('2026-03-01T09:00:00Z') });
await signUpNewUser(page);                              // trial anchored to the fixed clock
await page.clock.fastForward(14 * 24 * 60 * 60 * 1000); // +14 days, instantly
await page.reload();
await expect(page.getByText('Your trial has ended')).toBeVisible();

Server-side state gets reached the same way you would reach any hard-to-produce condition. A test-only backend hook ages the record, gated so it never ships to production. That is the clock trick one layer down, and it is test isolation doing its usual job of getting you to the state you need instead of driving the whole system in real time to arrive at it. The same instinct runs through most end-to-end testing that stays green.

Testing What the User Sees in Their Timezone

Timezone bugs hide from value-level assertions because most of them are correct in UTC and wrong only in the user’s actual offset. A test that checks a stored Date equals the right instant passes, while the same appointment renders as “yesterday at 11:00 PM” to a user in Los Angeles because the formatting layer used the wrong zone. The bug lives in the rendered string, so the test has to look at the rendered string.

The move is to run the scenario in a real, non-UTC timezone and assert on what the interface displays. Playwright emulates this at the browser context, per its emulation docs:

const context = await browser.newContext({
  timezoneId: 'America/Los_Angeles',
  locale: 'en-US',
});
// A 2026-03-08T06:30:00Z event must render as the user's local time,
// across the spring-forward, not as a raw UTC value.

Three zones expose most of the assumptions. Asia/Kathmandu for a fractional offset at UTC+5:45, any zone crossing a DST boundary on the test date, and Pacific/Kiritimati at UTC+14 for the far edge of the calendar.

Which is also the argument against UTC in CI. A pure-UTC runner makes every offset calculation look correct, because zero is the one offset that never catches an error. Run the suite in a fixed non-UTC zone with an active DST rule and the rendering bugs your users hit fail on your machine instead of theirs. The same trap sits under a lot of flaky mobile E2E tests, where the device timezone and locale add one more axis of state.

Where Pie Picks This Up, and Where It Doesn’t

Everything above is the pattern, and it holds whoever runs the test. What changes is how much of the wiring you keep doing by hand, and how much of it survives the next redesign.

Start with the limit, because it matters more than the pitch. Pie does not manipulate the device clock. The time seam still has to live in your code or your backend, exactly as described above. If your app reads Date.now() from a hidden global, no runner is going to rescue you from that.

What it does own is the two ends of the problem, reaching the state and judging the result.

  1. Reaching the state: A Pie test can call an execution script mid-run, which is a shell command registered against your app and invoked as #{script-name}. A script that hits your own API to age a subscription record puts the account in the expired state directly, so the run never waits out a real trial. It is the same test-only backend hook from the last section, moved into the test definition.
  2. Judging the result: The agent decides pass or fail from what is rendered on screen rather than from a returned value, and the test author never writes or maintains a selector to do it. A date printed in the wrong offset is wrong on the screen, which is precisely where a UTC value assertion is not looking.

A registered script is a shell command you write, so what it can do is whatever your API can do. A trial-expiry case ends up reading roughly like this, with the script name doing the work the fourteen-day wait used to:

#{expire-trial-for-test-user}
[open the app]
[verify the trial has ended screen appears]
[verify the gated feature is locked]

Because nothing in that definition is bound to a selector, the same autonomous testing loop keeps running through a redesign that would break a locator-based version of the same check, on web and on native iOS and Android alike. You describe the flow once, and self-healing absorbs the churn underneath it.

TLDR, Pie does not hand you a clock. It hands you a way to reach the state on the far side of one, and an assertion that looks at what a user in that timezone would actually see.

Make Time a Dependency You Own

You already control every other input to your suite. The database gets seeded. The network gets stubbed. Third-party APIs get a sandbox. The clock is the one input most teams still let the operating system answer, and the clock is where the Sunday-only failures come from.

Give it the same treatment. Inject it, freeze it, fast-forward it, and run at least one path in a real non-UTC zone with an active DST rule. Do that and the test that only broke on the second Sunday of March gets as boring as the rest of them.

We built Pie to take it from there. Once the seam exists, the run reaches the state on the far side of it and judges the result on what a user in that timezone would actually see. Own the clock, then stop thinking about it.

Stop Losing Sundays to the Clock

Send us the flow that only breaks in March. We will run it.

Book a demo

Frequently Asked Questions

Time-dependent tests flake because they read the real system clock instead of controlling it. The test's outcome then depends on the wall-clock second it runs, the CI runner's timezone, or whether that day happens to be a daylight-saving transition. Freeze or inject the clock and the same test becomes deterministic, because the input it depends on is now fixed.
Set the fake clock to a timestamp inside the transition and assert the behavior directly. For a spring-forward, install a clock at the local 2:30 AM that does not exist and check your code handles the gap. You never wait for the real date, because the clock is an input you control, not a wall you wait against.
Use both, at different layers. Set the TZ environment variable to pin the process timezone for backend and unit tests, so date math is reproducible across machines. In browser end-to-end tests, set the timezone in the framework (Playwright's timezoneId context option, for example) so the rendered UI matches what a user in that region actually sees.
Do not wait in real time. Install a fake clock, then fast-forward it past the 14-day window, or reach the expired state directly through a test-only backend hook that ages the record. Both turn a two-week wait into an instant, deterministic assertion. Waiting real minutes, let alone real days, is the anti-pattern that makes these tests both slow and flaky.
Use your framework's clock control. Playwright exposes page.clock.install, setFixedTime and fastForward. Cypress exposes cy.clock and cy.tick. Both override Date and the browser timers inside the page, so time-driven UI like countdowns, banners and scheduled state changes becomes controllable. It is the end-to-end equivalent of the injectable clock you already use in unit tests.
Pass milliseconds instead of a time string. Playwright's readable string format takes two digits per segment, so a three-digit hour value like 336 hours is not valid input and fourteen days has no string representation at all. For a 14-day jump, call fastForward with 14 times 24 times 60 times 60 times 1000. The numeric form has no upper bound and is the documented way to advance a long window in one call.
Pin CI to a fixed, non-UTC timezone rather than leaving it to the runner default. UTC hides an entire class of bug, because most offset and DST math looks correct when the offset is zero. Running CI in a timezone with a half-hour offset or an active DST rule surfaces the assumptions that would otherwise only fail in a customer's region.
Pie runs the flow the way a user would and reaches setup state through execution scripts, which are shell commands registered against your app and invoked mid-test. A script that calls your own API to age a subscription record puts the account in the expired state directly, so the run never waits out a real trial. Pie does not manipulate the device clock, so the clock seam stays where it belongs, in your backend or your app code.
Adithya Aggarwal
Adithya Aggarwal
CTO & Co-founder at Pie

Eight years building search and delivery systems at Amazon. The kind of scale where flaky tests block billion-dollar releases. Now CTO at Pie, building AI agents that adapt when your UI changes. LinkedIn →