#3 Recepta na – Unit Test + Object Builder

Dzisiejsza recepta będzie dotyczyła obiektów i sposobu ich konstruowania na potrzeby testów jednostkowych. Zastosowany wzorzec nie ogranicza się tylko do projektów testowych i jak najbardziej można go używać w innych warstwach.

Builder Design Pattern

Jak głosi wikipedia i inne źródła wzorzec budowniczy (prawda, że fajne tłumaczenie przyjęto 😃) wspomaga konstruowanie obiektów. Pozwala odseparować reprezentację obiektu (pola, właściwości) od pocesu jego konstrukcji. Tyle z teorii.

Kiedy używać

Wtedy kiedy w wielu miejscach w aplikacji tworzymy tą samą instancję obiektu o podobnych właściwościach. Mówiąc wprost jeśli widzisz, że klepiesz po raz n-ty ten sam kod aby utworzyć jakiś obiekt to jest to dobra wskazówka aby rozważyć ten wzorzec. Najczęściej ta sytuacja ma miejsce w testach jednostkowych gdzie każdy test jest wykonywany w izolacji i z założenia powinien operować na własnych instancjach obiektu.

Kodzimy

Na początek model obiektu, który posiada wiele właściwości – jest złożony. Klasyczny przykład zamówienia, które zawiera informację o kliencie, metodzie wysyłki i przedmiotach zakupu.

public class Order
{
    public OrderInfo OrderInfo { get; private set; }
    public ShipmentMethod Shipment { get; private set; }
    public ICollection<OrderItem> Items { get; private set; }
}
public class OrderInfo
{
    public Customer Customer { get; private set; }
}

public class Customer
{
    public Address Addres { get; private set; }
}

public class Address { }

public class OrderItem { }

public class ShipmentMethod { }
Utworzenie takiego obiektu od zera wiązałoby się z zainicjowaniem wielu pól i gdyby powtarzać tą czynność dla każdego testu powstało by wiele duplikowanego kodu. Taki kod określa się mianem „Boilerplate code” – takie obiekty potrzebujemy wielu miejscach (dla każdego testu) a nie wiele wnoszą. Dodatkowo poprzez brak enkapsulacji zmiana struktury którejś z klas Order, OrderInfo itd. skutkowała by poprawkami w wielu miejscach.

[Test]
public void My_First_Test()
{
    //Prepare some order
    var order = new Order();
    order.OrderInfo = new OrderInfo
    {
        Customer = new Customer
        {
            //fill customer properties ...
        }
    };
    order.Shipment = new ShipmentMethod
    {
        //fill shipment info ...
    };
    order.Items = new List<OrderItem>
    {
        //add some items to order ...
        new OrderItem { },
        new OrderItem{ }
    };
    //Act

    //Assert
}

Wyjście zastępcze

W niektórych przypadkach możemy dojść do wniosku, że nasz obiekt będzie wyglądał zawsze tak samo i nie potrzebujemy dodatkowych możliwości jego „konfiguracji”. Wtedy możemy utworzyć metodę, która wprost będzie zwracała taką instancję coś na wzór:

[Test]
public void My_First_Test()
{
    var order = GetSampleOrder();
    //Act

    //Assert
}

[Test]
public void My_Second_Test()
{
    var order = GetSampleOrder();

    //Act

    //Assert
}

private Order GetSampleOrder()
{
    //Prepare some order
    var order = new Order();
    order.OrderInfo = new OrderInfo
    {
        Customer = new Customer
        {
            //fill customer properties ...
        }
    };
    order.Shipment = new ShipmentMethod
    {
        //fill shipment info ...
    };
    order.Items = new List<OrderItem>
{
//add some items to order ...
new OrderItem { },
new OrderItem{ }
};

    return order;
}
 Jednak jeśli zaczniemy rozmnażać te metody bo jednak przyda się innaczej wyglądające zamówienie to wtedy czas na:

OrderObjectBuilder

W którym udostępnimy możliwość konstruowania różnych reprezentacji zamówienia. Dodatkowo ta implementacja będzie wykorzystywała tzw. „method chaining”. Pozwolę sobie na bardzo opisowe nazwy metod tak aby wprost pokazać wam ideę.

Krok #1

Utworzenie klasy OrderObjectBuilder z dwiem metodami:

  • CreateOrder() – tworzy instancje zamówienia – najprostsza możliwa reprezentacja. Zwróć uwagę na „return this”. To wspomniany „method chaining”. Zwracamy instancję klasy OrderObjectBuilder dzięki czemu będziemy mieli możliwość wywoływania kolejnych jej metod po „kropce” – przykład najlepiej to zaprezentuje.
  • Build() – metoda, która zwraca instancję zamówienia. Tą metodę wołamy na samym końcu.
public class OrderObjectBuilder
{
    private Order _order;
    public OrderObjectBuilder CreateOrder()
    {
        _order = new Order();
        return this;
    }

    public Order Build()
    {
        return _order;
    }
}

Przykład użycia:

OrderObjectBuilder _orderBuilder = new OrderObjectBuilder();

[Test]
public void My_First_Test()
{
    var myOrder = _orderBuilder
                    .CreateOrder()
                    .Build();
}

„Method chaining” umożliwił zawołanie metody Build() bezpośrednio po CreateOrder().

Krok #2

Dodaj kolejne metody, które posłużą do utworzenia twojego obiektu:

public OrderObjectBuilder WithCustomer()
{
    //create here sample customer
    return this;
}
public OrderObjectBuilder WithCustomer(Customer customer)
{
    _order.OrderInfo.Customer = customer;
    return this;
}

public OrderObjectBuilder WithShipment()
{
    //create sample shipment method
    return this;
}

public OrderObjectBuilder WithDPDShipment()
{
    //set DPD shipment
    return this;
}

public OrderObjectBuilder WithDHLShipment()
{
    //set DHL shipment
    return this;
}

public OrderObjectBuilder WithItem()
{
    //add sample item to order
    _order.Items.Add(new OrderItem());
    return this;
}

public OrderObjectBuilder WithItem(OrderItem orderItem)
{
    _order.Items.Add(orderItem);
    return this;
}

public Order Build()
{
    return _order;
}
 Dzięki takiej implementacji możemy tworzyć w prosty i czytelny sposób wiele przypadków testowych naszego zamówienia:

var myOrder = _orderBuilder
                    .CreateOrder()
                    .WithCustomer()
                    .WithDHLShipment()
                    .WithItem() //some random item
                    .WithItem(new OrderItem()) //particular order item
                    .Build();

var orderWithNoItems = _orderBuilder
                            .CreateOrder()
                            .WithCustomer()
                            .WithDPDShipment()
                            .Build();

var orderWithNoShipment = _orderBuilder
                            .CreateOrder()
                            .WithCustomer()
                            .WithItem()
                            .Build();

Podsumowanie

Dzięki wprowadzeniu ObjectBuilder’a:
  • Uzyskaliśmy separację reprezentacji obiektu od jego konstruowania
  • Pozbyliśmy się zbędnego, niewiele wnoszącego do przypadku testowego kodu
  • Enkapsulacja zabezpieczy nas przed przyszłymi zmianami
  • Method chaining wprowadził czytelne API