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.
We need to create a price engine with the following requirements...
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.
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.
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
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.
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
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
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
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
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
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.
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
The calculations can lead to numerical errors, so adding tests to fix the issues remains, however neither will these steps change the design.
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.
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.