Mateusz Mnich, Wojciech Leniar
Ailleron S.A.
Mateusz Mnich, Wojciech LeniarSoftware Mind part of Ailleron Group @ Ailleron S.A.

Czy testy jednostkowe w Javie mogą stanowić dokumentacje projektową?

Sprawdź, jakie możliwości daje wykonywanie testów jednostkowych oraz czy można je wykorzystywać jako dokumentację projektu.
6.10.20207 min
Czy testy jednostkowe w Javie mogą stanowić dokumentacje projektową?

Dlaczego programiści nie lubią pisać testów jednostkowych? Powodów prawdopodobnie jest tyle, co samych programistów. Jednymi z najczęściej powtarzanych wymówek są:

  • Ręczę za swój kod, działa on dobrze i nie potrzebuję testów.
  • Przecież przetestowałem swój kod na działającej aplikacji.
  • Ja na swoim stanowisku powinienem przygotowywać kod produkcyjny i wymyślać odpowiednie rozwiązania, niech te testy pisze ktoś inny.
  • Nie mam czasu, muszę jeszcze udokumentować swoje zmiany i odpowiedzieć na komentarze z code review.


To tylko wybrane powody, przez które nie lubimy pisać testów jednostkowych. Zapominamy przy tym, że testy pozwolą nam później wprowadzać bezpieczne zmiany do aplikacji. Co to znaczy? 

Jeżeli wprowadziliśmy zmiany w konkretnym miejscu w obrębie danej funkcjonalności, to liczymy, że testy nie powiodą się tylko dla tych funkcjonalności. Jeżeli nie powiodły się jeszcze jakieś inne testy, to jest bardzo możliwe, że ingerowaliśmy w bardziej złożony proces. To daje nam możliwość przeanalizowania, czy faktycznie nasza zmiana powinna powodować takie problemy. Dobrze napisane testy jednostkowe ułatwiają także wykrywanie bugów znacznie wcześniej niż na produkcji.

A gdyby tak nasze testy stanowiły nie tylko czujnik wykrywający niepożądane zmiany, ale również dokumentowały to, co się dzieje w naszych zawiłych procesach biznesowych? Brzmi jak niespełniony sen programisty? Podejmujemy wyzwanie i spróbujemy zmienić negatywne spojrzenie na testy jednostkowe!

Nowa nadzieja

Każdy z nas zapewne słyszał o TDD, ale pewnie już znacznie mniej osób korzysta z niego w życiu codziennym. TDD to nie sposób pisania testów, tylko technika tworzenia oprogramowania - ale co ona nam tak naprawdę daje? 

W większości programiści tworzą testy jednostkowe do już istniejących rozwiązań - i nie mówimy, że to jest złe. Natomiast w takim przypadku jesteśmy zmuszeni napisać test, który sprawdza istniejący kod. A co dzieje się w przypadku, gdy nasz kod ma jeszcze niewykrytego buga? Nasz test tego nie wykryje, ponieważ w tym momencie testujemy kod, a nie funkcjonalność. Założenie TDD opiera się na tym, że piszemy testy do funkcjonalności, a nie kodu. Z góry zakładamy, jak ma działać funkcjonalność, a potem uzupełniamy ją o implementację. Nie nastawiamy się na kod, nie widzimy go – gdyż jeszcze go nie ma. To naprawdę zmienia punkt widzenia!

Przejdźmy do zaprezentowania naszej aplikacji, która dzięki wykorzystaniu dwóch wzorców projektowych: Fabryki oraz Strategii, przygotowuje konkretne typy samochodów na podstawie zadanych parametrów.

Struktura projektu:

Interfejs strategii:

public interface CarCreatorStrategy {
    Car createCar();
​
    CarType getCarType();
}


Przykład konkretnej implementacji strategii:

public class SportCarCreatorStrategy implements CarCreatorStrategy {
    public Car createCar() {
        return new SportCar(2, 3.0F, "FERRARI");
    }
​
    public CarType getCarType() {
        return CarType.SPORT;
    }
}


Fabryka – klasa, na której testowaniu dzisiaj się skupimy:

public class CarFactory {
​
    private final List<CarCreatorStrategy> strategies = new ArrayList<CarCreatorStrategy>();
​
    public CarFactory() {
        strategies.add(new FamilyCarCreatorStrategy());
        strategies.add(new SportCarCreatorStrategy());
        strategies.add(new TruckCarCreatorStrategy());
        strategies.add(new LimousineCarCreatorStrategy());
    }
​
    public Car createCar(CarType carType) {
        for (CarCreatorStrategy strategy : strategies) {
            if(carType == strategy.getCarType()) {
                return strategy.createCar();
            }
        }
        throw new IllegalArgumentException("Invalid car type "+ carType);
    }
}

Atak klonów

Oto przykładowy test, który został błędnie napisany. Przeanalizujmy go pod względem czytelności. 

class CarFactoryTest {
​
    @Test
    public void testCreateCar() {
        CarFactory carFactory = new CarFactory();
        Car car = carFactory.createCar(CarType.SPORT);
​
        assertThat(car.getCarType()).isEqualTo(CarType.SPORT);
​
        assertThat(car.getCarBrand()).isEqualTo("FERRARI");
​
        assertThat(car.getEngineCapacity()).isEqualTo(3.0f);
​
        assertThat(car.getSeatsNumber()).isEqualTo(2);
    }
​
}


Test nie jest czytelny, raczej odpychający. Jest to normalne przy takim technicznym kodzie. Nawet w tak prostym scenariuszu znalezienie przyczyny błędu nie jest proste. Poprawienie takiego testu w przypadku regresji jest ponurym obowiązkiem, który nie pomoże nam w zrozumieniu problemu.

Przeanalizujmy zatem podstawowe problemy takiego podejścia:

  • Setup testów jest tak opasły i nieczytelny, że wygląda jak mechanizm analizujący start rakiety SpaceX.
  • Asercje wyglądają tak, jakby zawsze testowały to samo
  • No i w końcu... Co tu w ogóle jest testowane? Gdzie są jasno zdefiniowane założenia tych testów? 


Mówiliśmy na początku, że testy jednostkowe mogą być również dokumentacją rozwiązań biznesowych. Patrząc na powyższy kod, nie wydaje się to prawdopodobne. Po pierwsze, kod jest nieczytelny, a po drugie, te testy same wymagają dokumentacji.

Przebudzenie mocy

Wymagania biznesowe znamy, nie znamy konkretnej implementacji - dlatego tworzymy pseudokod Given/When/Then – nawet w zwykłych komentarzach.

Given – w tej części opisujemy założenia testu, informujemy, w jakim stanie znajduje się funkcjonalność przed testowanym wydarzeniem oraz definiujemy jej parametry/argumenty.
When – Określa testowane zdarzenie (np. wywołanie testowanej funkcjonalności).
Then – Definiuje oczekiwany skutek zdarzenia. W tym miejscu weryfikujemy poprawność rezultatu wykonanej akcji.

Odrodzenie

Jak się powiedziało A, to trzeba powiedzieć i B, zatem przejdźmy do zdefiniowania Given/When/Then dla naszego testu.

  @Test
    public void shouldCreateSportFerrariCar() {
        //given
        // There is a car factory
​
        //when
        // Create Sport car
​
        //then
        // Ferrari will be produced


To jest dobry moment na zatrzymanie się na chwilę. Samo zdefiniowanie Given/When/Then na nic się nie zda, jeśli nie nazwiemy testu poprawnie. Teraz zacznie się jazda bez trzymanki, no bo jak poprawnie nazwać test? 

Zazwyczaj programiści nie wysilają się w tej części i biorą nazwę metody, którą testują, dopisują do niej “test” - i voila! Niestety, tak to nie działa. Jak wspominaliśmy na wstępie - testujemy funkcjonalność, a nie kod! W nazwie testu powinniśmy zatem uwzględnić konkretną sytuację, której oczekujemy, np. ShouldCreateSportFerrariCar. Dzięki temu, gdy ktoś przeczyta nazwę testu, jest w stanie powiedzieć, co ten test sprawdza. Pozornie tak niewiele, prawda?

Należy zwrócić również uwagę na narzędzia, które wykorzystujemy - mogą nam naprawdę pomóc i zrobić ogromną ilość rzeczy “pod spodem”, zwalniając nas z obowiązku pisania jeszcze większej ilości technicznego kodu.

    @Test
    public void shouldCreateSportFerrariCar() {
        //given
        CarFactory carFactory = new CarFactory();
        Car expectedCar = new SportCar(2, 3.0f, "FERRARI");
​
        //when
        Car createdCar = carFactory.createCar(CarType.SPORT);
​
        //then
        assertThat(createdCar)
                .as("Created car should be exactly as expected")
                .isEqualToComparingFieldByField(expectedCar);


W tym miejscu użycie metody isEqualToComparingFieldByField opisuje wszystko, co się tam dzieje. Moglibyśmy w bardzo łatwy sposób schować ścianę asercji w jednej metodzie dostarczonej przez AssertJ. Nasz test w porównaniu do wersji bazowej wygląda już lepiej, ale nadal nie idealnie. Zbyt dużo tu kodu skupiającego się na technicznej konstrukcji, zamiast na funkcjonalności.

Spróbujmy teraz schować nasze ustawienia i techniczny kod w klasie pomocniczej, tak, abyśmy mogli w naszym teście skupić się tylko i wyłącznie na przetestowaniu funkcjonalności, a nie na zagłębieniu się w ustawienia testu czy asercje.

public class CarFactoryTestBuilder {
​
    private CarFactory carFactory;
​
    private Car car;
​
    private CarFactoryTestBuilder() {
​
    }
​
    public static Given given() {
        return new CarFactoryTestBuilder().buildGiven();
    }
​
    private Given buildGiven() {
        return new Given();
    }
​
    public class Given {
​
        public Given thereIsACarFactory() {
            carFactory = new CarFactory();
            return this;
        }
​
        public When when() {
            return new When();
        }
    }
​
    public class When {
​
        public When sportFerrariCarCreated() {
            car = carFactory.createCar(CarType.SPORT);
            return this;
        }
​
        public Then then() {
            return new Then();
        }
    }
​
    public class Then {
​
        public Then carTypeShouldBe(CarType carType) {
            assertThat(car.getCarType())
                    .as("Car type should be " + carType)
                    .isEqualTo(carType);
            return this;
        }
​
        public Then seatsNumberShouldBe(int seatsNumber) {
            assertThat(car.getSeatsNumber())
                    .as("Seats number should be " + seatsNumber)
                    .isEqualTo(seatsNumber);
            return this;
        }
​
        public Then carBrandShouldBe(String carBrand) {
            assertThat(car.getCarBrand())
                    .as("Car brand should be " + carBrand)
                    .isEqualTo(carBrand);
            return this;
        }
​
        public Then engineCapacityShouldBe(float engineCapacity) {
            assertThat(car.getEngineCapacity())
                    .as("Engine capacity should be " + engineCapacity)
                    .isEqualTo(engineCapacity);
            return this;
        }
    }
}


Powyższy screenshot pokazuje, jak wygląda klasa CarFactoryTestBuilder po wyniesieniu naszego kodu do jego klas wewnętrznych Given/When/Then.

Każda z tych klas reprezentuje metody, które są specyficzne dla każdego bloku. Nie zmieniło się tutaj nic, czego nie mieliśmy wcześniej w naszym teście - prócz tego, że klasa Given udostępnia metodę When, a klasa When udostępnia metodę Then.

Jak zatem wygląda teraz nasz test?

import static carfactory.CarFactoryTestBuilder.given;
​
class CarFactoryTest {
​
    @Test
    public void shouldCreateSportFerrariCar() {
​
        given()
           .thereIsACarFactory()
        .when()
            .sportFerrariCarCreated()
        .then()
            .carBrandShouldBe("FERRARI")
            .carTypeShouldBe(CarType.SPORT)
            .engineCapacityShouldBe(3.0f);
    }
​
}


Spójrzmy teraz na wykorzystanie naszej klasy pomocniczej w teście. Co daje nam takie rozwiązanie?

  1. Dzięki poprawnej nazwie mamy jasno określony cel testu, zdefiniowane założenia i od razu wiemy, co ten test weryfikuje.
  2. Poprzez ukrycie kodu technicznego zyskujemy przyjazny dla człowieka opis tego co się dzieje i jak ma działać. Co za tym idzie – mamy dokumentację, żeby zrozumieć kod jakim jest nasza fabryka, wystarczy zerknąć do testów i przeczytać jak to działa.


Oczywiście nasze testy zostały przygotowane tak, aby można było ponownie użyć metod z CarFactoryTestBuildera po to, aby można było zweryfikować inne typy samochodów, które jesteśmy w stanie stworzyć bez większych zmian w kodzie.


Dodatkowa informacja: Analizowany przykład został maksymalnie uproszczony na potrzeby artykułu. W projektach komercyjnych nie tak łatwo jest wprowadzić tego typu testy. Najczęściej powodem trudności z wprowadzeniem tego typu testów jest źle napisana klasa, którą chcemy przetestować. Problemy z napisaniem testu powinny dać nam jasny sygnał, że być może jest coś nie tak z naszą klasą biznesową i może ona wymagać dodatkowego przemodelowania. Natomiast pisząc nowy kod, powinniśmy pamiętać, aby był on łatwy do przetestowania.

Autorzy tekstu

Mateusz Mnich - Software Engineer w Software Mind part of Ailleron Group
Wojciech Leniar - Software Engineer w Software Mind part of Ailleron Group

<p>Loading...</p>