• Krzysztof Borkowski

Zasada Najmniejszych Różnic - część II

To jest druga część z serii artykułów o Zasadzie Najmniejszych Różnic. Pokażę tutaj kolejny przykład, jaskrawo obrazujący, jak nieprzestrzeganie tej zasady prowadzi do pisania testów, które niczego nie testuję. W tym artykule kontynuuję temat zakładając, że zapoznałeś się z poprzednią częścią tej serii. Jeżeli tego nie uczyniłeś, to proszę zrób to, zanim przejdziesz do kolejnego akapitu na tej stronie. Pierwszą część tej serii znajdziesz tutaj.


Natchniony Piotrek pisze dalej...


Jak pamiętacie, Piotrek testuje metodę findBy i robi to... pod natchnieniem, niemal kompletnie ignorując inżynieryjną stronę wytwarzania oprogramowania. Już wiemy, że w przypadku testów, czy metoda findBy prawidłowo weryfikuje argumenty wejściowe, zakończyło się to naprawdę miernym kodem. Zobaczmy, jak pójdzie Piotrkowi napisanie testów właściwej funkcjonalności tej metody. Zróbmy sobie jednak krótkie powtórzenie.


Piotrek wyrzeźbił kod, który opisać można takim oto diagramem klasa (znanym Wam już z pierwszego artykułu tej serii):

Zadaniem Piotrka jest przetestowanie metody findBy z klasy DocumentsWebService. Funkcjonalność realizowaną przez tę metodę można opisać następująco:

  1. Po pierwsze, na starcie ma zweryfikować poprawność argumentów wejściowych (tę funkcjonalność Piotrek już przetestował).

  2. Po drugie musi zwrócić obiekty klasy Document, których pole registrationDate jest większe lub równe registrationDateFrom i mniejsze lub równe registrationDateTo, a pole type jest równe docType. W przypadku, jeżeli w argumencie registrationDateFrom został przekazany null, pole registrationDate jest porównywane tylko z argumentem registrationDateTo - biznesowo oznacza to, że metoda ma wówczas zwrócić wszystkie dokumenty zarejestrowane od początku świata, aż do dnia registrationDateTo włącznie.

No więc Piotrek pisze dalej... a raczej czeka na kolejny powiew natchnienia. Kiedy wreszcie przychodzi, podpowiada Mu, że należy stworzyć dane testowe. Piotrek, niewiele myśląc, pisze taki oto kod:

@DisplayName("Weryfikacja poprawności zwracanych wyników")
@Test
public void findBy_02() {
  // czerwiec, same oferty
  final Document d01 = new Document(
    Type.OFFER, LocalDate.of(2020, 6, 1));
  final Document d02 = new Document(
    Type.OFFER, LocalDate.of(2020, 6, 2));
  final Document d03 = new Document(
    Type.OFFER, LocalDate.of(2020, 6, 3));
  final Document d04 = new Document(
    Type.OFFER, LocalDate.of(2020, 6, 4));
  final Document d05 = new Document(
    Type.OFFER, LocalDate.of(2020, 6, 5));

  // lipiec, same potwierdzenia przyjęcia zamówienia
  final Document d06 = new Document(
    Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 7, 1));
  final Document d07 = new Document(
    Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 7, 2));
  final Document d08 = new Document(
    Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 7, 3));
  final Document d09 = new Document(
    Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 7, 4));
  final Document d10 = new Document(
    Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 7, 5));

  Stream.of(d01, d02, d03, d04, d05, d06, d07, d08, d09, d10)
    .forEach(documentRepository::save);

  // to be continued...

Jest z niego bardzo dumny. Myśli sobie: pięknie to wygląda! Wszystko jest ładne, czyste, czytelne i pięknie podzielone: w czerwca mam same oferty, a w lipcu same potwierdzenia przyjęcia zamówienia! No i jeszcze ten strumień z referencją do metody - w dwóch linijkach kodu zapisuję do bazy dziesięć dokumentów - jestem bogiem!


Szkoda, że nie wie, że właśnie złamał zasadę najmniejszych różnic i wykonał pierwszy krok ku napisaniu testów, które niczego nie testują.


Na fali tych, jakże przyjemnych emocji, Piotrek pisze dwa testy, które całkowicie pogrążają Go jako developera.

// test pierwszy
final Set<Document> docs_01 = documentsWebService.findBy(
    LocalDate.of(2020, 6, 2), LocalDate.of(2020, 6, 4),
        Type.OFFER);
assertEquals(
    Stream.of(d02, d03, d04).collect(Collectors.toSet()),
    docs_01);

// test drugi
final Set<Document> docs_02 = documentsWebService.findBy(
    LocalDate.of(2020, 7, 1), LocalDate.of(2020, 7, 3),
        Type.ORDER_ACKNOWLEDGE);
assertEquals(
    Stream.of(d06, d07, d08).collect(Collectors.toSet()),
    docs_02);
} // koniec testów metody findBy

Uff - myśli sobie - dobra robota, skończone!


Niestety jednak, w tych dwóch testach, Piotrek również nie zastosował się do zasady najmniejszych różnic i przez to, jego testy są nic niewarte. Dlaczego?


Wyobraźmy sobie, że w wyniku wykonania metody DocumentsWebService.findBy zostanie wywołana metoda DocumentRepository.findBy, która wygeneruje takie oto zapytanie JPQL:

from
  Document d

where
  d.registrationDate >= :registrationDateFrom
  and d.registrationDate <= :registrationDateTo
  and d.type = :docType

A teraz wyobraźmy sobie, że po jakimś czasie, ktoś zrefactoruje metodę DocumentRepository.findBy w efekcie czego generować ona będzie takie oto zapytanie JPQL:

from
  Document d

where
  d.registrationDate >= :registrationDateFrom
  and d.registrationDate <= :registrationDateTo

Następnie ten ktoś uruchomi testy, żeby upewnić się, że nie wprowadził błędu regresji i.... wszystkie przejdą bez błędu - a nie powinny. Testy Piotrka są ślepe na wprowadzonego buga, gdyż Piotrek zmienił wartości więcej niż jednego argumentu naraz.


Przeanalizujmy to.


Test pierwszy Piotrka wyszukuje wszystkie dokumenty zarejestrowane od 2 do 4 czerwca 2020 roku, które jednocześnie są ofertami. Niestety jednak Piotrek zadbał o to, żeby w czerwcu były same tylko oferty. Jeżeli więc z zapytania JPQL usuniemy warunek:

d.type = :docType

to dla zadanego zakresu (od 2 do 4 czerwca 2020) zapytanie to wciąż zwróci te same wyniki!


Test drugi, zaś, wyszukuje dokumenty zarejestrowane od 1 do 3 lipca 2020 roku, które jednocześnie są potwierdzeniami przyjęcia zamówienie. Ale w tym zakresie nie ma żadnej oferty, są tam tylko potwierdzenia przyjęcia zamówienia (!) więc usunięcie warunku:

d.type = :docType

również nie wpłynie dla tego zakresu na zwracany wynik.


Piotrek miał dwa razy szansę wywinąć się od tego błędu. Mógł zastosować zasadę najmniejszych różnic przygotowując dane lub pisząc kod testujący, ale w obu tych przypadkach zasady najmniejszych różnic nie zastosował.


Pamiętacie developera, o którym wspominałem w poprzedniej części tej serii? Dzisiaj również zignorował zasadę najmniejszych różnic. To trochę zajmie, zanim bezwzględne stosowanie się do tej zasady wejdzie Mu w krew. Ale kiedy już wejdzie, będzie pisał testy znacznie szybciej niż teraz (nie będzie musiał bowiem czekać na natchnienie... ani po raz kolejny zgłaszać swój merge request po mój approval) a do tego będą to takie testy, że nic się przez nie prześlizgnie. Na teraz, jednak, musi znosić moje cierpkie komentarze. Dla przykładu dzisiaj poprosiłem Go, żeby w kodzie metody testowanej usunął kawałek kodu i uruchomił metodę testującą. W merge requeście napisałem tak: "I zobacz, czy Twój test przechodzi, jeżeli przechodzi to znaczy, że jest g........uzik warty". Cóż mi z testu, który nie daje mi 100% pewności, że kiedy zmodyfikuję kod produkcyjny i odpalę taki test, dla weryfikacji, czy nie wprowadziłem błędu regresji, ten test da wynik błędów brak - a nie będzie to prawda? Do niczego mi taki test!


No dobra, ale jak można było to zrobić lepiej?


Zasada Najmniejszych Różnic


A więc wyobraźmy sobie, że to my piszemy test metody findBy. Moglibyśmy zacząć go od napisania czegoś takiego:

@DisplayName("Weryfikacja poprawności zwracanych wyników")
@Test
public void findBy_02() {
  final Document d01 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 11));

  // to be continued...

Ten pierwszy stworzony dokument (d01) to jest wzorcowana dana testowa. Teraz zaczniemy dodawać kolejne dane testowe stosując dobrze nam już znane zasady:

  1. zasadę najmniejszych różnic

  2. oraz zasadę od lewej do prawej

Tak więc wstawiamy kolejny dokument, który rożni się od wzorcowego tylko jednym atrybutem. Zgodnie z zasadą od lewej do prawej, modyfikujemy pierwszy argument od lewej w konstruktorze klasy. Kod, po dodaniu nowej danej testowej, wygląda tak (kolorem pomarańczowym wyróżniłem różnice wprowadzone względem danej wzorcowej):

@DisplayName("Weryfikacja poprawności zwracanych wyników")
@Test
public void findBy_02() {
  final Document d01 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 11));
  final Document d02 = new Document(
    Document.Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 6, 11));

  // to be continued...

A teraz, zgodnie z zasadą od lewej do prawej, przechodzimy do kolejnego argumentu i zmieniamy go - zmianę, rzecz jasna, nanosimy na skopiowanej danej wzorcowej:

@DisplayName("Weryfikacja poprawności zwracanych wyników")
@Test
public void findBy_02() {
  final Document d01 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 11));
  final Document d02 = new Document(
    Document.Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 6, 11));
  final Document d03 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 12));

  // to be continued...

I to tyle! Więcej danych testowych nie potrzebujemy. Piotrek dodał aż dziesięć dokumentów, a my, podchodząc do zagadnienia po inżyniersku, stworzyliśmy tylko trzy - i to nam w zupełności wystarczy, żeby napisać unbreakable test.


Musimy jeszcze, rzecz jasna, zapisać te dokumenty do bazy danych. Nie jesteśmy tak "genialni" jak Piotrek - nie znamy tych wszystkich lambd, strumieni i innych ficzerów, ale... umiemy pisać pancerne testy... i to szybko. Tak więc te trzy dokumenty zapiszemy w starym, dobrym stylu, mając pełnię świadomości, że Piotrek patrzyłby na nas z politowaniem:

@DisplayName("Weryfikacja poprawności zwracanych wyników")
@Test
public void findBy_02() {
  final Document d01 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 11));
  final Document d02 = new Document(
    Document.Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 6, 11));
  final Document d03 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 12));

  documentRepository.save(d01);
  documentRepository.save(d02);
  documentRepository.save(d03);

  // to be continued...

Teraz przyszedł czas na napisanie testu wzorcowego, względem którego będziemy pisać kolejne testy stosując zasadę najmniejszych różnić. Piszemy więc coś takiego:

@DisplayName("Weryfikacja poprawności zwracanych wyników")
@Test
public void findBy_02() {
 final Document d01 = new Document(
  Document.Type.OFFER, LocalDate.of(2020, 6, 11));
 final Document d02 = new Document(
  Document.Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 6, 11));
 final Document d03 = new Document(
  Document.Type.OFFER, LocalDate.of(2020, 6, 12));

 documentRepository.save(d01);
 documentRepository.save(d02);
 documentRepository.save(d03);

  // Test wzorcowy
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }

  // to be continued...

Skoro mamy już test wzorcowy, możemy zacząć pastwić się nad pierwszym argumentu (i tylko nad pierwszym). Kopiujemy test wzorcowy jakieś dziesięć razy i myślimy nad tym, jakie różne wartości warto wprowadzić w pierwszym argumencie metody findBy tak, aby dokładnie przetestować wpływ pierwszego argumentu na zwracany wynik. Oto jak wygląda kod po zmianach:

@DisplayName("Weryfikacja poprawności zwracanych wyników")
@Test
public void findBy_02() {
  final Document d01 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 11));
  final Document d02 = new Document(
    Document.Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 6, 11));
  final Document d03 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 12));

  documentRepository.save(d01);
  documentRepository.save(d02);
  documentRepository.save(d03);

  // Test wzorcowy
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }

  // Testy pierwszego argumentu
  {
    final Set<Document> docs = documentsWebService.findBy(
      null, LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 11), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 12), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 13), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList()), docs);
  }

  // to be continued...

Proste, nieprawdaż? Nawet małpa by to napisała. Kontynuujemy dalej, czas na drugi argument:

@DisplayName("Weryfikacja poprawności zwracanych wyników")
@Test
public void findBy_02() {
  final Document d01 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 11));
  final Document d02 = new Document(
    Document.Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 6, 11));
  final Document d03 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 12));

  documentRepository.save(d01);
  documentRepository.save(d02);
  documentRepository.save(d03);

  // Test wzorcowy
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }

  // Testy pierwszego argumentu
  {
    final Set<Document> docs = documentsWebService.findBy(
      null, LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 11), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 12), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 13), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList()), docs);
  }

  // Testy drugiego argumentu
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 12),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 11),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 10),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList()), docs);
  }

  // to be continued...

I wreszcie czas na testy ostatniego argumentu:

@DisplayName("Weryfikacja poprawności zwracanych wyników")
@Test
public void findBy_02() {
  final Document d01 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 11));
  final Document d02 = new Document(
    Document.Type.ORDER_ACKNOWLEDGE, LocalDate.of(2020, 6, 11));
  final Document d03 = new Document(
    Document.Type.OFFER, LocalDate.of(2020, 6, 12));

  documentRepository.save(d01);
  documentRepository.save(d02);
  documentRepository.save(d03);

  // Test wzorcowy
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }

  // Testy pierwszego argumentu
  {
    final Set<Document> docs = documentsWebService.findBy(
      null, LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 11), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 12), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 13), LocalDate.of(2020, 6, 15),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList()), docs);
  }

  // Testy drugiego argumentu
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 12),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01, d03)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 11),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList(d01)), docs);
  }
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 10),
      Document.Type.OFFER);
    assertEquals(new HashSet<>(Arrays.asList()), docs);
  }

  // Testy trzeciego argumentu
  {
    final Set<Document> docs = documentsWebService.findBy(
      LocalDate.of(2020, 6, 7), LocalDate.of(2020, 6, 15),
      Document.Type.ORDER_ACKNOWLEDGE);
    assertEquals(new HashSet<>(Arrays.asList(d02)), docs);
  }
}

Podsumowanie


Zobaczyłeś obie zasady w akcji: zasadę najmniejszych różnic oraz zasadę od lewej do prawej. Mam nadzieję, że przekonałem Cię, że zachowanie tych dwóch zasad pozwala w prosty, metodyczny sposób (nawet małpa dałaby radę) pisać testy, przez które nawet najmniejszy bug się nie prześlizgnie.


Oczywiście istnieją sytuacje, kiedy musimy zmienić więcej niż jeden argument równcześnie. Jak wówczas zachować obie w/w zasady? No cóż... chyba po prostu napiszę jeszcze jeden artykuł w tej serii. Tymczasem happy testing!


"One more thing..."


Ten i inne artykuły piszę po to, żeby podzielić się swoją wiedzą i doświadczeniem z innymi. Jednakże napisanie artykułu to dopiero połowa sukcesu - drugą połowę stanowi dotarcie z treścią do innych. Możesz mi bardzo pomóc w tym zadaniu - wystarczy, że klikniesz w jedną z czterech ikon poniżej i udostępnisz innym link do tego posta. Za tę pomoc będę Ci bardzo wdzięczny.