Welcome to the fifth 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 might want condiments.
As a patron, I want cocoa
Ok. Our inbox is no longer full of such bitter feedback. But, there is one request that we haven't yet fulfilled. Some of our customers would like cocoa, rather than coffee or tea. So, let's extend our software.
Here's how to make cocoa:
- Dispense a cup
- Heat some water
- Add cocoa mix to the cup
- And, pour the hot water into the cup
Updating the Driver
As with the addition of tea, the hardware team will need to add a new method to the driver:
dispense_cocoa_mix()
And, our mock will also need the new method:
class MockDriver
...
def dispense_cocoa_mix; end
end
The Test
Next, we write a test. One thing to note is that our cocoa mix already contains sugar. So, there's no need to add sweetener, even if the user asks for it. So, let's incorporate that knowledge into the test, too:
example "As a patron, I want cocoa" do
# ARRANGE
driver = instance_double(
MockDriver,
dispense_cup: nil,
dispense_cocoa_mix: nil,
dispense_sweetener: nil,
dispense_water: nil,
engage_boiler: nil,
empty_boiler: nil
)
machine = CoffeeMachine.new(driver: driver)
# ACT
machine.vend(beverage: :cocoa, options: { sweet: true })
# 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_cocoa_mix).ordered
expect(driver).to have_received(:empty_boiler).ordered
# We did not add extra sweetener
expect(driver).not_to have_received(:dispense_sweetener)
end
The Code
The code to pass the test looks like this:
def vend(beverage: :coffee, options: {})
dispense_cup
heat_water
if beverage == :cocoa
dispense_cocoa_powder
elsif beverage == :tea
dispense_tea_bag
else
grind_coffee_beans
dispense_creamer if options[:creamy]
end
dispense_sweetener if options[:sweet] && beverage != :cocoa
dispense_hot_water
dispose_grounds if beverage == :coffee
end
private
# ...
def dispense_cocoa_powder
driver.dispense_cocoa_powder
end
Great! The test passes! Now, there's just one last feature to top things off. Some folks like whipped cream on their cocoa. Heck, Fito even likes it on his coffee. But, no one likes it on their tea!
So, let's add whipped cream to the mix after the hot water is dispensed and ensure that the option is ignored when the beverage is tea, starting with the driver and mock driver:
dispense_whipped_cream()
class MockDriver
...
def dispense_whipped_cream; end
end
Then, let's add a test for adding whipped cream to cocoa:
example "As a patron, I might want whipped cream on my cocoa" do
# ARRANGE
driver = instance_double(
MockDriver,
dispense_cup: nil,
dispense_cocoa_mix: nil,
dispense_sweetener: nil,
dispense_water: nil,
dispense_whipped_cream: nil,
engage_boiler: nil,
empty_boiler: nil
)
machine = CoffeeMachine.new(driver: driver)
# ACT
machine.vend(beverage: :cocoa, options: { fluffy: true })
# ASSERT
expect(driver).to have_received(:empty_boiler).ordered
expect(driver).to have_received(:dispense_whipped_cream).ordered
end
This is virtually identical to the test for coffee. So, let's add that, too.
example "As a patron, I might want whipped cream in my coffee" do
# ARANGE
driver = instance_double(
MockDriver,
dispense_cup: nil,
dispense_coffee_beans: nil,
dispense_water: nil,
dispense_whipped_cream: nil,
engage_boiler: nil,
engage_filter: nil,
engage_grinder: nil,
empty_boiler: nil,
empty_filter: nil,
empty_grinder: nil
)
machine = CoffeeMachine.new(driver: driver)
# ACT
machine.vend(beverage: :coffee, options: { fluffy: true })
# ASSERT
expect(driver).to have_received(:empty_boiler).ordered
expect(driver).to have_received(:dispense_whipped_cream).ordered
end
Note that the whipped cream must be dispensed after the hot water. Otherwise it wouldn't float on top of the cocoa or coffee.
Here's the code to make those tests pass:
def vend(beverage: :coffee, options: {})
dispense_cup
heat_water
if beverage == :cocoa
dispense_cocoa_powder
elsif beverage == :tea
dispense_tea_bag
else
grind_coffee_beans
dispense_creamer if options[:creamy]
end
dispense_sweetener if options[:sweet] && beverage != :cocoa
dispense_hot_water
dispense_whipped_cream if options[:fluffy]
dispose_grounds if beverage == :coffee
end
private
# ...
def dispense_whipped_cream
driver.dispense_whipped_cream
end
And, let's not forget a test to ensure that we don't put whipped cream on someone's tea:
example "As a patron, I might want whipped cream in my coffee" do
# ARANGE
driver = instance_double(
MockDriver,
dispense_cup: nil,
dispense_coffee_beans: nil,
dispense_water: nil,
dispense_whipped_cream: nil,
engage_boiler: nil,
engage_filter: nil,
engage_grinder: nil,
empty_boiler: nil,
empty_filter: nil,
empty_grinder: nil
)
machine = CoffeeMachine.new(driver: driver)
# ACT
machine.vend(beverage: :tea, options: { fluffy: true })
# ASSERT
expect(driver).not_to have_received(:dispense_whipped_cream)
end
Just need to add one more clause to the if
statement:
def vend(beverage: :coffee, options: {})
dispense_cup
heat_water
if beverage == :cocoa
dispense_cocoa_powder
elsif beverage == :tea
dispense_tea_bag
else
grind_coffee_beans
dispense_creamer if options[:creamy]
end
dispense_sweetener if options[:sweet] && beverage != :cocoa
dispense_hot_water
dispense_whipped_cream if options[:fluffy] && beverage != :tea
dispose_grounds if beverage == :coffee
end
private
# ...
def dispense_whipped_cream
driver.dispense_whipped_cream
end
Alright. This code passes the tests. But, this method now has a flog score of 31.4, which is well over Jake's "good enough" line of 20. Things are getting really complex.
Maybe it's time for us to refactor before we try to add any more functionality. We'll get to that in our next post. But, before we do, let's take a look at what we've learned:
First...
Conditionals increase complexity.
It stands to reason that a metric called Assignments, Branches, and Conditionals would penalize conditionals. But, why?
Well, simply put, it's because they make code harder to understand. Take our #vend
method, for example. It started off with one responsibility: brewing coffee. Now it's capable of creating twelve different beverage/condiment combinations. Figuring out the exact recipe for any one of those twelve recipes now involves parsing all that conditional logic to weed out that which is irrelevant from that which is meaningful.
Also...
Conditionals multiply like rabbits.
Using an if
statement to add tea to the #vend
method set a precedent. So, when we went to add cocoa to the method, it felt right to extend the conditional. And, future developers are going to feel even more comfortable following that existing pattern. In other words, conditionals beget more conditionals, which in turn, increase complexity and beget even more conditionals.
So...
Pay attention to conditionals.
We'll show you how to reduce the number of conditionals in your code in a future post. But, for now, that brings us to the end of the fifth post in our series. In the next article, we consider whether or not it's time to refactor this code.
Member discussion