Java Developer? Przejdź na wyższy poziom wiedzy 🔥💪  Sprawdź

Team Leader? Podnieś efektywność swojego zespołu 👌 Sprawdź

Wzorzec Test Data Builder – podnieś czytelność kodu testowego. Biblioteka make-it-easy

utworzone przez Java, Testowanie

Wprowadzenie

Kod testowy składa się z trzech części:

  1. Przygotowanie (given / arrange)
  2. Wykonanie (when / act)
  3. Sprawdzenie (then / assert)

Przygotowanie danych do wykonania testu wymaga stworzenia obiektów, wypełnienia konstruktorów, dostarczenia danych. Może być trudne w przypadku bardziej złożonych obiektów.

Oprócz stworzenia obiektów możliwe, że należy doprowadzić je do konkretnego stanu przechodząc przez operacje.

Kod w fazie given się powtarza pomiędzy testami i zaciemnia test.

Z tego artykułu dowiesz się:

  • Jak podnieść czytelność kodu testowego w fazie przygotowania danych testowych – given
  • Czym jest wzorzec Matki Obiektów
  • Czym jest wzorzec Test Data Builder
  • Jak tworzyć gotowe, nazwane biznesowo przypadki testowe, które można dodatkowo parametryzować na potzreby testu.

Wprowadzenie

Poniżej znajdziesz przykład, który sprawdza, czy koszyk zakupowy z dodanym produktem, po wyznaczeniu sumy koszyka wskazuje na cenę dodanych produktów:

@Test
@DisplayName("Calculates summary products price")
void calculatesSummaryProductsPrice() {
	// given
	var basket = new Basket();
	var product = Product.builder()
		.productId(PRODUCT_ID))
		.name("Item")
		.price(BigDecimal.TEN)
		.available(true)
		.build();

	// when
	basket.insert(product);

	// then
	assertThat(basket.getItemsTotalPrice())
			.isEqualTo(BigDecimal.TEN);
}

Poprawa czytelności testu w fazie given

Kod testu staje się nieczytelny. Tworzenie obiektów zaciemnia logikę testu, która powinna skupić się na sprawdzeniu ceny.

Extract method

Można nieco uprościć kod testu korzystając z bardzo prostej refaktoryzacji przeniesienia do metody (extract method):

@Test
@DisplayName("Calculates summary products price")
void calculatesSummaryProductsPrice() {
	// given
	var basket = givenBasketWithProducts(productWithPrice(BigDecimal.TEN));

	// then
	assertThat(basket.getItemsTotalPrice())
			.isEqualTo(BigDecimal.TEN);
}

static Basket givenBasketWithProduct(Product... products) {
	var basket = new Basket();
	
	Stream.of(products)
	    .forEach(basket::insert);
	
	return basekt;
}

static Product productWithPrice(BigDecimal price) {
	return Product.builder()
		.productId(PRODUCT_ID))
		.name("Item")
		.price(price)
		.available(true)
		.build();
}

Kod testu jest teraz bardziej czytelny.

Matki obiektów

Inną techniką są Matki Obiektów lub inaczej Fabryki Obiektów. Jeżeli dane testowe są wykorzystywane pomiędzy wieloma testami, można utworzyć klasę z metodami fabrykującymi konkretny obiekt, na przykład:

class Products {
	public static Product product() {
		return Product.builder()
			.productId(PRODUCT_ID))
			.name("Item")
			.price(BigDecimal.TEN)
			.available(true)
			.build();
	}
}

Tworzenie obiektów przez metody fabrykujące ma tę zaletę, że, przypadki te mogą być biznesowo nazwane: aktywny produkt, nieaktywny produkt, produkt z ceną X. Kiedy stosujemy nazwane obiekty, znacznie podnoszona zostaje czytelność testów, ponieważ wyrażamy intencję.

Jeżeli dla testów jest istotny jakiś parametr obiektu, na przykład możliwość dostosowania ceny, możemy dodać kolejną metodę:

class Products {
    // ...
	
	public static Product productWithPrice(BigDecimal price) {
		return Product.builder()
			.productId(PRODUCT_ID))
			.name("Item")
			.price(price)
			.available(true)
			.build();
	}
}

Kłopot pojawia się, gdy potrzebujemy nadpisać dwa parametry, albo trzy, albo tylko wybrane. Wtedy dochodzi do momentu, kiedy następuje eksplozja kombinatoryczna.

Test Data Builders

Do nadpisywania wielu cech obiektów idealnym zastosowaniem jest buider. Moglibyśmy ustawić kluczowe domyślne cechy i zwrócić builder, aby móc dodatkowo dostosować obiekt w teście na jego potrzeby.

Nie każdy obiekt jednak posiada builder, a pisanie go ręcznie może być uciążliwe, na przykład:

class ProductBuilder {
    private UUID productId;
    private String name;
    private BigDecimal price;
    private boolean available;
	
	public ProductBuilder withProductId(UUID productId) {
	    this.productId = productId;
		return this;
	}
	
	public ProductBuilder withName(String name) {
	    this.name = name;
		return this;
	}
	
	// ...
	
	public Product build() {
		return new Product(productId, name, price, available);
	}
}

Można skorzystać z anotacji @Builder z projektu Lombok, ale nie każdy obiekt ma taką możliwość.

A co, jeżeli powiem Ci, że buildery można generować?

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

Biblioteka Make-it-Easy

Biblioteka make-it-easy pozwala na konstrukcję w kodzie, która potrafi skonstruować i sparametryzować każdy obiekt – nawet ten, który nie posiada builderów.

Główne elementy konstrukcyjne to:

  • Instantiator – metoda fabrykująca obiekt
  • Property – reprezentuje dowolny atrybut obiektu, który można nadpisać
  • Maker – obiekt zawierający zestaw domyślnych wartości Property, może być nazwanym przykładem

Bibliotekę make-it-easy dołączysz do swojego projektu definiując zależność:

<dependency>
	<groupId>com.natpryce</groupId>
	<artifactId>make-it-easy</artifactId>
	<scope>test</scope>
</dependency>

Aby stworzyć obiekt w teście należy użyć składni:

var basket = make(an(EmptyBasket));

Albo nadpisując jakiś parametr, na przykład ID:

var basket = make(an(EmptyBasket)
		.but(with(BasketTestBuilder.BasketId, UUID.fromString("b9713fed-419d-4922-9481-506cb1141cf3"))));

Instantiator i Property

Aby stworzyć builder używasz składni:

public class BasketTestBuilder {

    public static final Property<Basket, UUID> BasketId = newProperty();

    public static Instantiator<Basket> EmptyBasket = (lookup) -> new Basket(lookup.valueOf(BasketId, UUID.randomUUID()));
}

Do stworzenia obiektu służy Instantiator. Podczas tworzenia obiektu możesz odwołać się do odpowiedniej cechy obiektu (Property) za pomocą składni: lookup.valueOf(property, wartość domyślna). Dzięki temu możesz nadpisywać dowolne atrybuty tworzonego obiektu.

Jeżeli stworzenie obiektu jest bardziej skomplikowane, na przykład należy doprowadzić obiekt do jakiegoś stanu, możesz napisać inny Instantiator. Na przykład koszyk z dodanymi produktami:

public class BasketTestBuilder {

    public static final Property<Basket, UUID> BasketId = newProperty();
    public static final Property<Basket, Collection<Product>> Products = newProperty();

    // ...
	
    public static Instantiator<Basket> BasketWithProducts = (lookup) -> {
        var basketId = lookup.valueOf(BasketId, UUID.randomUUID());
        var basket = new Basket(basketId);

        lookup.valueOf(Products, defaultProducts()).stream()
                .forEach(basket::insert);

        return basket;
    };

    private static Collection<Product> defaultProducts() {
        return asList(
                make(ProductTestBuilder.BookCleanCode),
                make(ProductTestBuilder.BookDDD)
        );
    }
}

Oraz użycie:

var basket = make(an(BasketWithProducts));

Oraz użycie z nadpisaniem produktów w koszyku:

var basket = make(an(BasketWithProducts)
    .but(with(BasketTestBuilder.Products, asList(make(BookCleanCode)))));

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

.

Maker

Aby stworzyć obiekt, używasz składni make( ... ). Argumentem metody make jest tzw. Maker.

Możesz użyć makera in-line, na przykład: make(a( ... )). Oznacza to, że nie nadpisujesz żadnego parametru. Jednocześnie możesz użyć makera in-line z napisaniem któregoś z nich, jak pokazano wcześniej, np: make(a( ... ).but(with(..., ...)).

Istnieje możliwość stworzenia nazwanych przypadków jako zestaw ustalonych atrybutów, na przykład: niedostępny produkt, produkt w kategorii X itd. Ja na swoje potrzeby stworzyłem produkty w postaci książek z ustalonymi atrybutami takimi jak tytuł i cena:

public static final Maker<Product> BookDDD = a(BasicProduct)
		.but(with(ProductId, UUID.fromString("c68698f4-4e9a-4302-a61c-efb78c6aff8d")))
		.but(with(Name, "Domain-Driven Design"))
		.but(with(Price, new BigDecimal("24.90")));

public static final Maker<Product> BookCleanCode = a(BasicProduct)
		.but(with(ProductId, UUID.fromString("1d67f74b-c1e3-4b36-8eaf-a26a07d88540")))
		.but(with(Name, "Clean Code"))
		.but(with(Price, new BigDecimal("29.90")));

Teraz mogę ich użyć wprost:

private static Collection<Product> defaultProducts() {
	return asList(
			make(ProductTestBuilder.BookCleanCode),
			make(ProductTestBuilder.BookDDD)
	);
}

Lub dalej nadpisując ich kolejne atrybuty, na przykład sprawiając, że jakiś produkt jest niedostępny:

var unavailableBook = make(BookCleanCode.but(with(Available, false)))

Mogę również po prostu stworzyć „jakiś” niedostępny produkt i nazwać ten przypadek:

public static final Maker<Product> UnavailableProduct = a(BasicProduct)
		.but(with(Available, false));

Zobacz teraz na kod testowy, który sprawdza, że nie można stworzyć zamówienia, jeżeli w koszyku istnieje jakiś już niedostępny produkt:

@Test
@DisplayName("Does not create order for basket with not active product")
void doesNotCreateOrderForBasketWithNotActiveProduct() {
	// given
	var basket = make(a(BasketWithProducts)
			.but(with(Products, asList(
					make(a(ProductTestBuilder.BasicProduct)),
					make(ProductTestBuilder.UnavailableProduct)
			))));
	givenBasketInRepository(basket);

	// when
	assertThatCode(() -> createOrderService.createOrder(basket.getBasketId()))
			.isInstanceOf(IllegalStateException.class)
			.hasMessage("Some product is not available.");
}

Czytelność kodu testowego jest duża, faza given nie koncentruje się na konkretnych danych i wartościach, a używa nazwanych biznesowo obiektów, które są ewentualnie dodatkowo parametryzowane.

Podsumowanie

Wzorzec Test Data Builder pozwala na stworzenie nazwanych przypadków testowych, które możemy parametryzować dostosowując dane pod konkretny przypadek testowy. Zwiększa to czytelność testu i skraca fazę przygotowania danych testowych – given.

Test Data Builder koncentruje tworzenie obiektów w jednym miejscu, przez co kod nie jest kopiowany po testach, a zmiana w metodzie konstrukcji nie pociąga za sobą konieczności refaktoryzacji całego w wielu miejscach.

Podoba Ci się ten artykuł? Weź więcej.

Jeżeli uważasz ten materiał za wartościowy i chcesz więcej treści tego typu – nie przegap ich i otrzymuj je prosto na swoją skrzynkę. Nawiążmy kontakt.

.

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.

Piguła wiedzy o najlepszych praktykach testowania w Java

Pobierz za darmo książkę 100 stron o technikach testowania w Java

Dyskusja