Wzorzec Builder do Budowania Danych Testowych
Pisząc testy jednostkowe, często potrzebujemy danych testowych. Częścią tych danych mogą być instancje jednej konkretnej klasy, ale różniące się wartościami właściwości w przypadku poszczególnych testów. Część właściwości będzie jednak wspólna lub obojętna dla większości testów. W celu uniknięcia powtarzania kodu w testach, warto zastosować jakiś automat – świetnie sprawdza się tu wzorzec Budowniczy (Builder).
Do przykładu!
Mamy następujący kod
Klasę Order
, która ma wszystkie właściwości wymagane – trzeba podać w konstruktorze.
public class Order { public Order(DateTime createdDate, decimal totalPrice, string currency, string description) { Id = Guid.NewGuid(); CreatedDate = createdDate; TotalPrice = totalPrice; Currency = currency; Description = description; } public Guid Id { get; } public DateTime CreatedDate { get; } public decimal TotalPrice { get; } public string Currency { get; } public string Description { get; } }
Następnie mamy serwis, który wykonuje jakieś akcje na obiekcie Order
. Jak widać, w różnych metodach różne właściwości obiektu Order
będą potrzebne
public class OrderService { private readonly decimal _vatPercentage; private readonly string _baseCurrency; public OrderService(decimal vatPercentage, string baseCurrency) { _vatPercentage = vatPercentage; _baseCurrency = baseCurrency; } public decimal CalculateTotalPrice(Order order) { if (order.TotalPrice == decimal.Zero) { throw new ArgumentException(); } return order.TotalPrice + (order.TotalPrice * _vatPercentage); } public decimal CalculateTotalPriceInBaseCurrency(Order order) { var totalPrice = CalculateTotalPrice(order); if (string.IsNullOrEmpty(order.Currency)) { throw new ArgumentException(); } var exchangeRate = GetDummyExchangeRate(order.Currency, _baseCurrency); return totalPrice * exchangeRate; } public static void SendEmail(Order order) { if (string.IsNullOrEmpty(order.Description)) { throw new ArgumentNullException(); } var title = $"Order {order.Id} placed {order.CreatedDate}"; var content = order.Description; DummySendEmail(title, content); } private static void DummySendEmail(string title, string content) { Console.WriteLine($"{title}. {content}"); } private static decimal GetDummyExchangeRate(string from, string to) { return 2m; } }
No i oczywiście nasze testy. Testujemy jeden serwis i jego 3 metody, w każdym teście potrzebny jest nieco inny Order
. Wygląda na to, że w każdym teście powtarzamy częściowo wywołanie konstruktora, podając dane, które dla danego testu są obojętne. Np. TotalPrice
nie ma żadnego znaczenia przy testowaniu metody SendMail
.
[TestClass] public class OrderServiceTests { private readonly OrderService _orderService; public OrderServiceTests() { _orderService = new OrderService(0.23m, "PLN"); } [TestMethod] public void CalculateTotalPrice() { var order = new Order(DateTime.Now, 100m, "PLN", ""); const decimal expectedTotalPrice = 123m; var actualTotalPrice = _orderService.CalculateTotalPrice(order); Assert.AreEqual(expectedTotalPrice, actualTotalPrice); } [TestMethod] public void CalculateTotalPriceInBaseCurrency() { var order = new Order(DateTime.Now, 100m, "USD", ""); const decimal expectedTotalPrice = 246m; var actualTotalPrice = _orderService.CalculateTotalPriceInBaseCurrency(order); Assert.AreEqual(expectedTotalPrice, actualTotalPrice); } [TestMethod] public void SendMail() { var order = new Order(DateTime.Now, 100m, "PLN", "This is description of the order"); // Assert not throws OrderService.SendEmail(order); } }
Jak użyć Buildera, żeby trochę poprawić sytuację? Potrzebujemy zdefiniować w nim jakieś domyślne wartości i metody, które pozwolą utworzyć instancję ze zmodyfikowanymi konkretnymi właściwościami.
Oto jak działa nasz Budowniczy.
- Właściwości, które są nam totalnie obojętne (np.
CreatedDate
) możemy podać w konstruktorze klasyOrder
w metodzieBuild
. - Właściwości, nad którymi chcemy mieć kontrolę będziemy przypisywać ze zmiennych przechowywanych w Builderze. W konstruktorze Buildera ustawimy je na jakieś domyślne, a w razie potrzemy będzie je można zmodyfikować metodami
With...
, w których podajemy żądaną wartość.
public class OrderBuilder { private decimal _totalPrice; private string _currency; private string _description; public OrderBuilder() { _totalPrice = 10m; _currency = "USD"; _description = ""; } public OrderBuilder WithTotalPrice(decimal totalPrice) { _totalPrice = totalPrice; return this; } public OrderBuilder WithCurrency(string currency) { _currency = currency; return this; } public OrderBuilder WithDescription(string description) { _description = description; return this; } public Order Build() { return new Order(DateTime.Now, _totalPrice, _currency, _description); } }
Ok, a teraz nasze testy po małej refaktoryzacji z użyciem buildera. Nie musimy już przejmować się obojętnymi nam właściwościami wywołując za każdym razem konstruktor. Zamiast tego budujemy sobie order z interesującymi nas wartościami dla danych właściwości.
public class OrderBuilder { private decimal _totalPrice; private string _currency; private string _description; public OrderBuilder() { _totalPrice = 10m; _currency = "USD"; _description = ""; } public OrderBuilder WithTotalPrice(decimal totalPrice) { _totalPrice = totalPrice; return this; } public OrderBuilder WithCurrency(string currency) { _currency = currency; return this; } public OrderBuilder WithDescription(string description) { _description = description; return this; } public Order Build() { return new Order(DateTime.Now, _totalPrice, _currency, _description); } }
Oczywiście prawdziwą moc Budowniczy pokazuje dopiero przy bardziej skomplikowanych implementacja. Tutaj starałem się to pokazać na w miarę prostym przykładzie, żeby zobrazować ideę.
Podsumowując
Na koniec kilka dobrych praktyk związanych z tym rozwiązaniem:
- Domyślne dane powinny być w miarę możliwości prawidłowe. W moim przykładzie ustawiłem sensowne
TotalPrice
(10) iCurrency
(USD), ale złamałem tę zasadę przypisując pusty string doDescription
. - Dodawaj tylko te metody rozszerzające (
With...
), których potrzebujesz. Jeśli dla danego przypadku (tutaj dla testów) jedna wartość jest zawsze ok, to ustaw ją jako domyślną. Ja np. mógłbym tutaj podać jako domyślną wartośćTotalPrice
100, a nie 10, bo w obu testach, gdzie potrzebujęTotalPrice
mogę użyć tej samej wartości. - Staraj się w każdym teście używać nowej instancji Buildera. Zastosowanie jednej instancji dla całej klasy testowej może spowodować, że jeden test zmodyfikuje dane testowe innego testu, czym złamiemy zasadę izolacji testów.
Daj znać, czy używasz Buildera do testów? Może masz jakieś podpowiedzi, co jeszcze można robić lepiej?
Dodaj komentarz
Musisz się zalogować, aby móc dodać komentarz.