Welcome to the third in a series of posts in which we will answer the questions:
- How does complexity sneak into software?
- How can we recognize it before it becomes painful?
- And, how can we remove it permanently?
Here's a link to the previous post: As a patron, I want coffee
With our superior coffee machines now taking business away from the competition, research tells us there's an opportunity to disrupt the tea market.
So, what does it take to steep tea?
- Grab a cup
- Heat some water
- Put a tea bag into the cup
- And, pour the hot water into the cup
Our machine already does most of what we need. In fact, it actually does more than we need. We won't need the grinder, for example. But, there is one new method to add to the driver:
So, we need to extend our mock driver with the new method:
class MockDriver ... def dispense_tea_bag; end end
Now we're ready to write a test that makes sure we're using the machine in the proper order to steep tea:
example "As a patron, I want tea" do # ARRANGE driver = instance_double( MockDriver, dispense_cup: nil, dispense_tea_bag: nil, dispense_water: nil, engage_boiler: nil, empty_boiler: nil ) machine = CoffeeMachine.new(driver: driver) # ACT machine.vend(beverage: :tea) # ASSERT expect(driver).to have_received(:dispense_cup).ordered expect(driver).to have_received(:dispense_water).ordered expect(driver).to have_received(:engage_boiler).with(seconds: 30).ordered expect(driver).to have_received(:dispense_tea_bag).ordered expect(driver).to have_received(:empty_boiler).ordered end
With the test in place, we're able to add tea to vend method:
class CoffeeMachine attr_reader :driver def initialize(driver:) @driver = driver end def vend(beverage: :coffee) if beverage == :tea dispense_cup heat_water dispense_tea_bag dispense_hot_water else dispense_cup heat_water grind_coffee_beans dispense_hot_water dispose_grounds end end private def dispense_cup driver.dispense_cup end def heat_water driver.dispense_water driver.engage_boiler(seconds: 30) end def grind_coffee_beans driver.dispense_coffee_beans driver.engage_grinder(seconds: 15) driver.engage_filter driver.empty_grinder end def dispense_hot_water driver.empty_boiler end def dispose_grounds driver.empty_filter end def dispense_tea_bag driver.dispense_tea_bag end end
Two tests passing! Yay! But, we introduced some duplication in the
#vend method. A common pattern at this point would be to DRY this code up.
def vend(beverage: :coffee) dispense_cup heat_water if beverage == :tea dispense_tea_bag else grind_coffee_beans end dispense_hot_water dispose_grounds if beverage == :coffee end
For the record, here's the Flog score for the
#vend method at various points in time:
5.5 - Serves coffee only 11.8 - Serves coffee or tea with duplication 8.5 - Serves coffee or tea without duplication
Why did the source code become more complex with the addition of tea? Complexity increased because we added a conditional and more branches (method calls) to the method.
Why did the source code become less complex when we removed the duplication? We removed enough branches from the code to offset the conditional that we added for disposing of the grounds.
But wait! Is 8.5 a good Flog score? It's better than 11.8, for sure — higher numbers indicate higher complexity. But, how do we know a good score from a bad one?
Well, way back in 2008, Jake Scruggs (author of the
methric_fu gem) proposed these numbers:
Score Means --------- ---------------------------------------- 0-10 Awesome 11-20 Good enough 21-40 Might need refactoring 41-60 Possible to justify 61-100 Danger 100-200 Whoop, whoop, whoop 200 + Someone please think of the children
We use these numbers in our own work. They help us know when it's time to pause and reflect on a method before adding more code to it. In fact, we even incorporated them into our Ruby Flog VS Code extension.
Alright! We've completed two user stories and are already disrupting multiple vending machine markets. We should reach unicorn status any day now!
So, that's it for our third post in the series. In our next post, we'll tackle yet another user story: As a patron, I might want condiments.