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ą:
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!
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);
}
}
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:
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.
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.
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?
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.
Mateusz Mnich - Software Engineer w Software Mind part of Ailleron Group
Wojciech Leniar - Software Engineer w Software Mind part of Ailleron Group