2012-04-10

Jak zjednodušit testy s buildery testovacích dat

Dlouhý a nepřehledný setup může zabít vaše testy. Čím delší setup, tím hůř se test čte a tím víc je náchylný na rozbití při nesouvisejících změnách. Buildery testovacích dat jsou užitečná pomůcka, která pomáhá setup testu zjednodušit.

Představme si hypotetickou situaci: Chceme otestovat, že objednávka nejde odeslat, pokud je celková cena vyšší, než zákazníkův předplacený kredit. Vytvořit všechny objekty pro takový test může být dost nepřehledné:

[Test]
public void OrderCannotBePlacedWhenCustomerBalanceIsLow()
{
    var order = new Order()
        {
            Customer = new Customer()
                {
                    Balance = 100,
                }
        };
    order.AddItem(new OrderItem()
        {
            Price = 101
        });
    ...
}

A to jsme zatím nastavili jen vlastnosti, které nás zajímají v testu. Často je ale nutné nastavit i další vlastnosti, které sice nejsou zajímavé z hlediska testu, který právě píšeme, ale bez nich test zhavaruje (typicky jsou to validace a databázové constraints (jak se to sakra řekne česky?) u integračních testů):

[Test]
public void OrderCannotBePlacedWhenCustomerBalanceIsLow()
{
    var order = new Order()
        {
            Customer = new Customer()
                {
                    Balance = 100,
                    // zákazník bez loginu není validní,
                    // login navíc musí být unikátní
                    LoginName = "neni_dulezite",
                    // zákazník musí patřit do společnosti
                    Company = new Company()
                        {
                            ...
                        }
                }
        };
    order.AddItem(new OrderItem()
        {
            Price = 101,
            // položka musí mít počet kusů, i když je to pro tento 
            // test zcela nepodstatné
            Items = 1, 
            // výchozí hodnota datumu v C# nejde uložit do MSSQL
            Ordered = DateTime.Now,
            // každá položka objednávky musí ukazovat na produkt
            Product = new Product()
        });
    ...
}

Takový test se hodně špatně čte. Vlastnosti důležité pro test zcela splývají s vlastnostmi, které jsou potřeba jen proto, aby kód prošel. Navíc je to hodně křehké a pokud máte takových testů hodně, pak si při refactoringu užijete hodně srandy (sarcasm).

Když použijeme buildery testovacích dat (test data builders), bude to vypadat o dost lépe:

[Test]
public void OrderCannotBePlacedWhenCustomerBalanceIsLow()
{
    var order = new OrderBuilder()
        .WithTotalPrice(101)
        .WithCustomer(
            new CustomerBuilder().WithBalance(100).Build()
        )
        .Build();
    ...
}

Test data builder je pomocná třída, jejímž úkolem je vytvořit validní objekt daného typu. Jeho metody mají fluent interface a umožňují nastavit právě a jen ty vlastnosti, které nás v testu zajímají. Většinou mají dost jednoduchou strukturu, například CustomerBuilder z předchozího příkladu může vypadat třeba takto:

public class CustomerBuilder
{
    static int _counter = 0;

    string _loginName;
    public CustomerBuilder WithLoginName(string loginName)
    {
        _loginName = loginName;
        return this;
    }

    decimal _balance = 0;
    public CustomerBuilder WithBalance(decimal balance)
    {
        _balance = balance;
        return this;
    }

    Company _company;
    public Company WithCompany(Company company)
    {
        _company = company;
        return this;
    }

    public Customer Build()
    {
        return new Customer()
            {
                Balance = _balance,
                LoginName = _loginName ?? "customer" + (_counter++),
                Company = _company ?? new CompanyBuilder().Build(),
                Items = 1,
                Ordered = DateTime.Now
            };
    }
}

Pro každou vlastnost, kterou chceme nastavovat z testu, obsahuje test data builder jednu metodu. Builder ale může obsahovat i metody, které nastavují víc než jednu vlastnost nebo vztah. Díky tomu, že takový setup uzavřeme do metody, dáme mu jméno, které v testu lépe vyjadřuje náš záměr. Například OrderBuilder obsahuje metodu WithTotalPrice(), i když Order žádnou takovou vlastnost nemá a celková cena se počítá jako součet všech položek objednávky:

public class OrderBuilder
{
    List<OrderItem> _items = new List<OrderItem>();

    public OrderBuilder WithTotalPrice(decimal totalPrice)
    {
        _items.Add(new OrderItemBuilder().WithPrice(totalPrice).Build());
        return this;
    }

    public Order Build()
    {
        var order = new Order();
        foreach (var item in _items)
        {
            order.AddItem(item);
        }
        return order;
    }
}

V našich projektech používáme tyto buildery v testech pro vytváření doménových objektů a DTO objektů. Protože máme doménu v DSL modelu, jsme dokonce schopni pro každou entitu generovat základní kostru builderu, která obsahuje pro každou vlastnost metodu (např. pokud má třída User vlastnost Name pak vygenerovaný UserBuilder bude mít metodu WithName). Tam, kde není možné buildery generovat, píšeme je postupně podle potřeby.

Naše buildery dokonce poznají, zda jsou použity v unit testu nebo v integračním testu s databází (vytváří je totiž base třída testu). V druhém případě pak vytvořené objekty rovnou persistují do databáze, takže nepotřebujeme žádné šílenosti jako SQL setup script pro každý test nebo databázi předem naplněnou nějakými testovacími daty. Každý integrační test začíná nad prázdnou databází a pomocí těchto builderů si vytvoří taková data, jaká potřebuje (ale o tom třeba někdy příště).

Test data builders jsou užitečná věc, která zpřehledňuje testy, zjednodušuje jejich psaní a činí je odolnější vůči změnám, protože dost často se změna projeví jen v builderu.

Další informace a triky najdete v knížce Growing Object-Oriented Software Guided by Tests. Pokud jste ji ještě nečetli, je to chyba kterou byste měli co nejdříve napravit, protože je to nejlepší knížka o testování, kterou znám:-) Příklady má v Javě, ale .NET je vlastně skoro to samé, takže jdou s mírnými úpravami použít i v C#. Tato kniha měla opravdu velký a praktický dopad na způsob, jakým dnes píšeme testy.

2 komentáře:

  1. constraints == omezení
    Jinak jsem díky článku zjistil, že používám "test data buildery", takže díky za rozšíření mého slovníku buzzwordů :-)

    OdpovědětVymazat
  2. Díky Dane za inspirativní článek. Aktuálně řešíme problematiku zjednodušení integračních testů. Aby samotné testy neměly složitou inicializaci v kódu, používáme databázové inicializační skripty. Není to však ideální řešení. Použití test data builderů vypadá hodně zajímavě a určitě ho vyzkoušíme.
    Chybu s nepřečtením doporučované knížky jsem se rozhodl napravit. Právě jsem investoval 20$ do Kindle verze ;)
    Tvůj blog je super! Těším se na další články.

    OdpovědětVymazat