Custom Hamcrest closeTo matcher

A practical hamcrest matcher closeTo to know if a date is close to another (of the same type). Supports java.util.Date, java.time.LocalDateTime, java.time.ZonedDateTime, java.time.OffsetDataTime. No dependencies other than hamcrest.

Usage

// default tolerance is 2 minutes
assertThat(someOffsetDateTime, is(closeTo(OffsetDateTime.now())));

// you can choose tolerance
assertThat(someOffsetDateTime, is(closeTo(OffsetDateTime.now(),1)));

Code of the matcher

import org.hamcrest.BaseMatcher;
import org.hamcrest.Description;
import org.hamcrest.Factory;
import org.hamcrest.Matcher;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Date;

/**
 * Matcher hamcrest permettant de vérifier qu'une date est proche d'une autre.
 * Le seuil de tolérance peut être précisé (en minutes)
 *
 * @param <T> Types supportés {@link LocalDateTime}, {@link OffsetDateTime}
 */
public class IsCloseTo<T> extends BaseMatcher<T> {
    private final Object expectedValue;
    /**
     * Seuil de tolérance en minutes.
     */
    private final int toleranceInMin;

    private IsCloseTo(Object expectedValue) {
        this.expectedValue = expectedValue;
        toleranceInMin = 2;
    }

    private IsCloseTo(T equalArg, int toleranceInMin) {
        expectedValue = equalArg;
        this.toleranceInMin = toleranceInMin;
    }

    private static boolean areClose(Object actual, Object expected, int toleranceInMin) {
        if (actual instanceof LocalDateTime && expected instanceof LocalDateTime) {
            final LocalDateTime expectedTime = (LocalDateTime) expected;
            final LocalDateTime actualTime = (LocalDateTime) actual;
            final LocalDateTime justBefore = expectedTime.minusMinutes(toleranceInMin);
            final LocalDateTime justAfter = expectedTime.plusMinutes(toleranceInMin);

            return justBefore.isBefore(actualTime) && justAfter.isAfter(actualTime);
        }
        if (actual instanceof OffsetDateTime && expected instanceof OffsetDateTime) {
            final OffsetDateTime expectedTime = (OffsetDateTime) expected;
            final OffsetDateTime actualTime = (OffsetDateTime) actual;
            final OffsetDateTime justBefore = expectedTime.minusMinutes(toleranceInMin);
            final OffsetDateTime justAfter = expectedTime.plusMinutes(toleranceInMin);

            return justBefore.isBefore(actualTime) && justAfter.isAfter(actualTime);
        }
        if (actual instanceof ZonedDateTime && expected instanceof ZonedDateTime) {
            final ZonedDateTime expectedTime = (ZonedDateTime) expected;
            final ZonedDateTime actualTime = (ZonedDateTime) actual;
            final ZonedDateTime justBefore = expectedTime.minusMinutes(toleranceInMin);
            final ZonedDateTime justAfter = expectedTime.plusMinutes(toleranceInMin);

            return justBefore.isBefore(actualTime) && justAfter.isAfter(actualTime);
        }
        if (actual instanceof Date && expected instanceof Date) {
            final Date expectedTime = (Date) expected;
            final Date actualTime = (Date) actual;

            final int amount = -toleranceInMin;
            final Date justBefore = add(expectedTime, Calendar.MINUTE, amount);
            final Date justAfter = add(expectedTime, Calendar.MINUTE, toleranceInMin);

            return justBefore.compareTo(actualTime) < 0 && justAfter.compareTo(actualTime) > 0;
        }
        throw new IllegalArgumentException("Unsupported type(s) combination : actual : " + actual.getClass().getTypeName() + ", expected : " + expected.getClass().getTypeName());
    }

    private static Date add(final Date date, final int calendarField, final int amount) {
        validateDateNotNull(date);
        final Calendar c = Calendar.getInstance();
        c.setTime(date);
        c.add(calendarField, amount);
        return c.getTime();
    }

    private static void validateDateNotNull(final Date date) {
        isTrue(date != null, "The date must not be null");
    }

    public static void isTrue(final boolean expression, final String message, final Object... values) {
        if (!expression) {
            throw new IllegalArgumentException(String.format(message, values));
        }
    }

    @Factory
    public static <T> Matcher<T> closeTo(T operand) {
        return new IsCloseTo<>(operand);
    }

    @Factory
    public static <T> Matcher<T> closeTo(T operand, int toleranceInMin) {
        return new IsCloseTo<>(operand, toleranceInMin);
    }


    @Override
    public boolean matches(Object actualValue) {
        return areClose(actualValue, expectedValue, toleranceInMin);
    }

    @Override
    public void describeTo(Description description) {
        description.appendValue(expectedValue);
    }

}