Date and Time Testing

Date and time testing checks that software behaves correctly at the moments the calendar and the clock make special: when a trial or a token expires, when a subscription renews, when a scheduled job runs, when a user turns 18, when a month, a year or a daylight-saving period ends. The clock is an input like any form field, but nobody types it, so most teams only ever test the one value it had on the day the tests ran. Future-date testing, also called date simulation or time-travel testing, makes that input controllable: you set the clock, or shift the dates inside the data, to the moments that matter and check the expected state at each one.

This guide shows how to make time controllable in code and in test environments, how to choose between moving the clock and shifting test data, which calendar, time-zone and timestamp edge cases to cover, and how to freeze time in Playwright, Jest, Vitest, Java, Python and .NET. It ends with a worked 30-day trial and a copy-ready checklist. How dates, numbers and currencies are displayed in each locale is taught in the roadmap lesson Dates, Numbers, Currency and Time Zones; this guide is about behaviour over time.

What date and time testing covers

Every rule that mentions a duration, a deadline or a calendar unit reads the clock somewhere, and that is where its defects hide. The clock is a hidden input: the test passes on the day it is written, and the failure appears 30 days later, on 29 February, on the night the clocks change or on 1 January, when nobody is watching. Time-dependent defects also escape because every environment has its own clock and zone — a laptop in Riyadh, a CI runner in UTC, a production server somewhere else — and because the expected result depends on when the test runs, so many such checks end up with no fixed expected value at all.

Date and time testing is not a separate test level. It is a family of test conditions — moments in time — applied to UI tests, API tests, batch jobs and reports. The technique is boundary value analysis: every time rule has boundaries, and the interesting values sit just before, exactly at and just after each of them. The clock is an input wherever the product has:

  • Expiry — trials, passwords, sessions, tokens, coupons, quotes, one-time codes, signed URLs and certificates.
  • Renewals and billing — subscriptions, invoices, grace periods, dunning, late fees and interest that accrues per day.
  • Schedules — cron jobs, reminders, notifications, reports, and retention or purge jobs that delete data after N days.
  • Eligibility — age limits (18 today or tomorrow?), probation periods, cooling-off and refund windows, warranty periods.
  • Reporting — "today", "this week", "this month", fiscal periods and anything grouped by day, all of which depend on the zone.
  • Ordering and history — audit trails, "latest" records, sorting by created_at and time-ordered identifiers.

Try it: Boundary Value Analysis

Make time controllable: inject the clock

The most valuable change for testability is to stop reading "now" deep inside the code. When business logic calls Date.now(), Instant.now(), DateTime.UtcNow or datetime.now() directly, the only way to test it at another moment is to change the clock of the whole machine. When the logic receives a clock — a small interface that returns the current instant — a test can pass a fixed clock set to 2026-03-02T06:59:59Z while production passes the system clock. This is clock injection. Java has had java.time.Clock, with Clock.fixed and Clock.offset, since Java 8, and its documentation recommends passing a Clock into any method that needs the current instant; .NET 8 added System.TimeProvider, with FakeTimeProvider for tests; in JavaScript and Python a small function or parameter does the same job.

For end-to-end and manual testing the same idea moves up a level: a test-only time-travel setting. A non-production build reads a clock offset or a fixed instant from configuration, or from an admin endpoint that exists only in test environments, so a tester can say "this account lives on 2 March 2026" without touching the server clock. The application must then use that clock everywhere — the web layer, the background jobs and the database queries that call NOW() or CURRENT_TIMESTAMP — or its parts will disagree about what day it is.

Changing the clock of a machine or a process (environment-level clock shifting) still has its place, for legacy systems and binaries you cannot change, but it has side effects the test must plan for:

  • TLS: certificate validity is checked against the local clock, so a clock moved past a certificate’s expiry, or before its start, makes HTTPS calls fail with certificate errors that have nothing to do with the feature under test.
  • Tokens and signatures: a JWT, an OAuth token or a signed URL issued by a service with the real clock looks expired, or not yet valid, to a component with the shifted clock.
  • Time synchronisation: NTP clients (chrony, systemd-timesyncd, the Windows Time service) set the clock back to real time, sometimes in the middle of a test; disable them on the machine under test and re-enable them afterwards.
  • Shared environments: the system clock belongs to everyone on the server, so one tester’s time travel breaks everybody else’s tests. Containers share the host’s wall clock, because Linux time namespaces virtualise only the monotonic and boot-time clocks, not CLOCK_REALTIME.
  • Data left in the future: records created while the clock was ahead keep their future timestamps after it returns, which breaks sorting, "latest" queries and time-ordered IDs; reset the data together with the clock.
  • Catch-up runs: moving the clock forward by days can make a scheduler run every missed job at once, or skip them all, depending on the scheduler.

Future-date testing: boundaries and expected states

Future-date testing, also called forward-date testing, checks what the system does when time has moved on: the trial has ended, the invoice is overdue, the user is now 18, the retention period has passed. The method is the same for every rule. Write the rule as a boundary instant, list the moments around it, and write down the expected state at each moment before you run anything.

Precision decides how close "just before" is. If the system stores seconds, test one second before, exactly at and one second after the boundary; if it stores milliseconds, one millisecond before matters too. Storage can move a moment onto the boundary by itself: MySQL rounds a value with fractional seconds that is inserted into a column with fewer fractional digits, and the default precision of DATETIME and TIMESTAMP columns is 0, so an event at 06:59:59.700 is stored as 07:00:00, exactly on the boundary, and is judged to have happened after the trial ended. Test values ending in .499 and .500 just before the boundary.

Then settle inclusive versus exclusive with the product owner, because "valid until 2 March" has at least three readings: until the start of 2 March, until the end of 2 March, or until the instant the purchase is 30 days old — and in which zone? Standards make the choice explicit: a JWT must be rejected as soon as the current time is no longer before exp, and accepted from nbf onwards (RFC 7519). Write the chosen rule into the acceptance criteria as a comparison, such as active while now < end.

  • Before, at and after every boundary: −1 s, exact, +1 s (or −1 ms when milliseconds are stored).
  • Long gaps: the user returns after 400 days; a job did not run for a week; a reminder date had already passed when the record was created.
  • Order of events: renewal and cancellation on the same day; a payment that arrives one second after expiry; a refund requested on the last day of the window.
  • Derived values: "days left" counters, progress bars and emails that say "ends tomorrow", checked at 23:59 and 00:00 in the user’s zone.
  • Background effects: the account is expired for requests at the boundary, but the nightly job that downgrades it runs later; check what the user sees in between.
  • Past dates as input: back-dated records, imports of historical data, and a start date in the past that puts the end date before today.

Try it: Date & Time Test Simulator Try it: Boundary Value Analysis Try it: Date Calculator

Date simulation vs date shifting: move the clock or the data

There are two ways to put a system into the future. Date simulation, or time travel, moves the clock the application reads — through an injected clock, a time-travel setting or a faked system clock — and leaves the data as it is. Date shifting moves the data: every date in a test data set is shifted by the same offset, or written relative to an anchor such as "today minus 29 days", so the relationships between dates — 29 days since purchase, 3 days to renewal — stay valid whenever the suite runs, against the real clock.

Shifting preserves intervals, not calendar positions. Shift every date by +30 days and a purchase on 31 January lands on 2 March: the 29 days to the end of the trial survive, but the month end, the weekday and a 29 February do not, unless you choose the offset deliberately (a multiple of 7 keeps weekdays; a whole number of years keeps month and day, but neither weekdays nor leap days). Every derived field must move with its source: ages, due dates, ISO week numbers, fiscal periods, partition keys, and any hash or signature computed over a date.

  • Use date simulation for calendar-specific behaviour: month ends, 29 February, year end, a DST change day, a tz rule change, the 2038 boundary, and anything whose expected result depends on the exact date.
  • Use date shifting for relative rules, in shared environments and in parallel runs: "expires 30 days after purchase", "remind 3 days before renewal", "archive after 90 days". No clock is touched, so TLS, tokens, other testers and NTP are unaffected.
  • Combine them: shifted data for most of the regression suite, and a small set of simulated-clock tests pinned to specific calendar moments.
  • With either approach, external systems keep their own clocks: a payment provider’s sandbox, an identity provider and a mail server will not share your simulated date or your shifted data.
-- Date shifting (PostgreSQL): move a whole data set 30 days forward,
-- so every relative age stays the same; shift derived columns with it
UPDATE subscriptions
   SET signed_up_at = signed_up_at + INTERVAL '30 days',
       trial_end    = trial_end    + INTERVAL '30 days',
       renews_on    = renews_on    + 30;   -- renews_on is a DATE: + 30 adds days

// Relative fixtures instead of fixed dates (JavaScript)
const DAY = 24 * 60 * 60 * 1000;
const now = clock.now();                // injected clock, milliseconds since the epoch
const account = { signedUpAt: new Date(now - 29 * DAY) };   // 29 days ago
// expected: trial active, "1 day left"

Try it: Date Range Generator Try it: Date Difference Calculator

Calendar edge cases: month ends, leap years and weeks

Month arithmetic is where libraries disagree. Adding one month to 31 January has no single right answer, and each library picks one. JavaScript’s Date overflows: setMonth on 31 January 2026 asks for 31 February, which rolls over to 3 March 2026 (MDN’s own example turns 31 January 2016 into 2 March 2016). Java’s LocalDate.plusMonths clamps to the last valid day, so 2026-01-31 plus one month is 2026-02-28, and the Temporal API’s add clamps the same way by default (overflow: "constrain"), or throws a RangeError with overflow: "reject". For a "monthly" renewal on the 31st both behaviours drift: overflow pushes each renewal into the next month (3 March, 3 April, 3 May), and clamping applied to the previous renewal slides to the 28th (28 February, 28 March, 28 April). Month ends are kept only when every renewal is computed from the original date, start + n months (28 February, 31 March, 30 April). The requirement must say which rule applies, and the tests must cover the 29th, 30th and 31st and several consecutive renewals; the Date and Time Test Simulator shows these schedules side by side.

"30 days" and "1 month" are different rules: from 31 January 2026, 30 days end on 2 March and one clamped month on 28 February. Leap years follow the Gregorian rule: a year divisible by 4 is a leap year, except century years, which are leap years only when divisible by 400 — 2028 and 2000 are leap years, 2100 is not. Someone born on 29 February has a birthday that exists only every four years, and the product must decide whether they turn 18 on 28 February or on 1 March in the other years.

Weeks have their own calendar. In ISO 8601 a week starts on Monday and week 1 is the week that contains the year’s first Thursday, so a year has 52 or 53 weeks and the first days of January can belong to the previous year: 2026 has 53 ISO weeks, and 1 January 2027 falls in week 53 of 2026. Formatting patterns trip over this. In Java’s DateTimeFormatter, YYYY is the week-based year and yyyy the calendar year, so the pattern YYYY-MM-dd prints 31 December 2026 as 2027-12-31 in the en-US locale, whose weeks start on Sunday.

One rule, three libraries: add 1 month to 31 January 2026

JavaScript Date   d = new Date(2026, 0, 31); d.setMonth(d.getMonth() + 1)
                  -> 3 March 2026   (31 February overflows into March)
Java              LocalDate.of(2026, 1, 31).plusMonths(1)
                  -> 2026-02-28     (clamped to the last valid day)
Temporal          Temporal.PlainDate.from("2026-01-31").add({ months: 1 })
                  -> 2026-02-28     (overflow: "constrain", the default)
                  ...add({ months: 1 }, { overflow: "reject" })
                  -> RangeError
"30 days" instead of "1 month"
                  -> 2026-03-02

Monthly renewal from 31 January 2026, next three renewals
overflow, from the previous renewal (JS setMonth)     2026-03-03, 2026-04-03, 2026-05-03
clamp, from the previous renewal (Java plusMonths)   2026-02-28, 2026-03-28, 2026-04-28
clamp, from the original date (start + n months)     2026-02-28, 2026-03-31, 2026-04-30

Dates every date test data set should contain
2026-01-29 .. 2026-01-31    monthly rules on the 29th, 30th and 31st
2026-02-28, 2026-03-01      end of a short February
2028-02-29, 2028-03-01      leap day and the day after
2100-02-28                  2100 is not a leap year (no 29 February)
2026-12-31, 2027-01-01      year end; both fall in ISO week 2026-W53
2025-12-29                  already in ISO week 2026-W01
2026-12-27 .. 2026-12-31    Java pattern YYYY-MM-dd prints 2027 in en-US

Try it: Date & Time Test Simulator Try it: Date Test Data Generator Try it: Date Calculator

Time zones, DST and tz database changes

Store instants in UTC or with an explicit offset, and keep the user’s zone as an IANA name such as Europe/Berlin or Asia/Riyadh, never as a fixed offset: an offset is a fact about one moment, a zone is a set of rules that changes over time. Convert to local time only at the edges. The roadmap lesson Dates, Numbers, Currency and Time Zones covers how times are displayed; for behaviour over time, the defects come from three places.

First, daylight saving time (DST) creates local times that do not exist and local times that happen twice. In Europe/Berlin on 29 March 2026 the clocks jump from 02:00 to 03:00, so 02:30 does not exist (a gap); on 25 October 2026 they go back from 03:00 to 02:00, so 02:30 happens twice, at 00:30Z and at 01:30Z (an overlap). In America/New_York the changes fall on 8 March 2026 (gap 02:00–03:00) and 1 November 2026 (overlap 01:00–02:00). Libraries resolve these cases by rule: Java’s ZonedDateTime and Temporal’s default "compatible" disambiguation move a time in a gap forward (02:30 becomes 03:30) and pick the earlier offset in an overlap, and Temporal can also be told to reject both.

A daily job scheduled for 02:30 local time needs a stated behaviour on both days — run at 03:00, run at 03:30, skip, or run once rather than twice. cronie, the cron daemon of many Linux distributions, runs jobs from a skipped interval immediately after the change and avoids running them twice in a repeated one, for clock changes of less than three hours; other schedulers differ, so test the job, not only the expression. Durations differ too: 30 calendar days from 10 March 2026 10:00 in Europe/London end at 10:00 BST on 9 April, but 720 hours end at 11:00 BST, because the clocks went forward on 29 March.

Second, zones without DST hide all of this. Asia/Riyadh is UTC+03:00 all year with no changes, so a team in Riyadh never sees a gap or an overlap unless it tests a zone that has them. Add offsets that are not whole hours — Asia/Kolkata at +05:30 and Asia/Kathmandu at +05:45 — to catch code that stores offsets as integer hours.

Third, the rules themselves change, and the tz database (tzdata) records each change in a new release. Mexico stopped observing DST after 2022 except near the US border (tzdata 2022f), so America/Mexico_City has been UTC−06:00 all year since 30 October 2022. Paraguay stopped changing its clocks after the spring-forward of 6 October 2024 and stays at UTC−03:00 (tzdata 2025a, January 2025), so America/Asuncion is −03 all through 2026. Manitoba (America/Winnipeg) moves to permanent −05 on 31 October 2026 (tzdata 2026e, 29 September 2026). A runtime that ships older data computes the old rules: JDK 22.0.2, with its bundled tzdata 2024a, converts 2026-07-01T12:00Z to 08:00 −04:00 in Asunción instead of 09:00 −03:00. Test against the tzdata version you deploy, in every runtime — operating system, JVM, Node.js and ICU, browser, database — and add a test that fails when the conversion for a recently changed zone is wrong.

Daylight saving changes in 2026: what a 02:30 local time becomes

Zone              Day      Clocks           02:30 local time
Europe/Berlin     29 Mar   02:00 -> 03:00   does not exist (gap); java.time and Temporal
                                            "compatible" resolve it to 03:30 +02:00
Europe/Berlin     25 Oct   03:00 -> 02:00   happens twice (overlap):
                                            00:30Z at +02:00 and 01:30Z at +01:00
America/New_York  8 Mar    02:00 -> 03:00   does not exist; resolved to 03:30 -04:00
America/New_York  1 Nov    02:00 -> 01:00   happens once; 01:00-01:59 happens twice
Europe/London     29 Mar   01:00 -> 02:00   normal; 01:00-01:59 does not exist
Asia/Riyadh       none     +03:00 all year  always exists, exactly once

30 calendar days vs 720 hours across a change (Europe/London)
2026-03-10 10:00 GMT + 30 days = 2026-04-09 10:00 BST (09:00Z)
2026-03-10 10:00 GMT + 720 h   = 2026-04-09 11:00 BST (10:00Z)

Zones whose rules changed recently (tzdata release that records it)
America/Mexico_City  -06:00 all year since 2022-10-30 (2022f)
                     2026-07-01T12:00Z -> 06:00; older data: 07:00 -05:00
America/Asuncion     -03:00 all year since 2024-10-06 (2025a)
                     2026-07-01T12:00Z -> 09:00; older data (e.g. JDK 22.0.2
                     with tzdata 2024a): 08:00 -04:00
America/Winnipeg     permanent -05:00 from 2026-10-31 (2026e), no fall-back on 1 Nov
                     2026-12-01T12:00Z -> 07:00; older data: 06:00 -06:00

Try it: Timezone Converter Try it: Cron Expression Explainer

Timestamps: epoch units, 2038, leap seconds and formats

Unix time counts seconds since 1970-01-01T00:00:00Z; JavaScript and many APIs count milliseconds. A unit mix-up is easy to spot once you know its signature: 1772434800 (2 March 2026, 07:00Z, in seconds) read as milliseconds is 21 January 1970, and the same instant in milliseconds read as seconds lands in the year 58136. JWT claims such as exp, nbf and iat are NumericDate values in seconds (RFC 7519), so a token whose exp is in milliseconds looks valid for tens of thousands of years.

The year 2038 problem is the limit of a signed 32-bit count of seconds: the largest value, 2147483647, is 2038-01-19T03:14:07Z, and one second later the counter wraps to −2147483648, which is 1901-12-13T20:45:52Z. For tests it is not a distant problem: a 15-year contract, a mortgage, a certificate or a "never expires" sentinel date already crosses it. MySQL’s TIMESTAMP type, for example, ends at 2038-01-19 03:14:07 UTC, while DATETIME reaches 9999-12-31. Test a date after 19 January 2038 in every field that stores a future date, through every layer — API, database, exports and mobile apps.

Leap seconds do not exist in Unix time: POSIX defines every day as exactly 86400 seconds, JavaScript’s Date ignores them, and RFC 3339 allows a seconds value of 60 only where a leap second actually occurs. Do not try to test them inside the application; check instead that input containing :60 is rejected or handled as the requirement says, and that durations are measured with a monotonic clock rather than as differences between wall-clock readings.

Formats: exchange instants as RFC 3339, the internet profile of ISO 8601 — 2026-03-02T07:00:00Z or 2026-03-02T10:00:00+03:00, always with Z or an offset. A string such as 03/04/2026 is 4 March in the United States and 3 April in most other places, and JavaScript’s Date guarantees only its ISO 8601-based format; other strings are parsed however the engine chooses. Even ISO strings hold a trap: in JavaScript a date-only string such as 2026-03-04 is parsed as UTC, but a date-time without an offset, such as 2026-03-04T00:00, as local time. In PostgreSQL, timestamp without time zone silently ignores an offset in the input, while timestamp with time zone converts it to UTC.

One instant, five encodings
RFC 3339, UTC           2026-03-02T07:00:00Z
RFC 3339, Riyadh        2026-03-02T10:00:00+03:00
Unix seconds            1772434800
Unix milliseconds       1772434800000
JWT "exp" (seconds)     1772434800

Unit mix-ups and what they look like
seconds read as ms      1772434800    -> 1970-01-21T12:20:34.800Z
ms read as seconds      1772434800000 -> a date in the year 58136

Signed 32-bit seconds (the 2038 problem)
 2147483647             2038-01-19T03:14:07Z   largest value
-2147483648             1901-12-13T20:45:52Z   one second later, after wrap-around
MySQL TIMESTAMP         up to 2038-01-19 03:14:07 UTC
MySQL DATETIME          up to 9999-12-31 23:59:59

Ambiguous or surprising input
03/04/2026              4 March (en-US) or 3 April (en-GB)
2026-03-04              JavaScript: midnight UTC (date-only form)
2026-03-04T00:00        JavaScript: midnight local time (date-time, no offset)
2026-03-04 10:00+03     PostgreSQL timestamp without time zone: offset ignored

Try it: Unix Timestamp Converter Try it: ISO 8601 Converter Try it: JWT Expiration Checker

How to freeze or shift time in Playwright, Jest, Java, Python and .NET

Every mainstream stack has a supported way to control time in tests. Use it at the level where the decision is made: a browser clock does not move the server, and a server-side fake does not change what the browser shows.

  • Playwright (browser): page.clock fakes Date, the timers and performance inside the page. setFixedTime freezes Date.now() and new Date() while timers keep running; install({ time }) must come before any other clock call and enables fastForward, runFor, pauseAt and resume; setSystemTime changes the time without firing timers, to simulate a clock change. Combine it with the timezoneId option to run the browser in a given zone.
  • Jest and Vitest (JavaScript unit tests): useFakeTimers() replaces Date and the timer functions; setSystemTime(date) sets the current time without firing timers; advanceTimersByTime(ms) runs timers; useRealTimers() restores everything after the test. Vitest’s vi.setSystemTime also works without fake timers, and then mocks only Date.
  • Java: pass a java.time.Clock; use Clock.fixed(instant, zone) for a frozen moment and Clock.offset(baseClock, duration) to run in the future or the past.
  • Python: time-machine (time_machine.travel(destination, tick=False)) or freezegun (@freeze_time("2026-03-02")) patch datetime and time for the test; time-machine’s traveller can shift() or move_to() during the test.
  • .NET: depend on TimeProvider (built into .NET 8 and later, available to older targets through the Microsoft.Bcl.TimeProvider package) and use FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package. Advance(TimeSpan) and SetUtcNow move time forward only (SetUtcNow refuses an earlier time), while AdjustTime changes it without affecting timers.
  • Binaries you cannot change: libfaketime intercepts the time calls of one process on Linux through LD_PRELOAD or the faketime wrapper, with an offset such as FAKETIME="+30d" or an absolute date. It does not work with statically linked binaries, and runtimes such as Go can bypass it.
// isTrialActive, is_trial_active and TrialService stand for the code under test.

// Playwright Test (page.goto("/account") resolves against baseURL in playwright.config).
// This fakes the browser clock only: the server must agree on "now".
import { test, expect } from "@playwright/test";
test.use({ timezoneId: "Asia/Riyadh" });
for (const [now, plan] of [["2026-03-02T06:59:59Z", "Trial"], ["2026-03-02T07:00:00Z", "Expired"]]) {
  test(`plan shown at ${now}`, async ({ page }) => {
    await page.clock.setFixedTime(new Date(now));
    await page.goto("/account");
    await expect(page.getByTestId("plan")).toHaveText(plan);
  });
}

// Jest test file, globals (Vitest: vi.useFakeTimers, vi.setSystemTime, vi.useRealTimers)
afterEach(() => jest.useRealTimers());
test("trial ends exactly at trialEnd", () => {
  jest.useFakeTimers();
  const trialEnd = new Date("2026-03-02T07:00:00Z");
  jest.setSystemTime(new Date("2026-03-02T06:59:59Z"));
  expect(isTrialActive(trialEnd)).toBe(true);
  jest.setSystemTime(trialEnd);
  expect(isTrialActive(trialEnd)).toBe(false);
});

// Java + JUnit 5: production code receives a Clock instead of calling Instant.now()
import java.time.*;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class TrialTest {
  static boolean isTrialActive(Instant end, Clock clock) { return clock.instant().isBefore(end); }

  @Test
  void trialEndsExactlyAtEnd() {
    Instant end = Instant.parse("2026-03-02T07:00:00Z");
    Clock before = Clock.fixed(end.minusSeconds(1), ZoneId.of("Asia/Riyadh"));
    assertTrue(isTrialActive(end, before));
    assertFalse(isTrialActive(end, Clock.offset(before, Duration.ofSeconds(1))));
  }
}

# Python (pytest body): time-machine
import datetime as dt
import time_machine
END = dt.datetime(2026, 3, 2, 7, 0, tzinfo=dt.timezone.utc)
with time_machine.travel(END - dt.timedelta(seconds=1), tick=False) as traveller:
    assert is_trial_active(END)
    traveller.shift(dt.timedelta(seconds=1))
    assert not is_trial_active(END)

// .NET 8+ test body (xUnit): TimeProvider in production, FakeTimeProvider in tests
// (package Microsoft.Extensions.TimeProvider.Testing, namespace Microsoft.Extensions.Time.Testing)
var end = new DateTimeOffset(2026, 3, 2, 7, 0, 0, TimeSpan.Zero);
var time = new FakeTimeProvider(end.AddSeconds(-1));
var trials = new TrialService(time);          // reads time.GetUtcNow()
Assert.True(trials.IsActive(end));
time.Advance(TimeSpan.FromSeconds(1));
Assert.False(trials.IsActive(end));

# libfaketime (Linux): fake the clock of one process only
TZ=UTC faketime "2026-03-02 06:59:59" ./expire-trials
LD_PRELOAD=/path/to/libfaketime.so.1 FAKETIME="+30d" ./expire-trials

Try it: Date & Time Test Simulator

Worked example: a 30-day trial tested without waiting 30 days

The rule, for a fictional product called Daftar Invoices: "The free trial lasts 30 days from sign-up. It is active while the current time is before trial_end. A reminder email is sent 3 days before trial_end. A nightly job at 00:05 Asia/Riyadh downgrades accounts whose trial has ended." A user signs up on 31 January 2026 at 10:00 in Riyadh.

Derive the expected values first. Sign-up is 2026-01-31T10:00:00+03:00, which is 2026-01-31T07:00:00Z or Unix time 1769842800. February 2026 has 28 days, so 30 days later is 2 March 2026 at 10:00 Riyadh time: trial_end = 2026-03-02T07:00:00Z, Unix time 1772434800. Asia/Riyadh has no DST, so 30 calendar days and 720 hours give the same instant here; in Europe/London across 29 March they would not. The reminder is due at 2026-02-27T07:00:00Z (10:00 in Riyadh). The first nightly run that sees the trial as ended is on 3 March 2026 at 00:05 Riyadh time, 2026-03-02T21:05:00Z.

Then pick the moments and the expected state at each, set the clock to each moment (or shift the account’s data so that it is that old), and check every place the rule shows up: the API, the account page, the email, the job and the database. Repeat the table for a sign-up on 31 January 2028: February has 29 days, so trial_end is 1 March 2028 at 10:00 Riyadh time.

Daftar Invoices (fictional): 30-day trial, sign-up 2026-01-31 10:00 Asia/Riyadh
Rule: active while now < trial_end; trial_end = sign-up + 30 days

Moment (UTC)           Riyadh (+03:00)    Expected
2026-01-31T07:00:00Z   31 Jan 10:00:00    active, "30 days left", trial_end 2026-03-02T07:00:00Z
2026-02-27T06:59:59Z   27 Feb 09:59:59    no reminder yet
2026-02-27T07:00:00Z   27 Feb 10:00:00    reminder sent once: "Your trial ends on 2 March 2026"
2026-03-01T20:59:59Z   1 Mar 23:59:59     active, "ends tomorrow"
2026-03-01T21:00:00Z   2 Mar 00:00:00     active, "ends today"
2026-03-02T06:59:59Z   2 Mar 09:59:59     active (last second)
2026-03-02T07:00:00Z   2 Mar 10:00:00     ended: API refuses premium features (exclusive end)
2026-03-02T07:00:01Z   2 Mar 10:00:01     ended
2026-03-02T21:05:00Z   3 Mar 00:05:00     nightly job downgrades the account
(same job run twice)                      second run changes nothing (idempotent)

Wrong implementations the table catches
+1 month, clamped (Java plusMonths)    ends 2026-02-28 10:00 Riyadh   2 days early
+1 month, overflow (JS setMonth)       ends 2026-03-03 10:00 Riyadh   1 day late
sign-up day counted as day 1           ends 2026-03-01 10:00 Riyadh   1 day early
date-only end compared with UTC date   ends 2026-03-02 03:00 Riyadh   7 hours early

Leap-year variant: sign-up 2028-01-31 10:00 Riyadh -> trial_end 2028-03-01T07:00:00Z

Try it: Date & Time Test Simulator Try it: Date Difference Calculator Try it: Timezone Converter

Date and time test checklist and common mistakes

Copy the checklist into a test plan or into a story’s acceptance criteria and strike out what does not apply. The mistakes below are the ones that let time-dependent defects reach production:

  • Asserting against the real clock: expected values computed with new Date() inside the test pass today and fail at midnight, at month end or in another zone — the classic time-dependent flaky test.
  • Testing only "now": a trial created in the test and checked a second later never reaches its end; set the clock, or shift the data, to the boundary.
  • Moving one clock only: the browser is in March, the API in January, the database in real time — and the result reflects none of them.
  • Storing offsets instead of zones: +01:00 saved for a user in Berlin is wrong for half of the year.
  • Trusting a library’s month arithmetic without reading its documentation: overflow and clamping produce different renewal dates.
  • Forgetting to reset: future-dated records and a clock left shifted on a shared server break other people’s tests for days.
  • Running CI only in UTC, or only in Riyadh time: run at least one job in a DST zone and one with a +05:30 offset (TZ for the test runner, timezoneId for the browser).
Date and time test checklist

Rules
[ ] Every time rule written as a comparison with an instant (active while now < end)
[ ] Inclusive or exclusive end agreed; the zone that defines "a day" named
[ ] "30 days" vs "1 month" vs "end of day" decided for each rule

Control of time
[ ] Code reads time from an injected clock / TimeProvider, not from "now" directly
[ ] Server, jobs and database use the same clock in test environments
[ ] Clock shifting plan covers TLS, tokens, NTP, other users and data clean-up

Moments
[ ] -1 s / exact / +1 s around every boundary (-1 ms if milliseconds are stored)
[ ] 23:59:59 and 00:00:00 in the user's zone and in UTC
[ ] The 28th, 29th, 30th and 31st; 29 February 2028; 28 February 2100
[ ] 31 December and 1 January; ISO week 53 (2026); YYYY vs yyyy in patterns
[ ] DST gap and overlap days in a DST zone (Europe/Berlin, America/New_York)
[ ] +05:30 and +05:45 offsets; a zone without DST (Asia/Riyadh)
[ ] A date after 2038-01-19T03:14:07Z in every stored future date

Data and formats
[ ] Instants in RFC 3339 with Z or an offset; zones stored as IANA names
[ ] Seconds vs milliseconds checked at every API boundary (JWT exp is in seconds)
[ ] Column types: timestamptz vs timestamp; MySQL TIMESTAMP range; precision
[ ] tzdata version known for every runtime; recently changed zones tested
[ ] Jobs idempotent; missed and repeated runs handled

Try it: Date Test Data Generator

Frequently asked questions

What is future date testing?

Future date testing, or forward-date testing, checks how software behaves when time has moved on: expired trials and tokens, overdue invoices, renewals, age limits and retention periods. Instead of waiting, testers set the clock the application reads to a future moment — or shift the dates in the test data — and compare the state at each boundary with the expected result.

What is date and time simulation testing?

It is testing with a simulated clock: the application reads time from a clock the test controls — a fixed or offset clock in code, a time-travel setting in a test environment, a faked browser clock or a faked process clock — so any moment can be tested on demand, including month ends, leap days and daylight-saving changes.

Should I shift the clock or shift the test data?

Shift the data — move every date by the same offset, or write it relative to today — for relative rules such as "30 days after purchase", especially in shared environments. Move the clock for calendar-specific behaviour such as 29 February, year end, a DST change day or a tz rule change, where the exact date matters. Most suites need both.

How do I test something that expires in 30 days without waiting?

Compute the expiry instant from the rule, then run the check one second before, exactly at and one second after it, with a controlled clock (Clock.fixed, FakeTimeProvider, jest.setSystemTime, page.clock.setFixedTime, time-machine) or with data created 30 days in the past. Agree first whether the end is inclusive or exclusive, and which zone defines a day.

How do you test time zones and daylight saving time?

Store instants in UTC or with an offset and zones as IANA names, then run the tests in at least one zone that observes DST, one that does not and one with a half-hour offset. Cover the gap (02:30 does not exist on the spring-forward day in Europe/Berlin), the overlap (02:30 happens twice in October), jobs scheduled inside them, and the tzdata version every runtime ships.

What is the year 2038 problem?

Systems that store Unix time as a signed 32-bit number of seconds can count only up to 2147483647, which is 2038-01-19T03:14:07Z; one second later the value wraps to a date in December 1901. Any stored future date after that moment — a long contract, a certificate, a far-future sentinel — exposes it today, and so does MySQL’s TIMESTAMP type, whose range ends at exactly that instant.

References

Tools mentioned in this guide

Plan future-date and time-zone tests: for an expiry, deadline, renewal, age or schedule rule, get the clock moments to test and the expected result at each.

Test Design New

Add or subtract days, weeks, months, years, hours and minutes from a date.

Utilities

Calculate the exact difference between two dates in years, months, days, hours and total units.

Utilities

Convert a date and time between timezones (Riyadh, Amman, Delhi, UTC, New York, London…).

Utilities

Generate date datasets: random dates in a range, leap days, month ends, DST edges and multiple formats.

Test Data

Perform full boundary value analysis (2-value and 3-value BVA) with equivalence classes and expected results.

Test Design

Check whether a JWT is expired, not yet valid, or how long remains until expiry.

Security

How Mutqan Soft can help

More guides

More guides