All articles

// Knowledge.log — 技術記事

Instant.now(): the deadline test that never fails in the morning

JDK 25: Instant.now() in domain code ties the overdue test to the machine clock. Clock.systemUTC() in production, Clock.fixed in JUnit. 8 tests, 0 failures.

The rule looks simple. The invoice is due at 08:00 UTC, and after that it's overdue. The domain code calls Instant.now(), compares it with the due time and returns a boolean. At 7 a.m. the test is green. After lunch the same line is red, and nobody has changed anything. LocalDate.now() is worse, because its result also changes when the day rolls over and when the test runs on a machine in a different time zone.

The usual workarounds are to disable the assertion, loosen assertTrue into "whatever" or add a Thread.sleep until midnight. The sleep does work, as long as your CI is fine with an eighteen-hour job.

The fix is to inject a java.time.Clock through the constructor, using Clock.systemUTC() in production and Clock.fixed(...) in the test. Below you'll see the naive test failing on this host, the same deadline pinned with Clock.fixed, and the point where Clock stops helping.

Environment

  • Temurin 25.0.4 (25.0.4+7-LTS), no --enable-preview
  • Maven 3.9.12, maven.compiler.release=25
  • maven-compiler-plugin 3.14.0, maven-surefire-plugin 3.6.0
  • org.junit.jupiter:junit-jupiter 6.1.3
  • Host time zone: Etc/UTC. Run at roughly 05:13 UTC on 2026-10-26.

The pom.xml, trimmed:

<groupId>devdojo</groupId>
<artifactId>deadline-clock-fixture</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
  <maven.compiler.release>25</maven.compiler.release>
  <junit.version>6.1.3</junit.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>${junit.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.14.0</version>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.6.0</version>
      <configuration>
        <excludes>
          <exclude>**/NaiveWallClockBoundaryTest.java</exclude>
        </excludes>
      </configuration>
    </plugin>
  </plugins>
</build>

The naive test is left out of the default Surefire run on purpose. Its job is to fail, so it only runs when you ask for it by name.

What the javadoc says about no-arg now()

The JDK 25 javadoc doesn't hedge. For Instant.now()), it says the method queries the system UTC clock and that using it "will prevent the ability to use an alternate time-source for testing because the clock is effectively hard-coded".

LocalDate.now()) works the same way, with an extra catch: it uses the system clock in the default time-zone. The clock is still hard-coded, and now the JVM's default time zone affects the result too.

Both classes have an overload that takes a clock: Instant.now(Clock) and LocalDate.now(Clock). The javadoc for each says it allows an alternate clock in tests and that the clock can be supplied through dependency injection. The Clock overview calls it best practice to pass a Clock into any method that needs the current instant and time zone.

In practice, a due-date rule that calls no-arg now() depends on the time and time zone of whatever machine it runs on. That's why the result changes when the day or the time zone changes.

Domain code that reads the machine clock

This is the version a lot of teams have in production:

package devdojo.deadline;

import java.time.Instant;
import java.time.LocalDate;

public final class WallClockInvoice {
    private final Instant dueInstant;
    private final LocalDate dueDate;

    public WallClockInvoice(Instant dueInstant, LocalDate dueDate) {
        this.dueInstant = dueInstant;
        this.dueDate = dueDate;
    }

    public boolean isOverdueInstant() {
        return Instant.now().isAfter(dueInstant);
    }

    public boolean isOverdueDate() {
        return LocalDate.now().isAfter(dueDate);
    }
}

Its only constructor doesn't take a Clock, and the suite checks this with reflection (wallClockInvoiceHasNoClockConstructor). The test has no way to choose the time.

The naive test tries anyway. It expects the invoice to be overdue "as if it were 09:00Z", and not yet overdue by date "as if it were the 25th in São Paulo":

class NaiveWallClockBoundaryTest {

    @Test
    void assertsOverdueAtNineAmButDomainCallsInstantNow() {
        Instant due = Instant.parse("2026-10-26T08:00:00Z");
        WallClockInvoice invoice = new WallClockInvoice(due, LocalDate.of(2026, 10, 25));
        assertTrue(invoice.isOverdueInstant());
    }

    @Test
    void assertsNotOverdueOnSaoPauloOctober25ButDomainCallsLocalDateNow() {
        WallClockInvoice invoice = new WallClockInvoice(
                Instant.parse("2026-10-26T08:00:00Z"), LocalDate.of(2026, 10, 25));
        assertFalse(invoice.isOverdueDate());
    }
}

The "as if it were 09:00Z" exists only in the head of whoever wrote the test. The JVM never got that memo.

mvn -q test -Dtest=NaiveWallClockBoundaryTest

Result on this host: tests=2, failures=2, errors=0.

  • assertsOverdueAtNineAmButDomainCallsInstantNow: expected: <true> but was: <false>. It was around 05:13Z, before 08:00Z, so the invoice wasn't due yet.
  • assertsNotOverdueOnSaoPauloOctober25ButDomainCallsLocalDateNow: expected: <false> but was: <true>. The host time zone is Etc/UTC, so LocalDate.now() returned 2026-10-26, which is after the 25th.

Run the same file after 08:00Z and the first test passes. Nobody fixed anything. The clock moved, and the result moved with it.

Computing the expected value with Instant.now() inside the test doesn't help either. The domain and the test read the same machine clock at slightly different moments, so all the test proves is that the clock exists.

The same deadline with Clock.fixed

The rule doesn't change. The only difference is that the clock comes in through the constructor:

package devdojo.deadline;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

public final class ClockInvoice {
    private final Instant dueInstant;
    private final LocalDate dueDate;
    private final Clock clock;

    public ClockInvoice(Instant dueInstant, LocalDate dueDate, Clock clock) {
        this.dueInstant = dueInstant;
        this.dueDate = dueDate;
        this.clock = clock;
    }

    public static ClockInvoice productionUtc(Instant dueInstant, LocalDate dueDate) {
        return new ClockInvoice(dueInstant, dueDate, Clock.systemUTC());
    }

    public static ClockInvoice productionInZone(Instant dueInstant, LocalDate dueDate, ZoneId zone) {
        return new ClockInvoice(dueInstant, dueDate, Clock.system(zone));
    }

    public boolean isOverdueInstant() {
        return Instant.now(clock).isAfter(dueInstant);
    }

    public boolean isOverdueDate() {
        return LocalDate.now(clock).isAfter(dueDate);
    }
}

The test now picks both the instant and the time zone:

class ClockDeadlineTest {

    private static final Instant DUE_INSTANT = Instant.parse("2026-10-26T08:00:00Z");
    private static final LocalDate DUE_DATE = LocalDate.of(2026, 10, 25);
    private static final ZoneId SAO_PAULO = ZoneId.of("America/Sao_Paulo");

    @Test
    void fixedClockPinsInstantOverdueIndependentOfHostTime() {
        Clock seven = Clock.fixed(Instant.parse("2026-10-26T07:00:00Z"), ZoneOffset.UTC);
        Clock nine = Clock.fixed(Instant.parse("2026-10-26T09:00:00Z"), ZoneOffset.UTC);
        assertFalse(new ClockInvoice(DUE_INSTANT, DUE_DATE, seven).isOverdueInstant());
        assertTrue(new ClockInvoice(DUE_INSTANT, DUE_DATE, nine).isOverdueInstant());
    }

    @Test
    void sameInstantLocalDateFlipsAcrossSaoPauloBoundaryWithFixedClock() {
        Instant twoAm = Instant.parse("2026-10-26T02:00:00Z");
        Clock beforeMidnightSp = Clock.fixed(twoAm, SAO_PAULO);
        Clock afterMidnightSp = Clock.fixed(Instant.parse("2026-10-26T03:00:00Z"), SAO_PAULO);
        Clock sameInstantUtc = Clock.fixed(twoAm, ZoneOffset.UTC);

        assertEquals(LocalDate.of(2026, 10, 25), LocalDate.now(beforeMidnightSp));
        assertEquals(LocalDate.of(2026, 10, 26), LocalDate.now(sameInstantUtc));
        assertEquals(Instant.now(beforeMidnightSp), Instant.now(sameInstantUtc));

        assertFalse(new ClockInvoice(DUE_INSTANT, DUE_DATE, beforeMidnightSp).isOverdueDate());
        assertTrue(new ClockInvoice(DUE_INSTANT, DUE_DATE, afterMidnightSp).isOverdueDate());
        assertTrue(new ClockInvoice(DUE_INSTANT, DUE_DATE, sameInstantUtc).isOverdueDate());
    }
}
mvn -q test

Result: ClockDeadlineTest with tests=8, failures=0, errors=0, skipped=0.

With the instant rule due at 2026-10-26T08:00:00Z and the date rule due on 2026-10-25, these were the observed values:

ClockRuleValue readOverdue?
Clock.fixed(07:00Z, UTC)instant vs 08:00ZInstant 07:00Zfalse
Clock.fixed(09:00Z, UTC)instant vs 08:00ZInstant 09:00Ztrue
Clock.fixed(02:00Z, America/Sao_Paulo)date vs 2026-10-25Instant 02:00Z, LocalDate 2026-10-25false
Clock.fixed(03:00Z, America/Sao_Paulo)date vs 2026-10-25LocalDate 2026-10-26true
Clock.fixed(02:00Z, UTC)date vs 2026-10-25Instant 02:00Z, LocalDate 2026-10-26true
Machine clock, ~05:13Z, Etc/UTCinstant vs 08:00ZInstant.now()false
Machine clock, ~05:13Z, Etc/UTCdate vs 2026-10-25LocalDate.now() (26)true

Two rows are worth a closer look. At 02:00Z, the São Paulo clock and the UTC clock return the same Instant but different dates: the 25th in São Paulo, which was at UTC−3, and the 26th in UTC. The date rule flips without anyone waiting for midnight. Changing the Clock's ZoneId is enough. Also, two consecutive reads of Instant.now(fixed) return the same value. Clock.fixed always returns the same instant, and the javadoc describes it as "not a clock in the conventional sense".

A note about the suite itself: two of the eight tests, wallClockInstantOverdueCouplesToHostNowVersusPinnedNineAm and wallClockDateOverdueCouplesToDefaultZoneAndHostDate, deliberately compare WallClockInvoice against the pinned clock. They record what happened at that particular hour. Run after 08:00Z, the first one flips. So the problem this article is about shows up inside the very suite that demonstrates the fix. The Clock.fixed tests don't have that problem.

About JUnit: version 6.1.3 has no annotation for freezing the clock. There's no @FixedClock. @DefaultTimeZone only changes the JVM's default time zone, and the Instant you read is still the machine's. What fixes it is Clock.fixed passed through the constructor.

Production: Clock.systemUTC() or a business ZoneId

Choose the production clock based on the kind of rule:

  • The rule compares instants (was it due at 08:00Z?): use Clock.systemUTC(). The javadoc recommends it over systemDefaultZone() when you need the current instant without a date or time. That's ClockInvoice.productionUtc(...) above. In the suite, productionSystemUtcMatchesWallInstantOnThisHost confirms it stays within 2 s of Instant.now() on this host.
  • The rule compares dates (was it due on the 25th?): use a Clock with the business ZoneId declared explicitly, such as Clock.system(ZoneId.of("America/Sao_Paulo")), which is productionInZone(...). In the test, the equivalent is Clock.fixed(instant, zone). Avoid Clock.systemDefaultZone() and no-arg LocalDate.now(), because both let the JVM's default time zone decide what day it is.

Prefer the constructor over a setter or a static global Clock. The object starts out with the clock it will use, the test passes in whatever clock it needs, and no now() is left hiding anywhere. If the due-date decision gets logged, log the instant read from the Clock next to the result. Then, when someone asks why the invoice was flagged overdue, the answer is already in the log.

DevDojo uses this pattern in any domain class that makes a decision based on "now". If a class never asks for the current time, it doesn't need a Clock.

Where Clock doesn't reach

Some people assume Clock.fixed freezes the whole JVM. It doesn't. It's an ordinary object, and only code that receives it uses its time. The clockFixedDoesNotFreezeSystemCurrentTimeMillisOrInstantNowNoArg test checks this:

@Test
void clockFixedDoesNotFreezeSystemCurrentTimeMillisOrInstantNowNoArg() {
    Clock fixed = Clock.fixed(Instant.parse("2026-10-26T08:00:00Z"), ZoneOffset.UTC);
    ClockInvoice invoice = new ClockInvoice(DUE_INSTANT, DUE_DATE, fixed);

    assertEquals(fixed.instant(), Instant.now(fixed));            // 08:00Z
    assertNotEquals(fixed.instant(), invoice.leakWallInstant());  // Instant.now() sem argumento
    assertNotEquals(fixed.millis(), invoice.leakCurrentTimeMillis()); // System.currentTimeMillis()
}

What it doesn't cover:

  • System.currentTimeMillis() and no-arg Instant.now() still read the machine clock, even when the same object holds a Clock.fixed. One forgotten now() in a helper method is enough to make the test depend on the time of day again.
  • Libraries that never receive the Clock see the real time. If a dependency computes expiry by calling Instant.now() internally, your Clock.fixed has no effect on it.
  • The Clock's ZoneId changes LocalDate.now(clock) but not Instant.now(clock). Changing the fixed clock's time zone leaves the instant rule alone and only affects the date rule.
  • Mockito's mockStatic(Instant.class) looks like a shortcut, but it doesn't fix the problem. The production code still has now() hard-coded, and the Clock javadoc's whole point is to get the time from an object instead of a static method.

If the test needs to simulate "an hour from now", there's Clock.offset(base, Duration). In the suite, Clock.offset(fixed, Duration.ofHours(1)) returns 09:00Z, and only for code that receives that clock.

Next step

Search for no-arg Instant.now() and LocalDate.now() in domain code that decides due dates, expiry or deadlines:

grep -rnE 'Instant\.now\(\)|LocalDate\.now\(\)' src/main/java

For each hit that decides an overdue boolean, add a Clock to the constructor, replace the call with Instant.now(clock) or LocalDate.now(clock), and write a Clock.fixed test on each side of the boundary: one minute before and one minute after the due time, plus, for the date rule, the same instant in two time zones. That test gives the same result at 7 a.m. and after lunch.

javatesting

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles