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
Setting Up
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:
dispense_tea_bag()
So, we need to extend our mock driver with the new method:
class MockDriver
...
def dispense_tea_bag; end
end
The Test
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
The Code
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.
Flog Scores
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.
Conclusion
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.
Member discussion