Testy jednostkowe #1 – Wprowadzenie, asercje

bug finding
Każdy programista w czasie pracy nieraz natrafi na błędy w swoich projektach. Im program bardziej rozbudowany, tym trudniej będzie te błędy wychwycić. Ich poszukiwanie jest niezwykle żmudnym i frustrującym zajęciem, nierzadko zniechęcającym do dalszej pracy. I o ile całkowite wyeliminowanie popełniania błędów jest niemożliwe (w końcu jesteśmy tylko ludźmi), to możemy skutecznie poprawić ich wykrywalność.

Wprowadzenie

Testy jednostkowe to sposób testowania programu, w którym wydzielamy niewielką część kodu (np. klasa, metoda) i testujemy ją w odizolowaniu od reszty oprogramowania.
Takie podejście do testowania daje nam szereg zalet. Poprzez wydzielanie małych wycinków kodu do testowania, możemy przetestować wiele funkcjonalności w izolacji. Dzięki temu działanie poszczególnych testów nie wpływa na siebie nawzajem. Gdy w nasz projekt wkradnie się błąd, to jeden lub więcej testów w IDE zaświeci się na czerwono, a my widzimy w którym miejscu go popełniliśmy.
Niniejszy artykuł jest pierwszym z trzech, w których krótko opiszę i przedstawię na przykładach podstawowe funkcjonalności umożliwiające pisanie własnych testów. Wszystkie przykłady zostały napisane w oparciu o język Java, bibliotekę JUnit oraz frameworki Hamcrest i Mockito.

Nazewnictwo i lokalizacja klas testowych

Załóżmy, że mamy pewną klasę, którą chcemy przetestować. W jaki sposób to zrobić? Otóż najpierw musimy utworzyć klasę testową. Klasa testowa powinna mieć taką samą nazwę jak klasa produkcyjna z dopiskiem Test. Przykładowo, jeżeli mamy klasę z kodem do przetestowania o nazwie Recipient, to klasa z testami powinna mieć nazwę RecipientTest.
W Javie wszystkie klasy kodu produkcyjnego znajdują się w folderze nazwaprojektu/src/main/java, a klasy kodu testowego znajdują się w folderze nazwaprojektu/src/test/java. Natomiast obydwie klasy powinny leżeć w tym samym pakiecie, tak jak na obrazku poniżej.
ścieżka do testów
Stosowanie się do tych reguł sprawia, iż kod produkcyjny i testowy są od siebie oddzielone, a odnalezienie klasy testowej korespondującej z klasą produkcyjną jest łatwe i intuicyjne.

Nazewnictwo i struktura testów

Wiemy już jak utworzyć klasę testową, teraz czas na napisanie samych testów. Aby tego dokonać, należy nad nazwą metody testowej dodać adnotację @Test. Jest to to informacja dla biblioteki JUnit, iż metoda poniżej tej adnotacji jest metodą testową.
Nazwa metody powinna jednoznacznie opisywać jakie jest jej przeznaczenie. Programista, po odpaleniu testów widzi nazwy wszystkich metod podświetlone na zielono (testy, które przeszły) i na czerwono (testy, które nie przeszły). Takich testów może być w jednym projekcie naprawdę dużo, a dobra nazwa metody ułatwia szybkie zlokalizowanie błędu w kodzie produkcyjnym.
Aby sam kod w metodzie testowej był przejrzysty, powinien być podzielony na trzy sekcje:
  • given – w tej części tworzymy dane wejściowe dla naszego testu,
  • when – tutaj uruchamiamy akcję którą chcemy przetestować, wykorzystując dane z sekcji given,
  • then – w tej sekcji sprawdzamy, czy wywołana metoda zachowała się tak, jak tego oczekujemy.
Konstruując testy w ten sposób, otrzymujemy pewien standardowy schemat działania testu: dla podanych parametrów (given) → gdy wywołam daną metodę (when) → sprawdź, czy otrzymam taki wynik (then).
Poniżej przykładowa metoda testowa z podziałem na sekcje:
    @Test
    void addressBookShouldNotBeEmptyAfterAddingRecipient() {

        //given
        AddressBook addressBook = new AddressBook();
        Recipient recipient = new Recipient();

        //when
        addressBook.add(recipient);

        //then
        assertThat(addressBook.getRecipientList(), hasSize(1));
    } 

Asercje

Asercja jest to metoda sprawdzająca, czy podana wartość jest zgodna z wartością oczekiwaną. Jeżeli wartości są zgodne, test zaświeci się na zielono, jeżeli nie są zgodne – na czerwono.
Asercja jest najważniejszym punktem każdego testu jednostkowego. To ona nam ostatecznie potwierdza, czy zbudowany przed wykonaniem asercji stan jest zgodny z tym czego oczekujemy.
Biblioteka JUnit zawiera wiele różnych asercji, przykładowe z nich to:
  • assertEquals() – sprawdza, czy podane parametry są takie same,
  • assertNotEquals() – sprawdza, czy podane parametry są różne,
  • assertArrayEquals() – sprawdza, czy podane tablice są takie same,
  • assertNull() – sprawdza czy podany parametr jest pusty,
  • assertNotNull() – sprawdza czy podany parametr nie jest pusty.
Asercje zazwyczaj przyjmują dwa argumenty – parametr oczekiwany oraz parametr do sprawdzenia. Wyjątkiem są asercje sprawdzające np. czy dany parametr jest pusty – one przyjmują tylko jeden argument.
Największą elastyczność daje asercja assertThat() wykorzystująca Matchery. Matchery to metody wykorzystywane jako argument w asercji assertThat(), które sprawdzają co musi się stać, aby asercja zakończyła się powodzeniem. Zastosowanie matcherów poprawia czytelność asercji.
Poniżej kilka przykładów zastosowania asercji.
public class AddressBook {

    private List<Recipient> recipientList = new ArrayList<>();

    public void add(Recipient recipient) {
        recipientList.add(recipient);
    }

    public void remove(Recipient recipient) {
        recipientList.remove(recipient);
    }

    public void clear() {
        recipientList.clear();
    }

    //getters & setters
} 
Klasa AddressBook odpowiada za zarządzanie książką adresową przechowującą listę obiektów klasy Recipient (adresat). Metody tej klasy umożliwiają dodanie adresta, usunięcie adresata oraz usunięcie wszystkich adresatów z listy. Stwórzmy zatem klasę testową i przetestujmy działanie tych metod.
class AddressBookTest {

    @Test
    void addressBookShouldNotBeEmptyAfterAddingRecipient() {

        //given
        AddressBook addressBook = new AddressBook();
        Recipient recipient = new Recipient("Adam", "Nowak", 34, "Warszawa", "323-011-976");

        //when
        addressBook.add(recipient);

        //then
        assertThat(addressBook.getRecipientList(), not(nullValue()));
        assertThat(addressBook.getRecipientList(), hasSize(1));
        assertThat(addressBook.getRecipientList().size(), greaterThan(0));
        assertThat(addressBook.getRecipientList().size(), equalTo(1));
    }

    @Test
    void addressBookShouldNotContainRecipientAfterRemovingIt() {

        //given
        AddressBook addressBook = new AddressBook();
        Recipient recipient = new Recipient("Adam", "Nowak", 34, "Warszawa", "323-011-976");
        addressBook.add(recipient);

        //when
        addressBook.remove(recipient);

        //then
        assertThat(addressBook.getRecipientList(), not(contains(recipient)));
    }

    @Test
    void addressBookShouldBeEmptyAfterClearingIt() {

        //given
        AddressBook addressBook = new AddressBook();
        Recipient recipient1 = new Recipient("Adam", "Nowak", 34, "Warszawa", "323-011-976");
        Recipient recipient2 = new Recipient("Jan", "Kowalski", 27, "Kraków", "414-884-273");
        addressBook.add(recipient1);
        addressBook.add(recipient2);

        //when
        addressBook.clear();

        //then
        assertThat(addressBook.getRecipientList(), hasSize(0));
    }
} 
Wszystkie asercje przeprowadzono z wykorzystaniem assertThat(). Bardzo często można przeprowadzić jedną asercję na wiele różnych sposobów, tak jak w pierwszej metodzie testowej.

Testowanie wyjątków

Ciekawym rodzajem asercji jest assertThrows(), która sprawdza, czy dana metoda rzuca określony wyjątek. Asercja kończy się powodzeniem, jeżeli wyjątek zostanie rzucony. Pozwala to na testowanie warunków granicznych (np. sprawdzenie, czy zostanie rzucony wyjątek, gdy dany parametr nie mieści się w określonym zakresie).
Pierwszym parametrem asercji assertThrows() jest nazwa klasy wyjątku, który oczekujemy, aby został rzucony. Drugim parametrem jest implementacja interfejsu funkcyjnego Executable, która powinna ten wyjątek rzucić. W praktyce będzie to najczęściej wyrażenie lambda z kodem, który powinien rzucić wyjątkiem.
public class Recipient {

    private String name;
    private String surname;
    private int age;
    private String city;
    private String phoneNumber;

    public Recipient(String name, String surname, int age, String city, String phoneNumber) {
        this.name = name;
        this.surname = surname;
        this.city = city;
        this.phoneNumber = phoneNumber;

        if (age < 0 || age > 100) {
            throw new IllegalArgumentException("Age should be between 0 and 100");
        } else {
            this.age = age;
        }
    }

    //getters & setters
} 
Klasa Recipient przechowuje dane jednego adresata. Jak widać, wiek powinien być w zakresie od 0 do 100. Jeżeli wiek wychodzi poza zakres, powinien zostać rzucony wyjątek IllegalArgumentException.
class RecipientTest {

    @Test
    void creatingRecipientWithAgeGreaterThan100ShouldThrowAnError() {

        //given
        //when
        //then
        assertThrows(IllegalArgumentException.class,
                () -> new Recipient("Adam", "Nowak", 101, "Malbork", "274-737-999"));
    }

    @Test
    void creatingRecipientWithAgeLessThan0ShouldThrowAnError() {

        //given
        //when
        //then
        assertThrows(IllegalArgumentException.class,
                () -> new Recipient("Adam", "Nowak", -4, "Malbork", "274-737-999")
        );
    }
} 
Metody testowe sprawdzają czy utworzenie nowego obiektu klasy Recipient z niepoprawnym wiekiem spowoduje rzucenie wyjątku. Co ciekawe, obydwie metody w celu przeprowadzenia testu wykonują jedynie asercję, dlatego sekcje given, when i then zostały połączone w jedną sekcję.

Podsumowanie

W niniejszym artykule przedstawiłem po co testujemy oprogramowanie, jak powinny być zbudowane testy oraz czym są i do czego służą asercje. W drugiej części przedstawię szczególne obiekty wykorzystywane w testach, zwane atrapami, oraz opiszę zasady pisania dobrych testów jednostkowych.

Znajdź mnie również na:

arrow