Quand le temps est un besoin fonctionnel

Dans le cadre du développement logiciel, il arrive que le temps soit un élément fonctionnel exprimé par le client. Nos exigences en termes de qualité d’un logiciel nous inculquent de valider ses comportements, au travers de tests. En tant que développeur, la question se pose : comment tester quelque chose que je ne maîtrise pas ?

En Java, l'accès au temps m'est essentiellement donné au travers de méthodes statiques. Dois-je sortir l’armada à coup de Mockito ou de PowerMock pour modifier le comportement réel de ces méthodes ? Dois-je, au contraire, créer une interface custom pour exprimer le temps à ma convenance ? Mieux encore, existerait-il une interface native à Java que personne ne semble connaître et qui pourtant répond simplement à la problématique ?

Clock : quand Java se fait horloge

Introduit dans Java 8, java.util.Clock est une classe abstraite qui permet de représenter le temps présent, relatif à une zone géographique. Elle est notamment déclinée en plusieurs implémentations :

  • SystemClock : l’implémentation de base, qui retournera toujours l’instant présent
  • TickClock : horloge dont la précision est ajustable, il est par exemple possible de ne regarder que la minute si les secondes, les millisecondes ou les nanosecondes ne sont pas utiles dans le cas d’usage
  • FixedClock : retourne toujours le même instant
  • OffsetClock : ajoute un décalage à une horloge sous-jacente

L’objet Clock est interfaçable avec les autres objets qui représentent le temps :

  • Instant.now(clock)
  • LocalDate.now(clock)

Notons par ailleurs que la méthode now() utilise de manière sous-jacente la Clock par défaut du système.

Exemple d’une location de voiture

Voyons maintenant comment l’utiliser dans notre code. Pour l’exemple, je prendrais une application de location de voitures. Nous souhaitons implémenter une règle somme toute assez simple, à savoir que la location d’un véhicule dure 7 jours. On peut imaginer que des notifications soient envoyées au locataire, que des pénalités de retard lui soient appliquées, … Ici, on va se concentrer uniquement sur cette durée de 7 jours.

On commence par le service applicatif qui va recevoir les demandes de location. L’objet Clock est injecté au service par le constructeur. Notons que cette injection sera certainement gérée par un framework comme Spring, ce qui nécessitera de définir un bean dans une configuration à part.

final class RentCarAPI {
  private final CarRepository carRepository;
  private final EventPublisher eventPublisher;
  private final Clock clock;

  // constructor

  void execute(RentCar command) {
    Car desiredCar = carRepository.get(command.carId());
    CarRented carRented = desiredCar.rent(command.desiredBy(), clock);
    carRepository.save(car);
    eventPublisher.publish(carRented);
  }
}

Le service applicatif récupère l'agrégat, ici Car, et lui délègue la location. L'agrégat a besoin de deux éléments : le client qui souhaite louer la voiture, et l’horloge sur laquelle il peut se baser pour prendre sa ou ses décisions métiers. Dans l’exemple, j’utilise un objet de classe LocalDate car le besoin exprimé n’est pas soucieux des heures.

final class Car {
  CarRented rent(CustomerId customerId, Clock clock) {
    // … apply some rules …
    LocalDate rentedAt = LocalDate.now(clock);
    LocalDate toReturnAt = rentedAt.plusDays(7);
    return new CarRented(carId, customerId, rentedAt, toReturnAt);
  }
}

Maîtriser le temps dans les tests

class RentCarSpec {
  final ZoneId zone = ZoneId.of("UTC");

  @Test
  void car_must_be_returned_after_7_days() {
    // given
    CarRepository carRepository = … // some implementation;
    EventPublisher eventPublisher = mock(EventPublisher.class);
    Instant fixedInstant = LocalDate.of(2023, 7, 1).atStartOfDay(zone).toInstant();
    Clock fixedClock = Clock.fixed(fixedInstant, zone);
    RentCarAPI api = new RentCarAPI(carRepository, eventPublisher, fixedClock);
    
    // when
    api.execute(new RentCar(CAR_ID, CUSTOMER_ID));

    // then
    CarRented expectedEvent = new CarRented(
      CAR_ID,
      CUSTOMER_ID,
      LocalDate.of(2023, 7, 1),
      LocalDate.of(2023, 7, 8));
    verify(eventPublisher).publish(expectedEvent);
  }
}

Il est bien évidemment possible de déléguer l’instanciation de nos objets à Spring, au travers d’un SpringBootTest.

@Bean
Clock fixedClock() {
  ZoneId zone = ZoneId.of("UTC");
  Instant fixedInstant = LocalDate.of(2023, 7, 1).atStartOfDay(zone).toInstant();
  return Clock.fixed(fixedInstant, zone);
}

Cette technique est particulièrement intéressante quand on relie Spring à des tests d’acceptance.

Maîtriser le temps dans les scénarios

Quand on applique des méthodes de travail tel que le Behaviour-Driven Development (BDD), le besoin est exprimé au travers d'exemples, la plupart du temps formalisés au travers de scénarios Gherkin, pour en faire des tests d’acceptance automatisables. Voyons ce que cela donnerait d’exprimer une contrainte de temps sous cette forme.

Rule: Car must be returned after 7 days

Scenario Outline: date of return
  Given today is <today>
  When customer rent a car
  Then the car is rented from <today> to <return_at>
  
  Exemples:
    | today      | return_at  |
    | 2023/07/01 | 2023/07/08 |
    | 2023/11/28 | 2023/12/05 |

Sans contexte Spring

@Given("today is {date}")
void today_is(LocalDate today) {
   Instant fixedInstant = today.atStartOfDay(ZONE_ID).toInstant();
   this.clock = Clock.fixed(fixedInstant, ZONE_ID);
}

@When("customer rent a car")
void customer_rent_a_car() {
   new RentCarAPI(…).execute(…);
}

Avec contexte Spring

Peut-être un peu plus complexe à mettre en place, il est possible de combiner Cucumber avec le contexte Spring. D’autres articles en parlent déjà très bien. Je me concentrerai donc sur les objets à mettre en place, une fois la configuration Cucumber / Spring prête.

Nous allons ainsi créer un ScenarioState qui gardera l’état de notre scénario en cours et qui sera recréé après chaque scénario :

class ScenarioState {
  LocalDate currentDate = LocalDate.now();
  // getter + setter
}
class RentCarSpec {

  // Load from spring context
  @Autowired
  ScenarioState scenarioState;
  @Autowired
  RentCarAPI api;
  // … other dependencies such as the repository and the event publisher …

  @Given("today is {date}")
  void today_is(LocalDate today) {
     scenarioState.setCurrentDate(today);
  }

  @When("customer rent a car")
  void customer_rent_a_car() {
    api.execute(…);
  }

}
@Configuration
final class RentTestModule {

  @Bean
  @ScenarioScoped
  ScenarioState scenarioState() {
    return new ScenarioState();
  }

  @Bean
  Clock scenarioClock(ScenarioState scenarioState) {
    return new ScenarioClock(scenarioState, ZONE_ID);
  }

  // … other beans like CarRepository …
}

Enfin, notre implémentation de Clock ressemblera à ceci :

final class ScenarioClock extends Clock {
  private final ScenarioState scenarioState;
  private final ZoneId zone;
  
  // constructor

  @Override
  public ZoneId getZone() {
    return zone;
  }

  @Override
  public Clock withZone(ZoneId zone) {
    return new ScenarioClock(scenarioState, zone);
  }

  @Override
  public Instant instant() {
    return scenarioState.getCurrentDate()
            .atStartOfDay(getZone())
            .toInstant();
  }
}

En définitive, le temps n’est peut-être pas si difficile à maîtriser. L’interface Clock a été désignée en ce sens. Au lieu de s’en abstraire, nous avons ici une solution fiable, lisible et native pour tester le temps. Dès lors qu’une règle aura besoin de vérifier le temps, nous pourrons nous appuyer sur cette horloge. J’y vois d’autres avantages comme la possibilité de créer un service de temps entièrement testable et qui vaudrait bien un article à lui tout seul.

Et vous, comment gérez-vous le temps dans vos applications ?