by jon kruger. when calculating the total price of an order, add the price of the products in the...
TRANSCRIPT
Writing Testable Code
by Jon Kruger
Implement this codeWhen calculating the total price of an order,add the price of the products in the order, the tax, and the shipping charges.
Tax rate in Ohio = 7%, Michigan = 6.5%, other states = 0%. Can ship to US only.
Shipping charges = $5 if cost of products is less than $25, otherwise shipping is free.
Implementation without tests
public class OrderProcessor{ public decimal CalculateTotalPrice(int orderId) { // load order from database var order = Database.GetOrder(orderId); var totalPriceOfAllProducts = order.Products.Sum(p => p.Price);
// calculate tax decimal tax = 0; if (order.State == "OH") tax = totalPriceOfAllProducts * .07m; else if (order.State == "MI") tax = totalPriceOfAllProducts * .065m;
// calculate shipping decimal shippingCharges = 0; if (totalPriceOfAllProducts < 25) shippingCharges = 5;
return totalPriceOfAllProducts + tax + shippingCharges; }}
First attempt at a test[TestFixture]public class OrderProcessorTests{ private decimal _totalPrice; private Order _order;
[Test] public void CalculateTotalPrice() { Given_an_order(); When_calculating_the_total_price_of_an_order(); Then_the_total_price_of_the_order_should_be(15.70m); }
[TearDown] public void Cleanup() { Database.DeleteOrder(_order); }
private void Given_an_order() { _order = new Order { Id = 1, State = "OH", Products = new List<Product> { new Product {Price = 10} } }; Database.SaveOrder(_order); }
private When_calculating_the_total_price_of_an_order() { _totalPrice = new OrderProcessor().CalculateTotalPrice(1); }
private Then_the_total_price_of_the_order_should_be(decimal amount) { _totalPrice.ShouldEqual(amount); }}
Test cases neededWe have to account for the following scenarios when writing our tests:
Tax (51 possibilities)Orders can be shipped to 51 states (50 states + DC)
Shipping (3 possibilities)Order total is < 25Order total is 25 exactlyOrder total is > 25
Loading Order from the database (1 possibility)Return the sum of products, tax, and shipping (1 possibility)
This means that we have 153 different combinations to test!
Let’s break this downWhat if we tested each individual piece of the order total calculating process in isolation?
Test tax calculation in each state (51 tests)Test shipping calculation (3 tests)Test that Order can be loaded from the database (1 test)Test that return value is price of products + tax + shipping (1 test)
Now we’re down to 56 test cases from 153 test cases!
public class OrderProcessor{ public decimal CalculateTotalPrice(int orderId) { // load order from database var order = Database.GetOrder(orderId); var totalPriceOfAllProducts = order.Products.Sum(p => p.Price);
// calculate tax decimal tax = new TaxCalculator().CalculateTax(order);
// calculate shipping decimal shippingCharges = new ShippingCalculator().CalculateShipping(order);
return totalPriceOfAllProducts + tax + shippingCharges; }}
public class OrderProcessor{ private readonly TaxCalculator _taxCalculator; private readonly ShippingCalculator _shippingCalculator;
public OrderProcessor(TaxCalculator taxCalculator, ShippingCalculator shippingCalculator) { _taxCalculator = taxCalculator; _shippingCalculator = shippingCalculator; }
public decimal CalculateTotalPrice(int orderId) { // load order from database var order = Database.GetOrder(orderId); var totalPriceOfAllProducts = order.TotalPriceOfAllProducts;
// calculate tax decimal tax = _taxCalculator.CalculateTax(order);
// calculate shipping decimal shippingCharges = _shippingCalculator.CalculateShipping(order);
return totalPriceOfAllProducts + tax + shippingCharges; }}
Now maybe I could create test classes that derive from TaxCalculator and ShippingCalculator…
public class OrderProcessor{ private readonly ITaxCalculator _taxCalculator; private readonly IShippingCalculator _shippingCalculator;
public OrderProcessor(ITaxCalculator taxCalculator, IShippingCalculator shippingCalculator) { _taxCalculator = taxCalculator; _shippingCalculator = shippingCalculator; }
public decimal CalculateTotalPrice(int orderId) { // load order from database var order = Database.GetOrder(orderId); var totalPriceOfAllProducts = order.TotalPriceOfAllProducts;
// calculate tax decimal tax = _taxCalculator.CalculateTax(order);
// calculate shipping decimal shippingCharges = _shippingCalculator.CalculateShipping(order);
return totalPriceOfAllProducts + tax + shippingCharges; }}
Now I don’t have to worry about whether those dependencies have virtual methods.
public class OrderProcessor{ private readonly ITaxCalculator _taxCalculator; private readonly IShippingCalculator _shippingCalculator;
public OrderProcessor(ITaxCalculator taxCalculator, IShippingCalculator shippingCalculator) { _taxCalculator = taxCalculator; _shippingCalculator = shippingCalculator; }
public decimal CalculateTotalPrice(int orderId) { // load order from database var order = Database.GetOrder(orderId); var totalPriceOfAllProducts = order.TotalPriceOfAllProducts;
// calculate tax decimal tax = _taxCalculator.CalculateTax(order);
// calculate shipping decimal shippingCharges = _shippingCalculator.CalculateShipping(order);
return totalPriceOfAllProducts + tax + shippingCharges; }}
I still can’t stub out the database access… I can’t take a static class in as a constructor parameter!
public class OrderProcessor{ private readonly IGetObjectService<Order> _getOrderService; private readonly ITaxCalculator _taxCalculator; private readonly IShippingCalculator _shippingCalculator;
public OrderProcessor(IGetObjectService<Order> getOrderService, ITaxCalculator taxCalculator, IShippingCalculator shippingCalculator) { _getOrderService = getOrderService; _taxCalculator = taxCalculator; _shippingCalculator = shippingCalculator; }
public decimal CalculateTotalPrice(int orderId) { // load order from database var order = _getOrderService.Get(orderId); var totalPriceOfAllProducts = order.TotalPriceOfAllProducts;
// calculate tax decimal tax = _taxCalculator.CalculateTax(order);
// calculate shipping decimal shippingCharges = _shippingCalculator.CalculateShipping(order);
return totalPriceOfAllProducts + tax + shippingCharges; }}
I removed the static class and replaced it with a non-static class hidden behind an interface.
We just refactored untestable code and made it testable!
What is testable code?Testable code is code that can we can test using a unit test instead of an integration testProvides a way to substitute fake objects for classes that the class that we’re testing depends onConsistent results on every test runManual configuration is not needed before test runOrder of tests do not matterMust be able to run only some of the testsTests must run fast
Rules for writing testable code
Rule #1: Don’t new up dependencies
Rule #2: Don’t do real work in constructors
Rule #3: Don’t expose static
anything
Rule #4: Don’t expose singletons
Rule #5: Entity objects should not
have external dependencies
Rule #6: Follow the Law of Demeter
The Law of Demeter states that a method of an object may only call methods of:
1) The object itself. 2) An argument of the method. 3) Any object created within the method. 4) Any direct properties/fields of the object.
Rule #6: Follow the Law of Demeter
public class OrderDisplayService{ private readonly IOrderProcessor _orderProcessor;
public OrderDisplayService(IOrderProcessor orderProcessor) { _orderProcessor = orderProcessor; }
public void ShowOrderDetails(Order order) { if (!_orderProcessor.UserAuthenticationService.IsAuthenticated) { throw new InvalidOperationException( "not logged in"); } // do more stuff }}
Stubbing “IsAuthenticated” in a test would be difficult.
Rule #6: Follow the Law of Demeter
public class OrderDisplayService{ private readonly IOrderProcessor _orderProcessor;
public OrderDisplayService(IOrderProcessor orderProcessor) { _orderProcessor = orderProcessor; }
public void ShowOrderDetails(Order order) { if (!_orderProcessor.IsAuthenticated) { throw new InvalidOperationException( "not logged in"); } // do more stuff }}
Encapsulate “IsAuthenticated” inside IOrderProcessor.
Rule #6: Follow the Law of Demeter
public class OrderDisplayService{ private IOrderProcessor _orderProcessor; private IUserAuthenticationService _userAuthenticationService;
public OrderDisplayService(IOrderProcessor orderProcessor, IUserAuthenticationService userAuthenticationService) { _orderProcessor = orderProcessor; _userAuthenticationService = userAuthenticationService; }
public void ShowOrderDetails(Order order) { if (!_userAuthenticationService.IsAuthenticated) { throw new InvalidOperationException( "not logged in"); } // do more stuff }}
RecapDon’t new up dependenciesDon’t do real work in constructorsDon’t expose static anythingDon’t expose singletonsEntity objects should not have external dependenciesFollow the Law of Demeter
?