An exercise in Test Driven Development: Kata No. 9

An exercise in Test Driven Development: Kata No. 9

You Aren't Going to Need it - until you do

Last night, at the North West Ruby Group, we did an exercise in Kata No. 9.

This meant using Test-Driven Development to design a Checkout class that could deal with calculating the price for multiple products, including when certain discount rules were applied.

Our genial host, Rob, wanted us to do things by designing a simple test first, then doing the bare minimum to get that test to pass.

The first example was:

describe "#total" do
  it "returns zero" do
    checkout = Checkout.new
    total = checkout.total      

    expect(total).to eq 0
  end
end

And to make it pass:

class Checkout
  def total = 0
end

Pretty trivial.

But that's kind of the point - too often we jump ahead and start building stuff that isn't necessary - complicating our code and wasting time and effort. So the idea is - we have a requirement, it's sat in front of us, and we need to deliver it now - so do the bare minimum to get that requirement fulfilled.

Also known as "YAGNI" - you aren't going to need it.

So we worked through the exercise, eventually ending up with a Checkout that looked something like this:

class Checkout
  def initialize 
    @total = 0
  end

  def total
    (@total > 115) ? 95 : @total
  end

  def scan product 
    if product == "A"
      @total += 50
    elsif product == "B"
      @total += 30
    else 
      @total + 20 
    end
  end
end

A ridiculous way of dealing with the discount for product B ("2 for 45"), but the tests passed, and it was simple, if a bit ugly (I really don't like if statements). But as we moved on the next condition "when the checkout contains B and 3 As" (triggering a different discount rule for product A), Will and I got bored. We were the oldest people in the room (I think) and we've been doing this for a long time. The slow, incremental, steps were frustrating and we knew that hacking the total method was never going to be the long term solution.

When I'm doing TDD, I tend to draft a specification for what I'm about to create (that's why it's called RSpec, not RTest). I fill it with expectations, so when I run it I have multiple failing tests. Then I go through and make them pass.

The approach we were being asked to take was to add one test at a time, make that pass, then move on to the next - you should never have a failing test. As a learning exercise for someone unfamiliar with TDD, that's understandable. As a tool for ensuring that you stick with YAGNI, it makes perfect sense.

But, looking at the requirements we had in front of us at that moment in time, it definitely mentioned pricing rules. My immediate thought, when I read it was "there is a checkout and there are pricing rules - we will need objects representing both". At that point I did not know what they would look like - and it may be that one or both of those objects may be unnecessary, but I see TDD as an act of discovery. Write the specification (in full), then use that specification to discover a way to implement it.

So I broke the rules of the exercise and wrote a specification for the discount "the unit price for A is 50 and the discount is '3 for 130'"

It looked like this:

describe APriceCalculator do
  subject(:calculator) { APriceCalculator.new }

  it "returns 50 for 1" do
    expect(calculator.price_for(1)).to eq 50
  end

  it "returns 100 for 2" do
    expect(calculator.price_for(2)).to eq 100
  end

  it "returns 130 for 3" do
    expect(calculator.price_for(3)).to eq 130
  end

  it "returns 180 for 4" do
    expect(calculator.price_for(4)).to eq 180
  end

  it "goes up to 7"
end

That final clause "it goes up to 7" was because I knew that, for a discount grouped in threes, we should ensure we test up to a quantity of six, probably seven, just to be sure it was working. Again, thinking ahead, but I left the clause as pending, so we could come back to it.

Then we implemented it as simply as possible. Our maths was poor so it took a few attempts.

class APriceCalculator 
  def price_for quantity
    return 130 if quantity == 3
    number_of_bundles = quantity / 3
    return quantity * 50 if number_of_bundles == 0 
    (number_of_bundles * bundle_price) + ((quantity % bundle_size) * 50)
  end
end

Then the equivalent for product B's price of 30, with a discount of "2 for 45".

describe BPriceCalculator do
  subject(:calculator) { BPriceCalculator.new }

  it "returns 30 for 1" do
    expect(calculator.price_for(1)).to eq 30
  end

  it "returns 45 for 2" do
    expect(calculator.price_for(2)).to eq 45
  end

  it "returns 75 for 3" do
    expect(calculator.price_for(3)).to eq 75
  end

  it "returns 90 for 4" do
    expect(calculator.price_for(4)).to eq 90
  end
end

And the BPriceCalculator looked like this:

class BPriceCalculator 
  def price_for quantity
    return 45 if quantity == 2
    number_of_bundles = quantity / 2
    return quantity * 30 if number_of_bundles == 0 
    (number_of_bundles * 45) + ((quantity % bundle_size) * 30)
  end
end

Now we had two specifications for price calculators that passed, plus a specification for the checkout that was failing.

And we also had two pricing calculator classes that looked almost identical. So I combined them into one class (our first proper refactoring) - I used a Struct so I didn't have to write a boiler-plate initialize method that simply copied parameters into instance variables - let's make Ruby do the work.

class BundleDiscountCalculator < Struct.new(:unit_price, :bundle_size, :bundle_price, keyword_init: true)

  def price_for(quantity)
    return unit_price if quantity == bundle_size
    number_of_bundles = quantity / bundle_size
    return quantity * unit_price if number_of_bundles == 0 
    (number_of_bundles * bundle_price) + ((quantity % bundle_size) * unit_price)
  end
end

We updated the specs to use this new class:

describe "Price calculator for A" do
  subject(:calculator) { BundleDiscountCalculator.new(unit_price: 50, bundle_size: 3, bundle_price: 130 }
  ...
end

describe "Price calculator for B" do
  subject(:calculator) { BundleDiscountCalculator.new(unit_price: 30, bundle_size: 2, bundle_price: 45 }
  ...
end

Our calculator specs still passed (so the refactoring was a success) but our checkout spec still failed (as we hadn't touched that yet). So the next step was to get the checkout working.

At this point we realised that the price is based upon the quantity of each product that had been scanned - 2 B's would trigger the discount, 3 A's would too, but 2 A's would not. So the checkout needs to track how many of each product we had scanned. Again, according the kata, this is probably jumping ahead, but TDD gives you the space to experiment and that's what we were doing.

Our checkout became something like this:

class Checkout
  def initialize pricing_rules
    @pricing_rules = pricing_rules
    @contents = Hash.new(0)
  end

  def total
    total = 0
    @contents.each do |product, quantity|
      total += @pricing_rules[product].price_for(quantity)
    end
    total
  end

  def scan(product) = @contents[product] += 1
end

Instead of updating the total when the product is scanned, we store each the quantity of each product in a hash. Then, when we need the total, we use the pricing_rules (that were mentioned in the initial requirements document) to calculate the price.

To get the checkout spec to pass, we needed to pass in the pricing rules. But the next problem arose - what about products, like C and D, that didn't have any discounts? We needed to update our calculator to deal with just unit prices.

My first thought was to add a simple conditional to the calculator. If the bundle_size is nil then return unit_price * quantity. But Will realised that the maths still worked if you pass in a bundle_size of 1 and bundle_price matching the unit_price. I took this a step further and added an initializer to the calculator, to handle this automatically.

class BundleDiscountCalculator < Struct.new(:unit_price, :bundle_size, :bundle_price, keyword_init: true)

  def initialize(**)
    super
    self.bundle_size ||= 1
    self.bundle_price ||= unit_price
  end
  ...
end

If you omit the bundle_size and bundle_price parameters when creating the calculator, it automatically configures itself for the case when there is no discount. We wrote a spec to prove this worked (writing the spec after the fact in this particular case), which passed:

describe "Price calculator with no discount" do
  subject(:calculator) { BundleDiscountCalculator.new(unit_price: 22) }

  it "returns the unit price for quantity 1" do
    expect(calculator.price_for(1)).to eq 22
  end

  it "returns the unit price x 2 for quantity  2" do
    expect(calculator.price_for(2)).to eq 44
  end

  it "returns the unit price x 3 for quantity 3" do
    expect(calculator.price_for(3)).to eq 66
  end

  it "returns the unit price x 4 for quantity 4" do
    expect(calculator.price_for(4)).to eq 88
  end
end

Now we could go back to the checkout spec and pass in our pricing rules for each condition we were testing. Which looked like this:

describe Checkout do
  let(:pricing_rules) do
    {
      "A" => BundleDiscountCalculator.new(unit_price: 50, bundle_size: 3, bundle_price: 130),
      "B" => BundleDiscountCalculator.new(unit_price: 30, bundle_size: 2, bundle_price: 45),
      "C" => BundleDiscountCalculator.new(unit_price: 20),
      "D" => BundleDiscountCalculator.new(unit_price: 15)
    }
  end
  ...
end

And now everything passed.

SUCCESS! We had a working checkout that implemented the requirements.

If this were a real project, I would have committed at this point (using Tekin's advice to make the commit message long, explaining the thought process that lead us to this code).

However, there was one final thing to do.

The specs were passing but there was a lot of code in there that I thought was a bit ugly.

The price calculator had been renamed BundleDiscountCalculator even though it calculated the price without a discount.

The implementation of the BundleDiscountCalculator#price_for(quantity) method definitely looked like a candidate for simplification.

And I didn't like the word bundle - I just came up with it because I couldn't think of anything else at the time.

Finally the Checkout#total method used Collection#each plus an external variable to track the return value, which felt wrong and was probably unnecessary.

But now we had passing specs, we were safe to go back and tidy things up.

The final code ended up looking like this:

require "rspec"

class Checkout
  def initialize pricing_rules
    @pricing_rules = pricing_rules
    @contents = Hash.new(0)
  end

  def total
    @contents.sum do |product, quantity|
      @pricing_rules[product].price_for(quantity)
    end
  end

  def scan(product) = @contents[product] += 1
end

class ProductPriceCalculator < Struct.new(:unit_price, :discount_bundle_size, :discount_bundle_price, keyword_init: true)

  def initialize(**)
    super
    self.discount_bundle_size ||= 1
    self.discount_bundle_price ||= unit_price
  end

  def price_for(quantity) = ((quantity / discount_bundle_size) * discount_bundle_price) + ((quantity % discount_bundle_size) * unit_price)
end

describe "Price calculator with no discount" do
  subject(:calculator) { ProductPriceCalculator.new(unit_price: 22) }

  it "returns the unit price for quantity 1" do
    expect(calculator.price_for(1)).to eq 22
  end

  it "returns the unit price x 2 for quantity  2" do
    expect(calculator.price_for(2)).to eq 44
  end

  it "returns the unit price x 3 for quantity 3" do
    expect(calculator.price_for(3)).to eq 66
  end

  it "returns the unit price x 4 for quantity 4" do
    expect(calculator.price_for(4)).to eq 88
  end
end

describe "Price calculator for A" do
  subject(:calculator) { ProductPriceCalculator.new(unit_price: 50, discount_bundle_size: 3, discount_bundle_price: 130) }

  it "returns 50 for 1" do
    expect(calculator.price_for(1)).to eq 50
  end

  it "returns 100 for 2" do
    expect(calculator.price_for(2)).to eq 100
  end

  it "returns 130 for 3" do
    expect(calculator.price_for(3)).to eq 130
  end

  it "returns 180 for 4" do
    expect(calculator.price_for(4)).to eq 180
  end

  it "returns 230 for 5" do
    expect(calculator.price_for(5)).to eq 230
  end

  it "returns 260 for 6" do
    expect(calculator.price_for(6)).to eq 260
  end

  it "returns 310 for 7" do
    expect(calculator.price_for(7)).to eq 310
  end

  it "returns 360 for 8" do
    expect(calculator.price_for(8)).to eq 360
  end
end

describe "Price calculator for B" do
  subject(:calculator) { ProductPriceCalculator.new(unit_price: 30, discount_bundle_size: 2, discount_bundle_price: 45) }

  it "returns 30 for 1" do
    expect(calculator.price_for(1)).to eq 30
  end

  it "returns 45 for 2" do
    expect(calculator.price_for(2)).to eq 45
  end

  it "returns 75 for 3" do
    expect(calculator.price_for(3)).to eq 75
  end

  it "returns 90 for 4" do
    expect(calculator.price_for(4)).to eq 90
  end
end

describe Checkout do
  let(:pricing_rules) do
    {
      "A" => ProductPriceCalculator.new(unit_price: 50, discount_bundle_size: 3, discount_bundle_price: 130),
      "B" => ProductPriceCalculator.new(unit_price: 30, discount_bundle_size: 2, discount_bundle_price: 45),
      "C" => ProductPriceCalculator.new(unit_price: 20),
      "D" => ProductPriceCalculator.new(unit_price: 15)
    }
  end

  describe "#total" do
    context "when the checkout is empty" do
      it "returns zero" do
        checkout = Checkout.new(pricing_rules)

        total = checkout.total

        expect(total).to eq 0
      end
    end

    context "when the checkout contains A" do
      it "returns 50" do
        checkout = Checkout.new(pricing_rules)

        checkout.scan "A"

        expect(checkout.total).to be 50
      end
    end

    context "when the checkout contains B and A" do
      it "returns 80" do
        checkout = Checkout.new(pricing_rules)

        checkout.scan "A"
        checkout.scan "B"

        expect(checkout.total).to be 80
      end
    end

    context "when the checkout contains B, A and B" do
      it "returns 95" do
        checkout = Checkout.new(pricing_rules)

        checkout.scan "B"
        checkout.scan "A"
        checkout.scan "B"

        expect(checkout.total).to be 95
      end
    end

    context "when the checkout contains A, B and C" do
      it "returns 100" do
        checkout = Checkout.new(pricing_rules)

        checkout.scan "A"
        checkout.scan "B"
        checkout.scan "C"

        expect(checkout.total).to be 100
      end
    end

    context "when the checkout contains A, B, C and D" do
      it "returns 115" do
        checkout = Checkout.new(pricing_rules)

        checkout.scan "A"
        checkout.scan "B"
        checkout.scan "C"
        checkout.scan "D"

        expect(checkout.total).to be 115
      end
    end

    context "when the checkout contains B and 3 As" do
      it "returns 160" do
        checkout = Checkout.new(pricing_rules)

        checkout.scan "A"
        checkout.scan "B"
        checkout.scan "A"
        checkout.scan "A"

        expect(checkout.total).to be 160
      end
    end
  end
end

The BundleDiscountCalculator has been renamed ProductPriceCalculator, explaining the reason for its existence much more clearly.

The ProductPriceCalculator#price_for(quantity) method has been squashed down into a single line (arguably a bit less readable, but it does the job in the most efficient manner). I would never have come up with that piece of maths on my own, but, again, I could experiment and use the spec to prove the new version worked.

Finally, Checkout#total method uses Collection#sum to calculate the return value, eliminating the extra variable.

The only issue was I could not think of a replacement for the word bundle - so I renamed the attributes discount_bundle_size and discount_bundle_price, making them more expressive.

Again, in a real project, I would commit this, with a message along the lines of "tidied up the code" and listing the changes, as above.

I'm pretty pleased with this final version. It handles the requirements (as proved by the specifications), the classes are small, the methods succinct, there aren't any if statements dirtying up the code and it uses ruby idioms like Collection#sum and endless methods[1].

I hope you found this exploration of TDD useful. Give me a shout[2] if you have any thoughts.


  1. I really like endless methods ↩︎

  2. Mastodon, Bluesky and Threads ↩︎

Rahoul Baruah

Rahoul Baruah

Rubyist since 1.8.6. I like hair, dogs and Kim/Charli/Poppy. Also CTO at Collabor8Online.
Leeds, England