I saw this question and thought it looked like a great opportunity to try my hand at the Strategy Pattern. I've never used it before, but I think I did pretty well. Did I?
The code below can also be found on github. I'm using C# 6.0 for this, so if there are any opportunities to use new features that I missed, I'd like to know.
The Challenge
"...checkout that calculates the total price of a number of items. In a normal supermarket, things are identified using Stock Keeping Units, or SKUs. In our store, we’ll use individual letters of the alphabet (A, B, C, and so on). Our goods are priced individually. In addition, some items are multipriced: buy n of them, and they’ll cost you y cents. For example, item ‘A’ might cost 50 cents individually, but this week we have a special offer: buy three ‘A’s and they’ll cost you $1.30. In fact this week’s prices are:"
| Item Name | Price | Special Price | |:-----------|------------:|:------------: | | A | 50 | 3 for 130 | | B | 30 | 2 for 45 | | C | 20 | | | D | 15 | |
"Our checkout accepts items in any order, so that if we scan a B, an A, and another B, we’ll recognize the two B’s and price them at 45 (for a total price so far of 95). Because the pricing changes frequently, we need to be able to pass in a set of pricing rules each time we start handling a checkout transaction. "
The Approach
- I created a
Sku
struct for type safety and to allow myself to implement more realistic Skus down the road if I choose to. - The
Cashier
is instantiated with a a List of Pricing Strategies. - The
Cashier
is then responsible for checking out a list ofSku
s and returning the total price. - Each strategy implements a
IPricingStrategy
interface. - Two different base classes fell out of my TDD exercise. A "Regular" and a "Sale" strategy. Strategies for specific products inherit from one of these two base classes.
My Concerns
- Is my implementation of the strategy pattern correct?
- Right now, I would have to implement a new class for each sku. This would be impractical (to say the least) with hundreds of thousands of skus. Was the strategy pattern a bad choice? How could the code be made more resilient and re-usable over many skus that use the same strategy?
- I choose to use a custom
Sku
struct as a datatype for type safety, but my implementation doesn't feel like it did that well. It allows anychar
to be implicitly cast to aSku
. It feels like I defeated the purpose. Did I? If I did, how could I regain the type safety I was aiming for? - Did I properly implement my
Sku
struct in regards to overridingEquals()
,GetHashCode()
, and overloading the operators? - I wrote my tests at a very high level, to test the results from
Cashier.Checkout()
. Should I go down a level and test the Strategies too, or instead of?
The Code
Sku.cs
namespace SuperMarketPricing
{
public struct Sku
{
private char _value;
public Sku(char value)
{
_value = value;
}
public Sku(Sku sku)
{
_value = sku._value;
}
public static implicit operator Sku(char v)
{
return new Sku(v);
}
public static explicit operator char (Sku v)
{
return v._value;
}
public static bool operator ==(Sku sku1, Sku sku2)
{
return sku1.Equals(sku2);
}
public static bool operator !=(Sku sku1, Sku sku2)
{
return !sku1.Equals(sku2);
}
public override bool Equals(object obj)
{
if (obj == null) { return false; }
Sku sku;
try
{
sku = (Sku)obj;
}
catch
{
return false;
}
return _value == (char)sku;
}
public override int GetHashCode()
{
return _value.GetHashCode();
}
}
}
Cashier.cs
using System.Collections.Generic;
using System.Linq;
namespace SuperMarketPricing
{
public class Cashier
{
private List<IPricingStrategy> _pricingStrategies;
public Cashier(List<IPricingStrategy> pricingStrategies)
{
_pricingStrategies = pricingStrategies;
}
public double Checkout(IList<Sku> products)
{
double result = 0;
foreach(var strat in _pricingStrategies)
{
var prods = products.Where(p => p == strat.Sku);
result = result + strat.GetPrice(prods.Count());
}
return result;
}
}
}
IPricingStrategy.cs
namespace SuperMarketPricing
{
public interface IPricingStrategy
{
Sku Sku { get; }
double GetPrice(int count);
}
}
SaleStrategies.cs
namespace SuperMarketPricing
{
public abstract class SaleStrategy : IPricingStrategy
{
public abstract Sku Sku { get; }
protected abstract double PricePerOne { get; }
protected abstract double PricePerX { get; }
protected abstract int X { get; }
public double GetPrice(int count)
{
if (count == 0)
{
return 0;
}
double result = 0;
while (count >= X)
{
result = result + PricePerX;
count = count - X;
}
return result + (PricePerOne * count);
}
}
public class PricingStategyA : SaleStrategy
{
public override Sku Sku { get; } = 'A';
protected override double PricePerOne { get; } = 50;
protected override double PricePerX { get; } = 130;
protected override int X { get; } = 3;
}
public class PricingStrategyB : SaleStrategy
{
public override Sku Sku { get; } = 'B';
protected override double PricePerOne { get; } = 30;
protected override double PricePerX { get; } = 45;
protected override int X { get; } = 2;
}
}
RegularStrategies.cs
namespace SuperMarketPricing
{
public abstract class RegularStrategy : IPricingStrategy
{
public abstract Sku Sku { get; }
protected abstract double Price { get; }
public double GetPrice(int count)
{
return Price * count;
}
}
public class PricingStrategyC : RegularStrategy
{
public override Sku Sku { get; } = 'C';
protected override double Price { get; } = 20;
}
public class PricingStrategyD : RegularStrategy
{
public override Sku Sku { get; } = 'D';
protected override double Price { get; } = 15;
}
}
The Tests
What's a TDD kata without the tests?
SkuTests.cs
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace SuperMarketPricing.Tests
{
[TestClass]
public class SkuTests
{
[TestMethod]
public void Sku_ImplicitCastFromCharToSku()
{
var sku = new Sku('A');
sku = 'B';
Assert.AreEqual('B', sku);
}
[TestMethod]
public void Sku_ImplicitCastFromSkuToSku()
{
var original = new Sku('A');
var expected = new Sku('B');
original = expected;
Assert.AreEqual(expected, original);
}
[TestMethod]
public void Sku_ExplicitCastFromSkuToChar()
{
var sku = new Sku('A');
Assert.AreEqual('A', (char)sku);
}
[TestMethod]
public void Sku_ExplicitCastFromCharToSku()
{
var sku = new Sku('A');
Assert.AreEqual(sku, (Sku)'A');
}
[TestMethod]
public void Sku_CharAndSkuHaveSameHashCode()
{
var sku = new Sku('A');
var chr = 'A';
Assert.AreEqual(chr.GetHashCode(), sku.GetHashCode());
}
[TestMethod]
public void Sku_SkuAndSkuHaveSameHashCode()
{
var sku1 = new Sku('A');
var sku2 = new Sku('A');
Assert.AreEqual(sku1.GetHashCode(), sku2.GetHashCode());
}
[TestMethod]
public void Sku_CanCreateNewSkuFromExisting()
{
var sku1 = new Sku('A');
var sku2 = new Sku(sku1);
Assert.AreEqual(sku1, sku2);
}
[TestMethod]
public void Sku_CanUseEqualsOperator()
{
Sku sku1 = 'A';
Sku sku2 = 'A';
Assert.IsTrue(sku1 == sku2);
}
[TestMethod]
public void Sku_TwoDifferentSkusAreUnEqual()
{
Sku sku1 = 'A';
Sku sku2 = 'B';
Assert.AreNotEqual(sku1, sku2);
}
}
}
CashierTests.cs
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace SuperMarketPricing.Tests
{
[TestClass]
public class CashierTests
{
[TestMethod]
public void Cashier_WhenNoProducts_PriceIsZero()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>();
var price = cashier.Checkout(products);
Assert.AreEqual(0, price);
}
[TestMethod]
public void Cashier_OneA_Is50()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'A' };
var price = cashier.Checkout(products);
Assert.AreEqual(50, price);
}
[TestMethod]
public void Cashier_TwoA_Is100()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'A', 'A' };
var price = cashier.Checkout(products);
Assert.AreEqual(100, price);
}
[TestMethod]
public void Cashier_ThreeA_Is130()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'A', 'A', 'A' };
var price = cashier.Checkout(products);
Assert.AreEqual(130, price);
}
[TestMethod]
public void Cashier_FourA_Is180()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'A', 'A', 'A', 'A' };
var price = cashier.Checkout(products);
Assert.AreEqual(180, price);
}
[TestMethod]
public void Cashier_SixA_Is260()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'A', 'A', 'A', 'A', 'A', 'A' };
var price = cashier.Checkout(products);
Assert.AreEqual(260, price);
}
[TestMethod]
public void Cashier_OneB_Is30()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'B' };
var price = cashier.Checkout(products);
Assert.AreEqual(30, price);
}
[TestMethod]
public void Cashier_TwoBIs45()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'B', 'B' };
var price = cashier.Checkout(products);
Assert.AreEqual(45, price);
}
[TestMethod]
public void Cashier_ThreeBIs75()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'B', 'B', 'B' };
var price = cashier.Checkout(products);
Assert.AreEqual(75, price);
}
public void Cashier_FourBIs90()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'B', 'B', 'B', 'B' };
var price = cashier.Checkout(products);
Assert.AreEqual(90, price);
}
[TestMethod]
public void Cashier_OneAOneB_Is80()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'A', 'B' };
var price = cashier.Checkout(products);
Assert.AreEqual(80, price);
}
[TestMethod]
public void Cashier_OneATwoB_OutOfOrder_Is95()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'B', 'A', 'B' };
var price = cashier.Checkout(products);
Assert.AreEqual(95, price);
}
[TestMethod]
public void Cashier_OneC_Is20()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'C' };
var price = cashier.Checkout(products);
Assert.AreEqual(20, price);
}
[TestMethod]
public void Cashier_TwoC_Is20()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'C', 'C' };
var price = cashier.Checkout(products);
Assert.AreEqual(40, price);
}
[TestMethod]
public void Cashier_OneD_Is15()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'D' };
var price = cashier.Checkout(products);
Assert.AreEqual(15, price);
}
[TestMethod]
public void Cashier_TwoD_Is30()
{
var cashier = new Cashier(GetPricingStrategies());
var products = new List<Sku>() { 'D', 'D' };
var price = cashier.Checkout(products);
Assert.AreEqual(30, price);
}
private static List<IPricingStrategy> GetPricingStrategies()
{
return new List<IPricingStrategy>()
{
new PricingStategyA(),
new PricingStrategyB(),
new PricingStrategyC(),
new PricingStrategyD()
};
}
}
}