Jak mądrze testować CZAS ⏰ w Javie?

Data utworzenia, data modyfikacji, data wygenerowania. Nasze systemy często potrzebują wstawić takie informacje do obiektów, którymi zarządzają. Ale jak przetestować, że poprawna wartość została wpisana do takich pól? O tym, w poniższym wpisie.

Spójrzmy na fragment kodu.

class InvoiceService {

  Invoice generate(Order order) {
    Invoice invoice = // ...;
    invoice.generatedAt(LocalDateTime.now());
    return invoice;
  }
}

Klasa InvoiceService ma za zadanie wygenerować fakturę Invoice na podstawie zamówienia Order.

W trakcie tworzenia do klasy Invoice wstawiana jest data wygenerowania faktury invoice.generatedAt(...).

Wszystko wygląda poprawnie. Czas więc by napisać test sprawdzający wpisanie prawidłowej daty.

class InvoiceServiceTest {

  InvoiceService invoiceService = new InvoiceService()

  def "contains date of generation"() {
    given:
      Order order = givenOrder();

    when:
      Invoice invoice = invoiceService.generate(order);

    then: 
      invoice.generatedAt == LocalDateTime.now()
  }
}

W sekcji then porównujemy dwie wartości.

invoice.generatedAt == LocalDateTime.now()

Datę wstawioną do faktury Invoice z aktualną datą LocalDateTime.now(). Na pierwszy rzut wszystko powinno być ok.

Wystarczy jednak, że uruchomisz ten kod na swoim komputerze i zobaczysz, że daty się nie zgadzają. Różnica będzie rzędu milisekund, czy nanosekund.

Nie sposób by data była dokładnie ta sama w momencie generowania faktury invoiceService.generate(order) jak i w chwili weryfikowania testu.

Co więc robi w takiej sytuacji doświadczony programista?

Wprowadza abstrakcję!

Wprowadź Clock i nie martw się czasem 🙂

Wystarczy, że do naszego kodu dodamy nowy interfejs Clock z jedną metodą time().

interface Clock {
  LocalDateTime time();
}

Spójrzmy teraz na poniższy fragment kodu zmienionej klasy InvoiceService.

class InvoiceService {

  Clock clock;

  Invoice generate(Order order) {
    Invoice invoice = // ...;
    invoice.generatedAt(clock.time());
    return invoice;
  }
}

W klasie pojawiło się pole typu Clock, które jest używane w momencie wpisywania daty przez metodę clock.time().

invoice.generatedAt(clock.time())

Po co to wszystko? A no to po, żeby w trakcie testów mieć pewność, że możemy bezpiecznie sprawdzić wstawioną wartość. Spójrzmy poniżej na zmodyfikowany test.

class InvoiceServiceTest {

  Clock clock = new FakeClock()
  InvoiceService invoiceService = new InvoiceService(clock);

  def "contains date of generation"() {
    given:
      Order order = givenOrder();

    when:
      Invoice invoice = invoiceService.generate(order);

    then: 
      invoice.generatedAt == clock.time()
  }
}

Do testu została wprowadzona „fałszywa” implementacja interfejsu Clock o nazwie FakeClock. Jest przekazywana do InvoiceService w trakcie konstrukcji, a później używana w sekcji then przy weryfikacji testu.

invoice.generatedAt == clock.time()

I jaki tu zysk? A no taki, że „fałszywa” implementacja zawsze zwraca tę samą wartość! Spójrzmy na jej przykładowy sposób zakodowania.

class FakeClock implements Clock {
  private final LocalDateTime time;

  FakeClock() {
    this.time = LocalDateTime.now();
  }

  LocalDateTime time() {
    return time;
  }
}

Zauważ, że czas używany przez FakeClock jest ustawiany tylko raz w momencie konstrukcji.

class FakeClock() {
  this.time = LocalDateTime.now();
}

Później, każdorazowe zawołanie metody time() zwraca zawsze jedną i tę samą instancję zmiennej time, a więc zawsze jedną i tę samą wartość.

Dzięki temu zarówno w momencie generowania faktury Invoice w trakcie testu jak i w chwili weryfikacji testu pobranie czasu clock.time() zwróci te same wartości i test będzie działać poprawnie.

W ten sposób zweryfikujemy, że data została poprawnie wstawiona do klasy Invoice. a test nie będzie się „wywalać” z powodu przesunięcia czasu o kilka milisekund.

A co z klasą Clock dla kodu produkcyjnego?

Tutaj kwestia jest prosta.

Wystarczy przygotować osobną implementację i dostarczyć ją do systemu (np. poprzez zadeklarowanie beana jeśli korzystamy ze Springa).

class SystemClock implements Clock {

  LocalDateTime now() {
    return LocalDateTime.now();
  }
}

Podsumowanie

Teraz już wiesz jak mądrze podejść do testowania atrybutów opartych o czas. Pamiętaj, że za każdym razem kiedy w systemie wołasz ręcznie new Date() czy LocalDateTime.now() warto zastanowić się, czy w tym miejscu nie powinieneś korzystać z abstrakcji Clock.

Author: Dariusz Mydlarz

Cześć, nazywam się Dariusz Mydlarz i od 2012 pracuję jako programista. Tworzę w ekosystemie Javy i uwielbiam systemy backendowe. Chcę w tym miejscu pomagać Ci stawać się lepszym programista. Jeśli mogę Ci jakoś pomoc, po prostu napisz do mnie.

8 Replies to “Jak mądrze testować CZAS ⏰ w Javie?

  1. W sumie to możemy to generowanie daty „zlecić” innej klasie, a potem ręcznie ją zaślepić w testach. Ale to rozwiązanie z Clock’iem jest dużo prostsze i bardziej czytelne. Dzięki, Darek!

    1. Tak, możemy datę przekazać z zewnątrz do tej metody (np. już jako LocalDateTime), ale znowu w tej klasie wyższej musimy skądś ten czas wziąć 🙂 I wtedy też warto mieć ze sobą „Clocka”. Pozdrawiam! 🙂

    1. Hej dzięki za odpowiedź do mojego wpisu 🙂 Oczywiście, nie ma problemu w używaniu wbudowanego typu „Clock” z JDK. To czy użyjesz z JDK czy własny to już kwestia gustu / preferencji. Przy swoim typie, zawsze możesz użyć przyjemniejszych, bardziej dopasowanych do siebie i zespołu konstruktorów, metod, itd.
      Jedyna rzecz, którą wolałbym preferować to wstrzykiwanie takich obiektów do innych klas, albo ich wartości, zamiast wstawiania statycznych pól, tak jak u Ciebie.

      Pozdro!

  2. Hej. Ciekawe podejście, jednak według mnie, nieco przekombinowane. Nieraz miałem do czynienia z podobnym zagadnieniem i również wystawiałem do tego wyspecjalizowaną klasę zwracającą dane czasowe, np. class TimestampService { long getTimestamp() { return System.currentTimeMillis(); } }.
    Jest to rozsądne i bardzo „testowalne” podejście, ale jeśli chodzi o samo przygotowanie testu – przychylałbym się tutaj do prostego zamockowania odpowiedzi, w stylu given: long now = 1L; when(service.getTimestamp()).thenReturn(now);.

    1. Siema 🙂 Jasne, tak też można. Ja po prostu preferuję interfejsy + fake-owe implementację nad ręczne dłubanie mockowania z when…thenReturn…
      Plus chociażby jest taki, że fakeową klasę piszesz raz i łatwo jej reużyć w wielu testach. Mockowanie musisz robić w każdej klasie testowej, a przy zmianach interfejsu/metod, testy będą też do poprawy.
      Pozdrawiam!

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *