~codersonly/cinema-paradiso

cinema-paradiso/PriceEngineKata.md -rw-r--r-- 8.5 KiB
0e70f513infinitary update the kata 29 days ago

#Price Engine Kata

Cinema Paradiso is a universal BDD/TDD playground, suitable to run various types of workshops targeting specific aspects of software development and automated testing. This document is a Kata of the Price Engine exercise, as specified in the README.md document.

#Requirements

We need to create a price engine with the following requirements...

  • calculates the sales totals for all commercial transactions
  • product categories: tickets, refreshments, merchandise
  • maintains the taxes for every product category (volatile)
  • receives sales items with product category and net price
  • calculates the gross sales total (after taxes)

#The Solution

Needless to say, this is but one possible approach and design - it's not perfect and others are just as valid. We simply use this to demonstrate several aspects of the TDD process. The code is in Ruby.

#Starting Off

Let's define the Price Engine class, the grossTotal() method and the use of BigDecimal with the first test.

  it "empty item list" do
    PriceEngine.new.grossTotal([]).must_equal BigDecimal(0)
  end

This will return a hardwired result.

#The First Sales Item

Let's add the first sales item, declaring it as a tuple, ignoring taxes for the time being.

  it "single ticket, zero tax" do
    PriceEngine.new.grossTotal([SalesItem.new(:ticket, BigDecimal(10))])
      .must_equal BigDecimal(10)
  end

This will result in creating the SalesItem class.

SalesItem = Struct.new(:category, :price)

class PriceEngine
  def grossTotal(items)
    return BigDecimal(10) if !items.empty?
    BigDecimal(0)
  end
end

#The First Refactor

Once in green, we can refactor our tests, extracting the price engine into the fixture and the ticket factory.

  before do
    @engine = PriceEngine.new
  end
  ...
  def ticket(price)
    SalesItem.new(:ticket, BigDecimal(price))
  end

We note the difference between the shared fixture and the factory method.

#The Second Ticket

Let's add a second ticket then.

  it "two tickets, zero tax" do
    @engine.grossTotal([ticket(10), ticket(12)]).must_equal BigDecimal(22)
  end

Let's take things back into green with the second hardcoded special case.

  def grossTotal(items)
    return BigDecimal(22) if !items.size > 1
    return BigDecimal(10) if !items.empty?
    BigDecimal(0)
  end

Two special cases are too many, let's clean up by introducing summation.

  def grossTotal(items)
    items.map { |item| item.price }.sum
  end

#Bring On the Tax

Let's add tax to tickets using an infrastructure method.

  it "single ticket, 10% tax" do
    @engine.setTax(:ticket, 10)
    @engine.grossTotal([ticket(10)]).must_equal BigDecimal(11)
  end

This goes into a special case where we simply note if the tax had been set.

class PriceEngine
  def initialize
    @tax = false
  end

  def setTax(category, percent)
    @tax = true
  end

  def grossTotal(items)
    return BigDecimal(11) if @tax
    ...
  end
end

#Let's Raise the Tax

We add a second tax test case, raising taxes on tickets to 20%. This results in a second special case.

class PriceEngine
  def initialize
    @tax = 0
  end

  def setTax(category, percent)
    @tax = percent
  end

  def grossTotal(items)
    return BigDecimal(12) if @taxPercent == 20
    return BigDecimal(11) if @taxPercent > 0
    ...
  end
end

Again, two special cases are too many, let's clean up by extracting the net total and introducing gross amounts.

class PriceEngine
  ...
  def grossTotal(items)
    netTotal(items) * (100.0 + @tax) / 100.0
  end

private
  def netTotal(items)
    items.map { |item| item.price }.sum
  end
end

#Further Functional Tests

Although based on the implementation it will pass, let's add tax to two tickets.

  it "two tickets, 30% tax" do
    @engine.setTax(:ticket, 30)
    @engine.grossTotal([ticket(10), ticket(20)]).must_equal BigDecimal(39)
  end

#Introducing Refreshments

Interestingly, the following test will pass.

  it "single refreshment, zero tax" do
    @engine.grossTotal([refreshment(10)]).must_equal BigDecimal(10)
  end

More surprisingly, even the following test will pass.

  it "single refreshment, 10% tax" do
    @engine.setTax(:refreshment, 10)
    @engine.grossTotal([refreshment(10)]).must_equal BigDecimal(11)
  end

#Separate Product Categories

OK, enough cheating, let's drive those product categories apart using mixed item lists.

  it "ticket plus refreshment, different taxes" do
    @engine.setTax(:ticket, 10)
    @engine.setTax(:refreshment, 20)
    @engine.grossTotal([ticket(10), refreshment(20)]).must_equal BigDecimal(35)
  end

This uncovered two issues, the singular tax and the undifferentiated item sum. So let's fix the first one, by disabling this test case and adding a test to drive the taxes apart.

  it "single ticket, different taxes" do
    @engine.setTax(:ticket, 10)
    @engine.setTax(:refreshment, 20)
    @engine.grossTotal([ticket(10)]).must_equal BigDecimal(11)
  end

This fails, but can be brought into the green with a quick fix.

  def setTax(category, percent)
    @tax = percent if @tax == 0
  end

Now, being in green, let's separate the net totals first. In small steps, let's extract a gross total calculator, even if incomplete.

  def grossTotalOf(items, category)
    netTotal(items) * (100.0 + @tax) / 100.0
  end

Then let's pass the category down into the net total calculator.

  def netTotalOf(items, category)
    items.map { |item| item.price }.sum
  end

And now we're ready to add filtering and sum the two supported product categories.

  def grossTotal(items)
    grossTotalOf(items, :ticket) + grossTotalOf(items, :refreshment)
  end
  ...
  def netTotalOf(items, category)
    items
      .filter { |item| item.category == category }
      .map { |item| item.price }.sum
  end

Now we're ready to separate taxes per product category. The driver is to eliminate the special case in setTax().

  def initialize
    @taxes = {}
  end

  def setTax(category, percent)
    @taxes[category] = percent
  end
  ...
  def grossTotalOf(items, category)
    netTotalOf(items, category) * (100.0 + (@taxes[category] || 0)) / 100.0
  end

Reenabling the test that started us off confirms the changes by going green.

#Let's Sell Some Merchandise

With the next test we drive the creation of the third product category.

  it "ticket plus merchandise, different taxes" do
    @engine.setTax(:ticket, 10)
    @engine.setTax(:merchandise, 20)
    @engine.grossTotal([ticket(10), merchandise(20)]).must_equal BigDecimal(35)
  end

Adding the trivial implementation we can also refactor to extract the product types into a list.

  PRODUCT_CATEGORIES = [:ticket, :refreshment, :merchandise]
  ...
  def grossTotal(items)
    PRODUCT_CATEGORIES.map { |category| grossTotalOf(items, category) }.sum
  end

#Precision and Rounding

The calculations can lead to numerical errors, so adding tests to fix the issues remains, however neither will these steps change the design.

#Summary

Looking at the resulting code we may ask ourselves, why did we do TDD at all. And rightly so. The code has borderline zero algorithmic complexity and trivial structures. Neither were the requirements in any way unclear or unresolved - we could spell them out unambiguously at the very beginning. So in the sense of Codefin both business and technical dimensions were clearly (lol) in the Clear domain.

So there really was no need to do TDD, we could've written the code out almost without thinking, simply translating the requirements, adding tests later, in a functional capacity, as it would belong into the Clear domain.

The reason we did TDD - and teaming - was the third dimension, that of people/knowledge complexity. We did TDD while teaming to learn and exchange the rationale and mechanics of the technique, something we wouldn't have done in a productive situation, having a stable team with established know-how and mature practices.

As Nietzsche said, "men of convictions are prisoners". So don't be. Free your mind, learn your techniques and be differentiated.

#Creative Commons License

Cinema Paradiso by Coders Only is licensed under CC BY-NC-SA 4.0.

To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0.

This license requires that reusers give credit to the creator. It allows reusers to distribute, remix, adapt, and build upon the material in any medium or format, for noncommercial purposes only. If others modify or adapt the material, they must license the modified material under identical terms.