Pisząc testy, staramy się szukać części wspólnych i zamykać powtarzalne fragmenty. Jednym z tych miejsc jest tworzenie danych testowych. Łatwo można sobie wyobrazić sytuację, w której w wielu testach powtarzany jest fragment kodu:
Basket basket = new Basket();
Product product = Product.builder()
.productId(UUID.randomUUID())
.name("The product")
.price(BigDecimal.TEN)
.build();
basket.insert(product);
Powoduje to pewne komplikacje:
- Kod jest powielany. Zmiany w konstrukcji obiektów dotykają wiele testów.
- Model wcale nie musi być spójny pomiędzy testami. Będąc spójnym mam na myśli poprawne ustawienie pewnych parametrów z punktu widzenia stanu obiektów w naszej domenie. Nadpisanie pewnych cech może pozbawić dane sensu w ogóle.
- Testy przestają być czytelne. Nie zawierają informacji naprawdę istotnych z punktu widzenia przypadku testowego. Zamiast tego tworzy się szum informacyjny tylko po to, aby utworzyć dane testowe.
Piguła wiedzy o najlepszych praktykach testowania w Java
Pobierz za darmo książkę 100 stron o technikach testowania w Java
Object Mother
Ten problem próbuje rozwiązać Object Mother pattern. Jest to nic innego jak zestaw fabryk tworzących obiekty. Mogą to być statyczne metody klasy, np.
Baskets.emptyBasket()
– pusty koszykBaskets.withSingleProduct()
– koszyk z jednym produktemBaskets.abadonedBasket()
– porzucony koszyk
Podejście ma szereg zalet:
- Kod samego testu jest o wiele bardziej czytelny, tworzenie obiektu jest przeniesione w inne miejsce.
- Przykłady danych testowych są nazwane, od razu wiadomo, z czym mamy do czynienia.
- Ewentualne zmiany w tworzeniu obiektów dotyczą mniejszej liczby miejsc.
Z biegiem czasu natomiast klasa Object Mother nie skaluje się dobrze w kontraście do zmienności danych w przypadkach testowych. Powstają coraz to nowsze przypadki w różnych kombinacjach, łącząc cechy nawzajem:
Baskets.withProducts(...)
– z produktamiBaskets.withProductsAndDiscount()
– z produktami i zniżkąBaskets.withoutDeliveryAddressButWithDiscount()
– bez (jeszcze) podanego adresu dostawy i ze zniżką
Dostrzegamy kolejne części wspólne, parametryzujemy, refaktoryzujemy. Gdy brakuje czasu, istnieje pokusa, aby wykorzystywać, już gotowe obiekty i dodatkowo mockować ich zachowania:
Basket basket = Baskets.withProductsAndDiscount();
given(basket.abadoned()).willReturn(true);
Co do zasady mockujemy rzeczy, które z punktu widzenia testu chcemy wyizolować lub nie mamy nad nimi kontroli. Pytanie natomiast, czy nasz własny model domenowy jest nie do okiełznania, że musimy wyizolować zachowanie metod?
Posiadając w projekcie bogatą domenę, zaczynamy enkapsułować pewne koncepcje w agregaty, value objecty, doprowadzamy obiekty do pewnych stanów, używając metod. Można dojść do wniosku, że tworzenie danych testowych jest całkiem uciążliwe.
Test Builders
Ten problem adresuje Test Builder pattern. Koncepcja jest prosta – tworzymy klasę buildera, która daje możliwość nadpisania konstrukcji obiektu. Implementacja może wyglądać tak:
public class BasketsBuilder {
private List<Product> products = singletonList(defaultProduct().build());
private String param1;
public static BasketsBuilder builder() {
return new BasketsBuilder();
}
public BasketsBuilder withProducts(List<Product> products) {
products = products;
return this;
}
public BasketsBuilder withSingleProducts(Product product) {
products = singletonList(product);
return this;
}
public BasketsBuilder withoutProducts() {
products = emptyList();
return this;
}
public BasketsBuilder withParam1(String value) {
param1 = value;
return this;
}
public Basket build() {
Basket basket = new Basket(param1);
products.stream().forEach(basket::insert);
return basket;
}
}
Koncepcja polega na tym, aby umożliwić nadpisanie dowolnego parametru, a następnie doprowadzić obiekt domenowy do oczekiwanego stanu w metodzie build().
Nic nie stoi na przeszkodzie, aby mieć już nazwane przypadki testowe i zamiast tworzyć dany obiekt, umożliwić dalsze nadpisywanie wartości dla kolejnych testów, np.
Baskets.withSingleItem() // zwraca BasketsBuilder
.withParam1(value)
.build();
Automatyzajca pisania test builderów
Trzeba jednak powiedzieć, że pisanie test builderów to spory kawałek kodu, w dodatku powtarzalny. Każdy parametr do nadpisania to:
- Pole w klasie buildera.
- Metoda nadpisująca.
Problem rozwiązuje biblioteka Make-it-easy. Spójrzmy na przykład budowniczego produktu:
import com.natpryce.makeiteasy.Instantiator;
import com.natpryce.makeiteasy.Property;
public class ProductsTestBuilder {
public static final Property<Product, UUID> ProductId = newProperty();
public static final Property<Product, String> Name = newProperty();
public static final Property<Product, BigDecimal> Price = newProperty();
public static final Instantiator<Product> BasicProduct = (lookup) -> {
return Product.builder()
.productId(lookup.valueOf(ProductId, UUID.randomUUID()))
.name(lookup.valueOf(Name, "Soap"))
.price(lookup.valueOf(Price, BigDecimal.TEN))
.build();
};
}
Przykładowe użycie w teście wygląda całkiem dobrze, naturalnie się czyta „make a basic product”.
// static import ProductsTestBuilder.BasicProduct
Product product = make(a(BasicProduct));
Gdy chcemy nadpisać wartości, również wyrażamy się prozą: „make a basic product but with price 24”.
// static import ProductsTestBuilder.BasicProduct
// i ProductsTestBuilder.Price
Product product = make(a(BasicProduct).but(
with(Price, new BigDecimal(24))));
Brzmi nieźle?
Cały koncept opiera się na tym, że:
- Tworzymy statyczne pola z pojemnikami na nawpisywanie danych, przez co nie trzeba pisać metod w test builderach:
public static final Property Price = newProperty();
- Tworzenie obiektu zamknięte jest w lambdzie:
BasicProduct = (lookup) -> {}
w której budujemy obiekt i uzupełniamy go o wartości nadpisane, podając wartość domyślną, np..price(lookup.valueOf(Price, BigDecimal.TEN))
- Budowanie obiektu w lambdzie odbywa się na dowolny sposób – czy jest to tworzenie przez konstruktor, czy przez wywołanie kilku metod. Na koniec otrzymujemy nowy obiekt.
Inne zalety test builderów
Konwersje danych
Dość często w naszych projektach konwertujemy struktury danych, np. z warstwy domenowej na obiekty pośrednie DTO. Dajmy na to prezentujemy adres dostawy z obiektu domenowego do DTO:
@Data
@Builder
public class AddressView {
private String country;
private String city;
private String street;
private String number;
public static AddressView fromAddress(Address address) {
return AddressView.builder()
.country(address.getCountry())
.city(address.getCity())
.street(address.getStreet())
.number(address.getNumber())
.build();
}
}
Domyślne wartości można wyeksponować do stałych Test Buildera i używać go w innych.
Tworzymy dwa test buildery (jeden dla obiektu domenowego Address, kolejny dla DTO AddressView
), a domyślne wartości test buildera dla AddressView
otrzymujemy takie same jak dla domenowego Address
.
Co nam to daje? Już nigdy więcej kodu typu drabina assercji pole po polu:
@Test
void convertsAllFields() {
Address address = make(a(AddressTestBuilder.AnyAddress));
AddressView view = AddressView.fromAddress(address);
assertThat(view.getCountry()).isEqualTo(address.getCountry());
assertThat(view.getCity()).isEqualTo(address.getCity());
assertThat(view.getStreet()).isEqualTo(address.getStreet());
assertThat(view.getNumber()).isEqualTo(address.getNumber());
}
Od teraz można porównywać obiekty zbudowane na podstawie tych samych danych (jeden test builder korzysta z danych innego):
@Test
void convertsAllFields() {
Address address = make(a(AddressTestBuilder.AnyAddress));
AddressView view = AddressView.fromAddress(address);
AddressView expected = make(a(AddressViewTestBuilder.AnyAddress));
assertThat(view)
.usingRecursiveComparison()
.isEqualTo(expected);
}
Powyższy kod porównuje AddressView
stworzony przez konwersję z przykładowym obiektem z test buildera pole po polu. Rozwiązanie ma zasadniczą zaletę, że gdy dodane zostałoby pole do obiektu transportowego AddressView
i:
- albo przy konwersji pole zostałoby przepisywane bez zmiany test buildera,
- albo zostałby uaktualniony test builder, ale nie konwerter
Wtedy test zasygnalizuje błąd.
Stosując drabinę assertów nie sprawdzimy, czy w konwertowanym obiekcie znajdują się nadmiarowe nieoczekiwane wartości.
Wpis który czytasz to zaledwie fragment wiedzy zawartej w Programie szkoleniowym Java Developera od SoftwareSkill. Mamy do przekazania sporo usystematyzowanej wiedzy z zakresu kluczowych kompetencji i umiejętności Java Developera. Program składa się z kilku modułów w cotygodniowych dawkach wiedzy w formie video.
Zakończenie
Podsumowując:
- Nazwane przypadki testowe zwiększają czytelność kodu i ograniczają liczbę miejsc do ew poprawy w przypadku zmiany sposobu tworzenia obiektów.
- Tworzenie danych testowych może prowadzić do duplikacji, a Matki Obiektów nie są odporne na zmienność danych w przypadkach testowych.
- Rozwiązaniem są Test Buildery umożliwiające ingerowanie w proces budowania przykładowego obiektu i ustawienie interesujących nas cech przypadku testowego.
- Tworzenie Test Builderów można automatyzować, np. za pomocą Make-it-easy.
Piguła wiedzy o najlepszych praktykach testowania w Java
Pobierz za darmo książkę 100 stron o technikach testowania w Java
Gdybyś potrzebował jeszcze więcej:
Jesteś Java Developerem?
Przejdź na wyższy poziom wiedzy
„Droga do Seniora” 🔥💪
Jesteś Team Leaderem? Masz zespół?
Podnieś efektywność i wiedzę swojego zespołu 👌
Szybkie podsumowanie
Przenoś tworzenie danych testowych do osobnych klas i metod. Pomogą Ci w tym Matki Obiektów i Test Buildery.
Jest to zestaw fabryk tworzących obiekty. Mogą to być statyczne metody klasy, np:Baskets.emptyBasket()
– pusty koszykBaskets.withSingleProduct()
– koszyk z jednym produktemBaskets.abadonedBasket()
– porzucony koszyk
Podejście ma szereg zalet:
1. Kod samego testu jest o wiele bardziej czytelny, tworzenie obiektu jest przeniesione w inne miejsce.
2. Przykłady danych testowych są nazwane, od razu wiadomo, z czym mamy do czynienia.
3. Ewentualne zmiany w tworzeniu obiektów dotyczą mniejszej liczby miejsc.
Test Builder to klasa buildera, która daje możliwość nadpisania konstrukcji obiektu. Koncepcja polega na tym, aby umożliwić nadpisanie dowolnego parametru, a następnie doprowadzić obiekt domenowy do oczekiwanego stanu w metodzie build().
Literatura
- https://martinfowler.com/bliki/ObjectMother.html
- http://www.natpryce.com/articles/000714.html
- https://reflectoring.io/objectmother-fluent-builder/
Obrazek: Tło plik wektorowy utworzone przez freepik – pl.freepik.com