Hackle's blog
between the abstractions we want and the abstractions we get.
Unit testing can be made cheap and painless, or even joyful if code is written with a conscious segregation of complexity.
The idea is very simple: separate complex logic to simpler units, and make the integration of these units trivial.
ShoppingController
Let's look at an example, a ShoppingController
for an online shopping application. To calculate total sum to pay for a list of items, the business logic is as follows:
public class ShoppingController : BaseController
{
public ShoppingController(ILoggingService loggingService, IStockService stockService)
{
// ...
}
public decimal CalculateTotalPayable(IEnumerable<Item> items, Membership member, string promoCode)
{
var memberDiscountPercentage = 0;
switch (member.Type)
{
case MemberType.Diamond:
memberDiscountPercentage = 10;
break;
case MemberType.Gold:
memberDiscountPercentage = 5;
break;
default:
break;
}
var promoDiscountPercentage = 0;
if (promoCode == "akaramba")
{
promoDiscountPercentage = 8;
}
else if (promoCode == "excellent")
{
promoDiscountPercentage = 6;
}
var discountToApply = Math.Max(memberDiscountPercentage, promoDiscountPercentage);
var totalPayable = items.Sum(item => {
if (item.IsDiscountable)
return item.Price * (1.0 - discountToApply / 100);
else
return item.Price;
});
this.loggingService.Log(LogLevel.Info, $"logging that member XYZ gets prices for ABC with dicount so and so");
return totalPayable;
}
// ... other methods
}
As an example this may seem unnecessarily complicated - but we all know it's nowhere as complicated as real world applications.
There should be no doubt CalculateTotalPayable
needs to be unit tested thoroughly, because it's quite important to a shopping application, and it's fairly complex (and can grow much more so).
CalculateTotalPayable
Let's get a feel of the complexity of the method in light of unit testing.
ILoggingService
and IStockService
, mocks
for these interfaces are to be created to construct the controller. Although CalculateTotalPayable
has no use for IStockService
. (injecting interfaces is questionable too - see also inject functions, not interfaces)Item
can be discountedI'll have to confess I am not looking forward to unit testing this method:
3 x 3 x 2 = 18
test cases to fully cover this method. (and remember real-life applications can be much more complex!)CalculateTotalPayable
, all the 18 tests will break and need fixing.ILoggingService.Log
really is trivial - but it also needs to be stubbed or tests will fail.This is typically the moment teams decide that it's not worthwhile to write any unit test, as developers explain how complicated, time-consuming it is, and how annoying maintenance will be, "although it would be the right thing to do", everybody agrees with regret.
This is unfortunate and seems paradoxical: complex code is not tested because it's complex, but what's the point of testing if we only test simple code?
But hold on team! This doesn't have to be the case. We'll see how we can move some code around and make unit testing a breeze. It won't take long - just 2 very simple steps, at most.
Looking closely at CalculateTotalPayable
, it's clear that it's made up of 3 steps:
So step 1 is to move each step into a separate static class - not interfaces, because:
So we create three separate static classes as follows, to start with discount based on member types:
public static class MemberDiscount
{
public static int GetInPercentage(Membership member)
{
switch (member.Type)
{
case MemberType.Diamond: return 10;
case MemberType.Gold: return 5;
default: return 0;
}
}
}
Let's unit test it,
[Theory]
[InlineData(MemberType.Diamond, 10)]
[InlineData(MemberType.Gold, 5)]
[InlineData(MemberType.Respected, 0)]
public void Gets_discounts_per_type_of_membership(
MemberType memberType,
int expectedDiscount)
{
var member = new Membership { Type = memberType };
var actual = MemberDiscount.GetInPercentage(member);
Assert.Equal(actual, expectedDiscount);
}
It doesn't get any easier - only a matter of giving input and expecting output! I consider this type of unit tests data-driven. Such is the beauty of static methods aka pure functions.
Same goes for the other two classes.
public static class PromoCodeDiscount
{
public static int GetInPercentage(string promoCode)
{
if (promoCode == "akaramba")
{
return 8;
}
else if (promoCode == "excellent")
{
return 6;
}
return 0;
}
}
public static class ItemSalePrice
{
public static decimal Calculate(
Item item,
decimal memberDiscountPercentage,
decimal promoDiscountPercentage)
{
var discountToApply = Math.Max(memberDiscountPercentage, promoDiscountPercentage);
if (item.IsDiscountable)
return item.Price * (1.0 - discountToApply / 100);
else
return item.Price;
}
}
I'll leave the joy of testing these two methods to you. Here is the challenge: if the method body of each unit test is longer than 3 lines, you are not doing it right.
Finally CalculateTotalPayable
has a new look:
public decimal CalculateTotalPayable(IEnumerable<Item> items, Membership member, string promoCode)
{
var memberDiscountPercentage = MemberDiscount.GetInPercentage(member);
var promoDiscountPercentage = PromoCodeDiscount.GetInPercentage(promoCode);
var totalPayable = items.Sum(item => ItemSalePrice.Calculate(item, memberDiscountPercentage, promoDiscountPercentage));
this.loggingService.Log(LogLevel.Info, $"logging ...");
return totalPayable;
}
You'd be surprised - we are done, there is not really a step 2. Except maybe, deleting any unit tests previously written for the original CalculateTotalPayable
?
And yes, you guessed right - we are not going to unit test CalculateTotalPayable
, because it's now trivial.
Look at it. There is no branching of logic in this method any longer, it's made up of simple (almost boring) statements and expressions. There is but a single path through this method, or in other words, its Cyclomatic complexity is 1.
Unit testing such methods are still very possible, but usually uninteresting and almost pointless.
Taking this style of coding and unit testing one step further, we arrive at a pattern that features:
a type of classes that only integrate other code units, but are devoid of complexity themselves. Examples of such classes: controllers in MVC, presenters in MVP, services in service oriented architecture, or components in some front-end frameworks. They have a very low level of complexity and therefore require no unit testing.
complexity is separated into code units with specific responsibilities. These classes / functions are where the logic of the application lives and should be thoroughly unit tested. They are preferably static / pure, so unit testing becomes data-driven, or, a matter of coming up with representative (if not complete) sets of input and expected output.
Let's call this the Integrator pattern.
The software industry is notorious for complicating things that should otherwise be simple. Some of the more popular practices for unit testing is just another example.
Unit testing is key to productivity especially when dealing with complexity. However, because of poor practices it has a bad name of being expensive: hard to write, fragile and easily broken, a nightmare to maintain.
The matter is made worse by some popular frameworks that endorse practices that try to manage complexity with more complexity, such as built-in support for mocking, dependency injection etc. Now developers need to learn yet another new way to unit test, which does not help and compounds the problems more. (Yes I am looking at you, Angular).
The Integrator pattern, as can be seen above, handles complexity by looking right in its face, consciously breaking it up, putting it in separate places, and quite easily, we restore simplicity as well as the joy of unit testing.
It's language- or framework- agnostic, so we can all start using it now. Simplicity, the essence of programming, prevails. Happy hacking!