Search

Dark theme | Light theme

October 26, 2021

Spocklight: Adjusting Time With MutableClock

Testing classes that work with date calculations based on the current date and time (now) can be difficult. First of all we must make sure our class under test accepts a java.time.Clock instance. This allows us to provide a specific Clock instance in our tests where we can define for example a fixed value, so our tests don't break when the actual date and time changes. But this can still not be enough for classes that will behave different based on the value returned for now. The Clock instances in Java are immutable, so it is not possible to change the date or time for a Clock instance.

In Spock 2.0 we can use the new MutableClock class in our specifications to have a Clock that can be used to go forward or backward in time on the same Clock instance. We can create a MutableClock and pass it to the class under test. We can test the class with the initial date and time of the Clock object, then change the date and time for the clock and test the class again without having to create a new instance of the class under test. This is handy in situations like a queue implementation, where a message delivery date could be used to see if messages need to be delivered or not. By changing the date and time of the clock that is passed to the queue implementation we can write specifications that can check the functionality of the queue instance.

The MutableClock class has some useful methods to change the time. We can for example use the instant property to assign a new Instant. Or we can add or subtract a duration from the initial date and time using the + and - operators. We can specify the temporal amount that must be applied when we use the ++ or -- operators. Finally, we can use the adjust method with a single argument closure to use a TemporalAdjuster to change the date or time. This last method is useful if we want to specify a date adjustment that is using months and years.

In the following example Java code we have the class WesternUnion that accepts letters with a delivery date and message. The letter is stored and when the deliver method is we remove the letter from our class if the delivery date is after the current date and time.

package mrhaki;

import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

/**
 * Class to mimic a letter delivery service, 
 * that should deliver letters after the letter
 * delivery data has passed.
 */
public class WesternUnion {
    private final Clock clock;
    private final List<Letter> letters = new ArrayList<>();

    /**
     * Default constructor that uses a default Clock with UTC timezone.
     */
    public WesternUnion() {
        this(Clock.systemUTC());
    }

    /**
     * Constructor that accepts a clock, very useful for testing.
     * 
     * @param clock Clock to be used in this class for date calculations.
     */
    WesternUnion(final Clock clock) {
        this.clock = clock;
    }

    /**
     * Store accepted letter.
     * 
     * @param deliveryDate Date the letter should be deliverd.
     * @param message Message for the letter.
     */
    public int acceptLetter(Instant deliveryDate, String message) {
        final Letter letter = new Letter(deliveryDate, message);
        letters.add(letter);
        return letters.size();
    }

    /**
     * "Deliver" letters where the delivery date has passed.
     */
    public int deliver() {
        Instant now = Instant.now(clock);
        letters.removeIf(letter -> letter.getDeliveryDate().isBefore(now));
        return letters.size();
    }

    /**
     * Simple record for a "letter" which a delivery date and message.
     */
    private record Letter(Instant deliveryDate, String message) {
        private Instant getDeliveryDate() {
            return deliveryDate;
        }
    }
}

In order to test this class we want pass a Clock instance where we can mutate the clock, so we can test if the deliver method works if we invoke it multiple times. In the next specification we test this with different usages of MutableClock. Each specification method uses the MutableClock in a different way to test the WesternUnion class:

package mrhaki

import spock.lang.Specification
import spock.lang.Subject
import spock.util.time.MutableClock

import java.time.Duration
import java.time.Instant
import java.time.Period
import java.time.ZoneId
import java.time.ZonedDateTime

class WesternUnionSpec extends Specification {

    // Default time zone used for letter dates.
    private static final ZoneId TZ_HILL_VALLEY = ZoneId.of("America/Los_Angeles")
    
    // Constants used for letter to be send.
    private static final Instant DELIVERY_DATE = 
        ZonedDateTime
            .of(1955, 11, 12, 0, 0, 0, 0, TZ_HILL_VALLEY)
            .toInstant()
    private static final String MESSAGE = 
      'Dear Marty, ... -Your friend in time, "Doc" Emmett L. Brown'

    @Subject
    private WesternUnion postOffice

    void "deliver message after delivery date"() {
        given: "Set mutable clock instant to Sept. 1 1885"
        def clock = new MutableClock(
          ZonedDateTime.of(1885, 9, 1, 0, 0, 0, 0, TZ_HILL_VALLEY))

        and:
        postOffice = new WesternUnion(clock)

        expect:
        postOffice.acceptLetter(DELIVERY_DATE, MESSAGE) == 1

        when:
        int numberOfLetters = postOffice.deliver()

        then: "Delivery date has not passed so 1 letter"
        numberOfLetters  == 1

        when: "Move to delivery date of letter + 1 day and try to deliver"
        // We can change the clock's Instant property directly to 
        // change the date.
        clock.instant = 
            ZonedDateTime
                .of(1955, 11, 13, 0, 0, 0, 0, TZ_HILL_VALLEY)
                .toInstant()
        int newNumberOfLetters = postOffice.deliver()

        then: "Delivery date has passed now so 0 letters left"
        newNumberOfLetters == 0
    }
    
    void "deliver message after adjusting MutableClock using adjust"() {
        given: "Set mutable clock instant to Sept. 1 1885"
        def clock = new MutableClock(
            ZonedDateTime.of(1885, 9, 1, 0, 0, 0, 0, TZ_HILL_VALLEY))
        
        and:
        postOffice = new WesternUnion(clock)

        expect:
        postOffice.acceptLetter(DELIVERY_DATE, MESSAGE) == 1

        when:
        int numberOfLetters = postOffice.deliver()
        
        then: "Delivery date has not passed so 1 letter"
        numberOfLetters  == 1

        when: "Move clock forward 70 years, 2 months and 12 days and try to deliver"
        // To move the clock forward or backward by month or years we need
        // the adjust method. The plus/minus/next/previous methods are applied
        // to the Instant property of the MutableClock and with Instant
        // we can not use months or years.
        clock.adjust {t -> t + (Period.of(70, 2, 12)) }
        int newNumberOfLetters = postOffice.deliver()
        
        then: "Delivery date has passed now so 0 letters left"
        newNumberOfLetters == 0
    }

    void "deliver message after adding amount to MutableClock"() {
        given: "Set mutable clock instant to Oct. 26 1955"
        def clock = new MutableClock(
            ZonedDateTime.of(1955, 10, 26, 0, 0, 0, 0, TZ_HILL_VALLEY))

        and:
        postOffice = new WesternUnion(clock)

        expect:
        postOffice.acceptLetter(DELIVERY_DATE, MESSAGE) == 1

        when:
        int numberOfLetters = postOffice.deliver()

        then: "Delivery date has not passed so 1 letter"
        numberOfLetters  == 1

        and: "Move clock forward by given amount (18 days) and try to deliver"
        // The +/- operators are mapped to plus/minus methods. 
        // Amount cannot be months or years, then we need the adjust method.
        clock + Duration.ofDays(18)

        when: "Try to deliver now"
        int newNumberOfLetters = postOffice.deliver()

        then: "Delivery date has passed now so 0 letters left"
        newNumberOfLetters == 0
    }
    
    void "deliver message with fixed change amount"() {
        given: "Set mutable clock instant to Oct. 26 1955"
        def defaultTime = 
            ZonedDateTime.of(1955, 10, 26, 0, 0, 0, 0, TZ_HILL_VALLEY)
        // We can set the changeAmount property of MutableClock 
        // in the constructor or by setting the changeAmount property. 
        // Now when we invoke next/previous (++/--)
        // the clock moves by the specified amount.
        def clock = 
            new MutableClock(
                defaultTime.toInstant(), 
                defaultTime.zone, 
                Duration.ofDays(18))

        and:
        postOffice = new WesternUnion(clock)

        expect:
        postOffice.acceptLetter(DELIVERY_DATE, MESSAGE) == 1

        when:
        int numberOfLetters = postOffice.deliver()

        then: "Delivery date has not passed so 1 letter"
        numberOfLetters  == 1

        and: "Move clock forward by given amount (18 days) and try to deliver"
        // The ++/-- operators are mapped to next/previous. 
        // Amount cannot be months or years, then we need the adjust method.
        clock++

        when: "Try to deliver now"
        int newNumberOfLetters = postOffice.deliver()

        then: "Delivery date has passed now so 0 letters left"
        newNumberOfLetters == 0
    }
}

Written with Spock 2.0.