Kurs Java od podstaw Tydzień 3

Testy jednostkowe – TDD i JUnit

Testy jednostkowe

Czas rozpoczać temat, który nie każdego zbyt cieszy. Po prostu nie każdy lubi pisać testy, każdy woli pisać kod aplikacji – to proste. Jednak trzeba zrozumieć, że testy jednostkowe są bardzo ważne i powinny być pisane zawsze. Już śpieszę z tłumaczeniem dlaczego tak jest.

Czym są testy jednostkowe…

Testy jednostkowe jak sama nazwa wskazuje odpowiadają za testowanie jednostek – czyli małych części aplikacji. Mówiąc małych mam na mysli metody – większość, lecz nie wszystkie co się później okaże.

Dlaczego to jest takie ważne?

Przypuśćmy, że nad aplikacją pracuje  5 developerów, produkt jest tworzony już dwa lata i jest ciągle rozwijany, posiada wiele modułów i pierdyliard innych rzeczy. Jest to już produkt, nad którego produkcją jest coraz ciężej zapanować – w końcu ciągle się rozrasta, co może być niebezpieczne nie pisząc testów jednostkowych.

Testy jednostkowe – czyli testowanie metod pozwala nam z góry upewnić się, że testowana metoda działa poprawnie. Przypuśćmy, że mamy jakiś bug w aplikacji, naprawa jego wymaga poprawienia jednej metody, która jest używana również w innych miejscach w kodzie – np. w 10.

Dobry developer będzie ostrożnie edytował metodę, ponieważ wie, że  zła zmiana w metodzie – choć może naprawić bug! – może odbić się na innych metodach, które nie są przygotowane na takie zmiany.

Zaś świadomy developer będzie miał przygotowane testy wszystkich tych metod i bez żadnych obaw będzie robił zmiany w tej konkretnej metodzie, ponieważ wie, że testy jednostkowe za niego sprawdzają poprawność innych metod.

Podsumowując – testy jednostkowe pozwalą nam sprawdzić poprawność działania metody, to my metodzie dajemy konkretne dane np. tablicę liczb i oczekujemy konkretnego, tylko jednego poprawnego rozwiązania – np. poprawnej średniej podanych liczb.

Test Driven Development

Każdy kto wchodzi w świat testów zderza się z terminem TDD – czyli Test Driven Development. Co to tak naprawdę oznacza?

TDD oznacza pisanie aplikacji w taki sposób, gdzie testy jednostkowe mają wyższy priorytet niż sam kod. Ale jak to zapytasz?

W TDD jest proste podejście – najpierw piszemy test, dopiero później kod! Kod piszemy do skutku, aż testy przejdą – nie ma czegoś takiego jak wyłączenie testów – testy mają być na zielono!

I w sumie mógłbym zostawić Cię z tym – niby proste – najpierw testy, później kod. Jednak bardziej przybliżmy sobie to zjawisko – zostawmy jednak teorię, przejdźmy do pisania kodu!

JUnit

Do pisania testów jednostkowych użyjemy biblioteki JUnit – bardzo popularnej i intuicyjnej do pisania testów. I dodamy ją z poziomu naszego Mavena, czyli deklarując ją w pliku pom.xml.

Na początku mamy o to taki piękny plik pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>pl.kjop</groupId>
    <artifactId>management</artifactId>
    <version>1.0.0</version>

</project>

Potrzebujemy w nim dodać miejsce na dependency, jest to dokładnie między tagami dependencies:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>pl.kjop</groupId>
    <artifactId>management</artifactId>
    <version>1.0.0</version>

    <dependencies>
    </dependencies>

</project>

Możemy między nimi dodawać teraz przeróżne dependency – ale skąd o nich wiedzieć? Wystarczy wpisać w Google:

maven junit

I na pierwszej stronie znajdziemy możliwe zależności Junit. Ja wybrałem najnowszą wersję 4.12.

Z niej skopiowałem po prostu schemat dependency:

<!-- https://mvnrepository.com/artifact/junit/junit -->
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>

I to wystarczy dokleić do pliku pom i możemy już korzystać z Junit. 😉

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>pl.kjop</groupId>
    <artifactId>management</artifactId>
    <version>1.0.0</version>

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

I w taki o to sposób dodałeś swoją pierwszą dependency – IntelliJ powinien automatycznie zaciągnąć już JUnit ze zdalnego repozytorium. 😉

Testy w praktyce

Póki co nasza aplikacja Management jest stworzona jako projekt Maven, w ramach nauki testów stwórz nowy projekt Maven, w którym trochę się pobawimy. 😉 Możesz to zrobić tak samo jak w poprzedniej lekcji, gdy przenosiliśmy naszą aplikację ze zwykłego projektu na Maven project.

Jeżeli masz już stworzony pusty projekt, w którym jest dodany JUnit przejdźmy do pisania testów.

Naszym zadaniem jest stworzyć klasę odpowiedzialną za obliczanie sumy i średniej oraz znalezienie min i max. Na obliczaniu sumy i średniej zobaczysz co miałem na myśli, mówiąc, że jedna metoda powoduje uszkodzenie innych metod. 😉

Na początek stwórzmy sobię klasę Math – i nic więcej z nią nie róbmy, wrócimy do niej niebawem. 😉

Ważne aby klasa testowana była w tak samo nazwanym pakiecie co znajdują się testy.

public class Math {
}

W folderze src/test stwórzmy pakiet math, a w nim klasę o nazwie: MathMaxTest. Dzięki pakietowi, będziemy mieli podzielone testy np. per klasa.

package math;

public class MathMaxTest {
}

Stwórzmy w niej test – jest to po prostu metoda z adnotacją Test.

Adnotacja może pojawiać się przed klasą, polem lub metodą – jest to nazwa poprzedzona @, może również w nawiasach przyjmować argument. O adnotacjach powiemy sobie później, jednak wystarczy Ci teraz wiedzieć, że pozwalają one wskazywać elementy, na które musi np. zwrócić uwagę kompilator lub jakieś narzedzię – tak jak u nas JUnit, któremu wskazujemy, że to jest nasz test.

Test będzie wyglądał tak:

package math;

import org.junit.Test;

public class MathMaxTest {

    @Test
    public void testFindMaxInPositiveNumbers() {
    }
}

Czas teraz napisać ciało testu – różniamy trzy etapy:

  • is – czyli jest coś dane np. liczby
  • then – wykonujemy sprawdzenie metody na podstawie danych z is
  • excepted – oczekujemy konkretnego wyniku operacji z then

Naszym is jest podanie danych wejściowych, u nas jest to tablica liczb:

int [] numbers = {0, 12, 3, 4, 8, 9, 55, 12, 99, 101};

Czas na then – czyli wykonanie operacji – wywołanie metody (którą dopiero napiszemy później!)

final int result = Math.findMax(numbers);

Wywołujemy metodę statyczną findMax Math – którą dopiero stworzymy. Jak widać nasza metoda będzie musiała przyjmować tablicę liczb.

Na koniec sprawdzamy czy wartość zwrócona jest faktycznie równa wartości oczekiwanej przez nas.

Assert.assertEquals(result, 101);

W tym celu korzystamy z tzw. Assercji z Junit – są przeróżne assertTrue, assertFalse, assertEquals, assertArrayEquals i każda assercja służy do innego sposobu sprawdzania rezultatu.

My użyliśmy assertEquals oznacza to, że podane przez nas dwa argument (result i 101) są sobie równe – jeżeli tak to test zostanie spełniony. Pod spodem tak naprawdę jest zwykłe sprawdzenie przy użyciu if i operatora ==, jednak jest to opakowane w asercję z JUnit, abyśmy wiedzieli kiedy test jest spełniony, a kiedy nie.

Cały test wygląda tak:

@Test
public void testFindMaxInPositiveNumbers() {
    //is
    int [] numbers = {0, 12, 3, 4, 8, 9, 55, 12, 99, 101};

    //then
    final int result = Math.findMax(numbers);

    //excepted
    Assert.assertEquals(101, result);
}

Oczywiście komentarzy is, then, excepted możemy pomijać, ja napisałem je tylko po to, abyś wiedział co do czego służy.

Jeszcze raz omówimy test – mamy liczby, chcemy aby metoda znalazła największą wśród nich. Sami wiemy, że największą liczbą jest 101 dlatego jej oczekujemy. Jeżeli metoda go zwróci to znaczy, że test jest spełniony.

Napiszmy sobie jeszcze drugi przypadek – w tym przypadku będą również liczby ujemne – może przykład i bez sensu, jednak pozwala Ci pokazać, że jedna metoda nie oznacza napisania jednego testu. Testów powinno być napisane tyle, aby sprawdzić jak najwięcej sytuacji podbramkowych – tzw. edge/corner case’ów. 😉

@Test
public void testFindMaxInNegativeNumbers() {
    //is
    int [] numbers = {0, -12, -3, 4, 8, 9, -55, 15, -99};

    //then
    final int result = Math.findMax(numbers);

    //excepted
    Assert.assertEquals(15, result);
}

Skoro mamy już testy możemy przejść do pisania kodu.

Nie będę się juz skupiał na tłumaczeniu co się dzieje w metodzie findMax, ponieważ już nie raz pojawiała się jej implementacja w tym kursie.

Nasza metoda findMax wygląda tak:

public class Math {
    
    public static int findMax(int [] numbers) {
        int max = Integer.MIN_VALUE;

        for (int number : numbers) {
            if (number > max) {
                max = number;
            }
        }
        return max;
    }
}

Metoda jest statyczna to znaczy, że możemy dostać się do niej bez tworzenia obiektu przez nazwę klasy np. Math.findMax. 

Skoro mamy już utworzone testy i klasę to czas je uruchomić. Wpisujemy w konsolę:

mvn install

Jeżeli wszystko poszło ok to paczka się zbuduję, a jednym z komunikatów Mavena będzie:

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running math.MathMaxTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.083 sec

Results :

Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

Czyli wszystkie testy przeszły.

Gdybyśmy zmienili tylko wartość expected z 15 na 16:

@Test
public void testFindMaxInNegativeNumbers() {
    //is
    int [] numbers = {0, -12, -3, 4, 8, 9, -55, 15, -99};

    //then
    final int result = Math.findMax(numbers);

    //excepted
    Assert.assertEquals(16, result);
}

To od razu dostalibysmy informację:

Results :

Failed tests:   testFindMaxInNegativeNumbers(math.MathMaxTests): expected:<15> but was:<16>

Tests run: 2, Failures: 1, Errors: 0, Skipped: 0

Dokładnie otrzymując informację, który test nie przeszedł i jaki był rezultat. 😉

Kolejne testy..

Stwórzmy sobie test odpowiedzialny za sprawdzenie, czy obliczona wartość jest poprawna.

package math;

import org.junit.Assert;
import org.junit.Test;

public class MathSumTest {

    @Test
    public void testSum() {
        //is
        int [] numbers = {1, 2, 3};

        //then
        int result = Math.calculateSum(numbers);

        //then
        Assert.assertEquals(6, result);
    }
}

Podajemy, więc trzy liczby – obliczamy sumę i oczekujemy wyniku 6 – w końcu to jest poprawny wynik dodawania liczb.

Czas na napisanie metody calculateSum – przy pomocy pętli zliczamy wszystkie elementy i tyle.

public static int calculateSum(int[] numbers) {
    int sum = 0;
    for(int number : numbers) {
        sum += number;
    }

    return sum;
}

No i ponownie uruchamiamy wszystkie testy.

mvn install

No i nasze testy jak widać przeszły.

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running math.MathMaxTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.075 sec
Running math.MathSumTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0 sec

Results :

Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

Stwórzmy teraz kolejny test, który będzie sprawdzał poprawność obliczonej średniej liczb.

package math;

import org.junit.Assert;
import org.junit.Test;

public class MathAverageTest {

    @Test
    public void testCalculateAverage() {
        //is
        int [] numbers = {1, 2, 3};

        //then
        float average = Math.calculateAverage(numbers);

        //expected
        Assert.assertEquals(2.0, average, 0.01);
    }
}

Trzeci argument assertEquals czyli liczba 0.01 odpowiada za to, do której liczby do przecinku mają być porównywane liczby zmiennoprzecinkowe float.

I czas na stworzenie metody do obliczania średniej – skorzystamy oczywiście z istniejącej już metody do obliczania sumy liczb.

public static float calculateAverage(int[] numbers) {
    return Math.calculateSum(numbers) / (float) numbers.length;
}

Po uruchomieniu testów wszystko zostało spełnione – jednak co, gdybyśmy zmienili lekko metodę do sumowania liczb:

public static int calculateSum(int[] numbers) {
    int sum = 1;
    for(int number : numbers) {
        sum += number;
    }
    return sum;
}

Zmieniliśmy wartość początkową sum z 0 na 1 – bardzo wymuszany błąd, jednak pozwoli Ci pokazać jak zadziałają teraz testy.

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running math.MathAverageTest
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.122 sec <<< FAILURE!
testCalculateAverage(math.MathAverageTest)  Time elapsed: 0.014 sec  <<< FAILURE!
java.lang.AssertionError: expected:<2.0> but was:<2.3333332538604736>

Running math.MathMaxTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.001 sec
Running math.MathSumTest
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0 sec <<< FAILURE!
testSum(math.MathSumTest)  Time elapsed: 0 sec  <<< FAILURE!
java.lang.AssertionError: expected:<6> but was:<7>  

Results :

Failed tests:   testCalculateAverage(math.MathAverageTest): expected:<2.0> but was:<2.3333332538604736>
  testSum(math.MathSumTest): expected:<6> but was:<7>

Tests run: 4, Failures: 2, Errors: 0, Skipped: 0


Przy zmianie tylko w jednej funkcji dostaliśmy błąd w aż w testach z dwóch klas – rozumiesz już teraz?

Dzięki testom zabezpieczamy się przed rozsypaniem się całej aplikacji podczas zmiany w jednym miejscu w kodzie. Jest to bardzo duża zaleta testów jednostkowych – mamy właśnie pewność, że nasze jednostki są sprawne. 😉

Co testować?

Testować warto większość metod – jest to około 70-80% kodu aplikacji. Sprawdzamy wszystko co może faktycznie pójść nie tak – czyli prawie wszystko.

Czego, więc nie testować? Moim zdaniem są to modele. Modele służą tylko do przechowywania informacji, nie ma potrzeby testowania czy obiekt poprawnie się zbudował, czy może setter działa poprawnie. Są to zbyt proste operacje, aby je testować. Warto jednak się skupić na szukaniu różnych przypadków metod bardziej serwisowych, czyli tych, które są za coś odpowiedzialne. 😉

Podsumowanie

Testy jednostkowe pozwalają nam na tworzenie i edytowanie aplikacji z czystym sumieniem. Dzięki nim możemy spać spokojnie, czy nasza zmiana w kodzie, aby na pewno nie popsuło 10 innych funkcjonalności. Choć może jest to teraz abstrakcyjne to przy dużym projekcie mogłoby się to stać.

Zachęcam Cię, więc do pisania kodu – choć nie zawsze nam się chcę, jednak w przyszłości sobie podziękujemy za to, gdy będziemy widzieć co się nam sypie w aplikacji.

Same testy też trzeba ćwiczyć, więc wykonaj następujące zadania.

  1. Stwórz klasę Math, która będzie posiadała kilka metod matematycznych. Pierwsza z nich będzie wyliczała silnię na podstawie podanej liczby n , druga wyliczała wynik mnożenia podanych liczb w tablicy, trzecia będzie odpowiedzialna za znalezienie minimum w podanych liczbach. Postaraj się napisać do każdej metody co najmniej trzy testy. Najpierw napisz testy, a dopiero później metodę. 
  2. Stwórz klasę UserService, która będzie posiadała tablicę Stringów, która będzie inicjalizowana przy użyciu konstruktora parametrowego. Napisz trzy metody: pierwsza odpowiedzialna, za sprawdzenie czy User istnieje w tablicy, druga będzie zwracała ilość userów w tablicy, trzecia zaś będzie liczyla ilość powtórzeń podanego loginu w tablicy. Do każdej z tych metod napisz 2-3 testy jednostkowe, a dopiero weź się za pisanie klasy UserService. O ile w przykładach i w pierwszym zadaniu metody mogły być statyczne to teraz podczas testowania metod musisz najpierw utworzyć obiekt i podać mu tablicę Stringów, na której będziemy przeprowadzać testy. W każdym teście twórz po prostu obiekt UserService, a w konstruktorze daj mu odpowiednią tablicę wartości. 😉

W przypadku testowania funkcji boolean wykorzystaj assertTrue/assertFalse – które spełniają test, gdy dostaną dokładnie true lub false.

Rozwiązanie zadań możesz znaleźć tutaj.

Subscribe
Powiadom o
guest
7 komentarzy
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
tomasz
tomasz
2 lat temu

[INFO] Scanning for projects…
[INFO]
[INFO] ————————————————-
[INFO] Building MojeTesty 1.0-SNAPSHOT
[INFO] ——————————–[ jar ]———————————
[INFO] ————————————————————————
[INFO] BUILD FAILURE
[INFO] ————————————————————————
[INFO] Total time: 0.078 s
[INFO] Finished at: 2018-08-18T21:22:28+02:00
[INFO] ————————————————————————
[ERROR] Unknown lifecycle phase „instal”. You must specify a valid lifecycle phase or a goal in the format : or :[:]:. Available lifecycle phases a
re: validate, initialize, generate-sources, process-sources, generate-resources, process-resources, compile, process-classes, generate-test-sources, process-test-sources, generate-test-resources, process-test-resources, test-co
mpile, process-test-classes, test, prepare-package, package, pre-integration-test, integration-test, post-integration-test, verify, install, deploy, pre-clean, clean, post-clean, pre-site, site, post-site, site-deploy. -> [Help
1]
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/LifecyclePhaseNotFoundException

Taki błąd po wpisaniu komendy mvn instal

Poul
Poul
2 lat temu
Reply to  tomasz

Zjadłeś drugie l przy install.
A ja mam taki problem, że nie mogę stworzyć klasy we folderach projektu Maven, tylko we folderze test mogę stworzyć nową klasę

Poul
Poul
2 lat temu

Jest błąd przy drugim teście, tworzysz metodę do sumowania a w teście oczekujesz w wyniku średnią, nawet napisałeś, że obliczamy średnią ale oczekujemy sumy, a w expected jest wynik ze średniej, do tego jako typ zmiennej result przypisałeś float, nie ma opcji aby taki test przeszedł.
Pozdrawiam.

Kamil Klimek
Kamil Klimek
2 lat temu
Reply to  Poul

Dzięki, już poprawione. Podczas redagowania lekcji poprawiałem coś jeszcze na szybko i poprawiłem przypadkiem nie ten test co potrzeba. 😉

Dawid Orzycki
Dawid Orzycki
2 lat temu

Swietny rozdzial, zadania fajne – zmuszaja do wysilenia sie przy nauce!

Artur Stalowy
Artur Stalowy
2 lat temu

Tests run: 4, Failures: 2, Errors: 0, Skipped: 0

[INFO] ————————————————————————
[INFO] BUILD FAILURE
[INFO] ————————————————————————
[INFO] Total time: 2.360 s
[INFO] Finished at: 2018-09-30T17:30:25+02:00
[INFO] ————————————————————————
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.12.4:test (default-test) on project MvnTestJunit: There are test failures.
[ERROR]
[ERROR] Please refer to F:JAVAJAVA_PMvnTestJunittargetsurefire-reports for the individual test results.
[ERROR] -> [Help 1]
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException

Wszystko niby mi działa, kod jest dobry ale np. umyślne wywołanie błędu w teście zwraca mi inny wynik niż tobie.
Dzieje się to po ustawieniu sumy na 1