Zbavte se jednou provždy testů závislých na čase spuštění!

Téměř v každém softwarovém projektu běžně pracujeme s časovými údaji. Může jít např. o výpočet data, kdy je k současnému datu přičten interval ve dnech. Možná si říkáte, že se na takovém výpočtu nedá nic zkazit. V článku si ale povíme, jaké nepříjemnosti nás při práci s časem mohou potkat a jak je lze efektivně vyřešit.

Při prvotním vývoji logiky, kdy je jednoduše přičten čas, možná chybu neuděláte, ale co až budete chtít refaktorovat stovky řádků kódu dopočítávajícího časové údaje a Vy jej uvidíte poprvé v životě?

Už víte, kam tím mířím?

 

Správně – k unit testům!

 

Kód, který pracuje s časovými údaji, je naprosto nezbytné dobře a spolehlivě otestovat, aby byly budoucí změny bezpečně proveditelné ať už z důvodu refaktoringu nebo úpravy samotné business logiky.

Následující kód představuje část servisní třídy, která vytváří a ukládá fakturu na základě zadaných parametrů.


/* importy a package vynechány */
@Service
public class InvoiceServiceImpl implements InvoiceService {

  @Autowired
  private InvoiceRepository invoiceRepository;

  @Override
  public void generateInvoice(final Customer customer, final int dueIntervalInDays, final BigDecimal amount) {
    final LocalDate now = LocalDate.now();
    final LocalDate dueDate = now.plusDays(dueIntervalInDays);

    final BigDecimal recountedAmount = this.performComputationThatTakes10Seconds(amount);

    final Invoice invoice = Invoice.InvoiceBuilder.anInvoice()
        .customer(customer)
        .dueDate(dueDate)
        .amount(recountedAmount)
        .build();

    invoiceRepository.save(invoice);
  }
  
  /* metoda performComputationThatTakes10Seconds() vynechána */
}

Další ukázka představuje část unit testů k této logice.


/* importy a package vynechány */
@RunWith(MockitoJUnitRunner.class)
public class InvoiceServiceImplUnitTest {

  private static final int DUE_INTERVAL_IN_DAYS = 30;
  private static final BigDecimal AMOUNT = BigDecimal.TEN;
  private static final String CUSTOMER_NAME = "ABCD a.s.";

  @InjectMocks
  private InvoiceServiceImpl invoiceService;

  @Mock
  private InvoiceRepository invoiceRepositoryMock;

  @Captor
  private ArgumentCaptor<Invoice> invoiceCaptor;

  private Customer customer;

  @Before
  public void prepareData() {
    customer = Customer.CustomerBuilder.aCustomer()
        .name(CUSTOMER_NAME)
        .build();
  }

  @Test
  public void invoiceShouldBeSavedProperly() {
    // mock behaviour
    doReturn(null).when(invoiceRepositoryMock).save(invoiceCaptor.capture());

    // tested method
    invoiceService.generateInvoice(customer, DUE_INTERVAL_IN_DAYS, AMOUNT);

    // verification
    final Invoice savedInvoice = invoiceCaptor.getValue();
    assertThat(savedInvoice, notNullValue());
    assertThat(savedInvoice.getDueDate(), is(LocalDate.now().plusDays(DUE_INTERVAL_IN_DAYS)));
    assertThat(savedInvoice.getAmount(), is(AMOUNT));
    assertThat(savedInvoice.getCustomer(), is(customer));
  }

  /* ostatní testovací metody vynechány */
}

Zdá se Vám na těchto ukázkách vše v pořádku? Zkuste si projekt otevřít a testy si spustit. S největší pravděpodobností úspěšně projdou.

Proč jen “s největší pravděpodobností”?

 

Protože pokud je spustíte pár vteřin před půlnocí, spadnou!

 

V testovaném kódu je nejprve získáno aktuální datum a pak následuje volání výpočtu, který trvá cca. 10 vteřin. V unit testu volání testované metody trvá také kolem 10 vteřin a během této doby může padnout půlnoc. Data, která jsou v unit testu porovnávána, se tudíž budou lišit!

 

Připadá Vám pravděpodobnost, že k něčemu podobnému bude docházet, zanedbatelně malá?

Představte si, že nemáte v projektu jen 1 takový test, ale máte jich 500.

Představte si, že každý z nich je náchylný na jiný souběh okolností.

Představte si, že neporovnáváte časové údaje na úrovni dní, ale na úrovni milisekund.

Hádám, že teď Vám přijde zanedbatelně nízká naopak pravděpodobnost, že všechny testy úspěšně projdou. 🙂

 

Jak z toho ven?

Lékem na tento neduh je zavést v projektu komponentu, která bude aktuální časy, data nebo jiné časové údaje poskytovat a od níž se budeme moci při unit testech odstínit. Tuto třídu pak použijeme všude, kde potřebujeme v rámci business logiky aktuální čas či datum.

Rozhraní pojmenujeme např. TimeService a implementaci TimeServiceImpl. Její kód vidíme v následujícím výpise.


@Service
public class TimeServiceImpl implements TimeService {

  @Override
  public LocalDateTime getCurrentDateTime() {
    return LocalDateTime.now();
  }

  @Override
  public LocalDate getCurrentDate() {
    return LocalDate.now();
  }

  @Override
  public LocalTime getCurrentTime() {
    return LocalTime.now();
  }
}

Jedná se asi o nejjednodušší možnou implementaci, jakou si lze představit. Je pochopitelné, že ve svém projektu budete možná potřebovat i jiné typy časových údajů (datum/čas i s časovou zónou, aktuální den v týdnu apod.). Implementaci si ale jistě snadno dopíšete sami.

Upravená část třídy InvoiceServiceImpl, která bude TimeService využívat, vypadá nyní takto:


/* importy a package vynechány */
@Service
public class InvoiceServiceImpl implements InvoiceService {

  /* vynechán Logger a InvoiceRepository */

  @Autowired
  private TimeService timeService;

  @Override
  public void generateInvoice(final Customer customer, final int dueIntervalInDays, final BigDecimal amount) {
    final LocalDate now = timeService.getCurrentDate();
    final LocalDate dueDate = now.plusDays(dueIntervalInDays);

    final BigDecimal recountedAmount = this.performComputationThatTakes10Seconds(amount);

    final Invoice invoice = Invoice.InvoiceBuilder.anInvoice()
        .customer(customer)
        .dueDate(dueDate)
        .amount(recountedAmount)
        .build();

    invoiceRepository.save(invoice);
  }

  /* metoda performComputationThatTakes10Seconds() vynechána */
}

Upravený unit test je uveden v následujícím výpise:


/* importy a package vynechány */
@RunWith(MockitoJUnitRunner.class)
public class InvoiceServiceImplUnitTest {

  /* testovací data vynechána, od minulé verze se neliší */

  @InjectMocks
  private InvoiceServiceImpl invoiceService;

  @Mock
  private InvoiceRepository invoiceRepositoryMock;

  @Mock
  private TimeService timeServiceMock;

  @Captor
  private ArgumentCaptor<Invoice> invoiceCaptor;

  /* Customer a metoda prepareData() vynechány */

  @Test
  public void invoiceShouldBeSavedProperly() {
    // mock behaviour
    doReturn(LocalDate.of(2016, 2, 1)).when(timeServiceMock).getCurrentDate();
    doReturn(null).when(invoiceRepositoryMock).save(invoiceCaptor.capture());

    // tested method
    invoiceService.generateInvoice(customer, DUE_INTERVAL_IN_DAYS, AMOUNT);

    // verification
    final Invoice savedInvoice = invoiceCaptor.getValue();
    assertThat(savedInvoice, notNullValue());
    assertThat(savedInvoice.getDueDate(), is(LocalDate.of(2016, 3, 2)));
    assertThat(savedInvoice.getAmount(), is(AMOUNT));
    assertThat(savedInvoice.getCustomer(), is(customer));
  }

  /* ostatní testovací metody vynechány */
}

Z nové podoby testu by mělo být patrné, že s datem se nyní dá pracovat mnohem přesněji a spolehlivost takového testu je daleko větší.

Shrnutí

Business logika by měla být vždy dobře otestována. Pokud v ní odvozujeme časové údaje např. od aktuálního data, je nutné získávat toto datum z komponenty, od které je možné se při testech odstínit např. pomocí mockování.

Ač nás může od vytvoření komponenty odrazovat její triviálnost, z pohledu testovatelnosti se jedná o nutnost. Mít v projektu množství testů, které jsou nespolehlivé a oprávněně projdou jen souhrou šťastných okolností, je cesta do pekel. Mohou totiž značně zkomplikovat, ne-li úplně znemožnit provádění procesu ověřování kvality vyvinutého softwaru.

 

Zdrojový kód

Projekt si můžete naklonovat z GIT repozitáře https://github.com/vitherain/invoice-app-tutorial

Tag “without-time-service” je verze kódu bez použití TimeService, kód v tagu “with-time-service” ji používá.

 

 

Autor: Vít Herain