The Coffee Machine Series: As a patron, I might want condiments
Welcome to the fourth 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 tea.
With the coffee and tea vending machine markets turned spectacularly upside down thanks to our innovation, tenacity, and grit, the feedback is beginning to pour in. To our surprise — especially given our innovation, tenacity, and grit — much of the feedback is acerbic. It turns out many of our patrons think our beverages are too bitter. These folks would prefer their drinks sweet and/or creamy.
To smooth things out, we decided to add an epic to our backlog called As a patron, I might want condiments. The epic contains three user stories:
- As a patron, I might want sweetener in my coffee
- As a patron, I might want creamer in my coffee
- As a patron, I might want sweetener in my tea
(No one wants powdered creamer in their tea!)
Setting Up
With the stories in hand, both our team and the firmware team spring into action. We quickly agree on the following interface for dispensing sweetener and creamer.
dispense_sweetener()
dispense_creamer()
With that in place, we can extend our mock driver:
class MockDriver
# ...
def dispense_sweetener; end
def dispense_creamer; end
end
Test Driven Design
As always, we're going to start with tests. Let's begin with the three tests we know we need — one for each user story — and the code that goes with them.
Our first test checks to see that if a patron asks for the optional sweetener condiment, then the dispenser gets called in the right order.
example "As a patron, I might want sweetener in my coffee" do
# ARANGE
driver = instance_double(
MockDriver,
dispense_cup: nil,
dispense_coffee_beans: nil,
dispense_sweetener: nil,
dispense_water: 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: { sweet: true })
# ASSERT
expect(driver).to have_received(:dispense_sweetener).ordered
expect(driver).to have_received(:empty_boiler).ordered
end
Note that we're not asserting on the entire process anymore. We already have tests for that. What we want to ensure here is that the sweetener is dispensed before the hot water is added to the cup. The action of the water spilling into the cup helps dissolve the sweetener.
And, here's the code to implement that:
def vend(beverage: :coffee, options: {})
dispense_cup
heat_water
if beverage == :tea
dispense_tea_bag
else
grind_coffee_beans
dispense_sweetener if options[:sweet]
end
dispense_hot_water
dispose_grounds if beverage == :coffee
end
private
# ...
def dispense_sweetener
driver.dispense_sweetener
end
To get the test passing, we dispensed the sweetener inside the else
clause that grinds coffee. We chose to do this because we do not yet have a test that requires us to also optionally serve sweetener with tea.
Let's add that test now:
example "As a patron, I might want sweetener in my tea" do
# ARRANGE
driver = instance_double(
MockDriver,
dispense_cup: nil,
dispense_sweetener: nil,
dispense_tea_bag: nil,
dispense_water: nil,
engage_boiler: nil,
empty_boiler: nil
)
machine = CoffeeMachine.new(driver: driver)
# ACT
machine.vend(beverage: :tea, options: { sweet: true })
# ASSERT
expect(driver).to have_received(:dispense_sweetener).ordered
expect(driver).to have_received(:empty_boiler).ordered
end
Here, our assertions are identical to the coffee test. What's different is the action we are testing. This triggers a different code path which needs to (but currently does not) produce the same result.
So, let's add sweetener to the tea:
def vend(beverage: :coffee, options: {})
dispense_cup
heat_water
if beverage == :tea
dispense_tea_bag
dispense_sweetener if options[:sweet]
else
grind_coffee_beans
dispense_sweetener if options[:sweet]
end
dispense_hot_water
dispose_grounds if beverage == :coffee
end
private
# ...
def dispense_sweetener
driver.dispense_sweetener
end
Now both tests pass. But, we've introduced some duplication again. So, let's DRY it up.
def vend(beverage: :coffee, options: {})
dispense_cup
heat_water
if beverage == :tea
dispense_tea_bag
else
grind_coffee_beans
end
dispense_sweetener if options[:sweet]
dispense_hot_water
dispose_grounds if beverage == :coffee
end
private
# ...
def dispense_sweetener
driver.dispense_sweetener
end
For our final test, let's add creamer to our coffee:
example "As a patron, I might want creamer in my coffee" do
# ARANGE
driver = instance_double(
MockDriver,
dispense_cup: nil,
dispense_coffee_beans: nil,
dispense_creamer: nil,
dispense_water: 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: { creamy: true })
# ASSERT
expect(driver).to have_received(:dispense_creamer).ordered
expect(driver).to have_received(:empty_boiler).ordered
end
And, the resulting code:
def vend(beverage: :coffee, options: {})
dispense_cup
heat_water
if beverage == :tea
dispense_tea_bag
else
grind_coffee_beans
dispense_creamer if options[:creamy]
end
dispense_sweetener if options[:sweet]
dispense_hot_water
dispose_grounds if beverage == :coffee
end
private
# ...
def dispense_creamer
driver.dispense_creamer
end
Since we do not want to dispense powdered creamer into a cup of tea, we'll leave that optional condiment in the coffee specific else
clause.
The Edge Cases
Now that we've smoothed things out, we need to consider whether or not there are edge cases that need to be tested. The first potential edge case we see is what if a patron wants both sweetener and creamer in their coffee. Do we need a test for that?
No. We don't. That test would be duplicative of the two coffee related tests above.
Next, what about the people who don't want any condiments in their beverages? Should we test to make sure that we do not send a signal to the dispensers?
The answer here is a resounding maybe. We already have our original tests for coffee and tea. Those tests do not request condiments. And, if for some reason sweetener or creamer were added, the tests would currently fail because the mock driver doesn't set up spies for those driver methods.
However, the error messages produced in that situation would not be ideal. RSpec would complain about the lack of spies, not the fact that the the condiments shouldn't have been added. For that reason, we would likely add another assertion or two to those original tests.
Here's the extended version of the original coffee test.
RSpec.describe CoffeeMachine do
describe "#vend" do
example "As a patron, I want coffee" do
# ARANGE
driver = instance_double(
MockDriver,
dispense_cup: nil,
dispense_coffee_beans: nil,
dispense_creamer: nil,
dispense_sweetener: nil,
dispense_water: 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
# ASSERT
# Dispense a cup
expect(driver).to have_received(:dispense_cup).ordered
# Heat some water
expect(driver).to have_received(:dispense_water).ordered
expect(driver).to have_received(:engage_boiler).with(seconds: 30).ordered
# Grind some coffee beans
expect(driver).to have_received(:dispense_coffee_beans).ordered
expect(driver).to have_received(:engage_grinder).with(seconds: 15).ordered
expect(driver).to have_received(:engage_filter).ordered
expect(driver).to have_received(:empty_grinder).ordered
# Pour the hot water over the grounds
expect(driver).to have_received(:empty_boiler).ordered
# Dispose of the grounds
expect(driver).to have_received(:empty_filter).ordered
# We did not add condiments
expect(driver).not_to have_received(:dispense_sweetener)
expect(driver).not_to have_received(:dispense_creamer)
end
end
end
Given that our tests are integration tests not unit tests, we feel comfortable adding assertions like this. Were these more traditional unit tests, with a single assertion per test, we'd be less likely to find value in negative tests. There are just too many ways that they can produce a false positive.
Conclusion
Sweet! We've averted what could have become a bitter ending. Now our customers have more choices than ever. But, our code is getting more complex. There are thirteen lines of code in the #vend
method, and it already has five conditionals and eight branches. We should keep our eyes on that as we move forward.
So what can we take away from this work? Here are three lessons:
Define your system by what it does, not what it doesn't do.
There are an infinite number of things in this world that your system doesn't do. Focus your user stories, and therefore your tests, on what your system actually does.
Don't test the same thing twice.
Similar code paths may produce similar assertions. But, the "Act" phase of those tests should trigger different code paths. Try not to write two tests that traverse exactly the same code path.
Learning TDD requires patience and practice.
The same could be said of learning any new skill. But, it's particularly important with TDD. The analogy that is often provided is that of learning a martial art.
At first, your sensei teaches the movements: "Wax on. Wax off." When you're ready, they begin combining movements into defenses, then attacks. After you assimilate these routines, you are free to experiment, and ultimately break the rules.
We've reached the end of the fourth post in our series. In the next article, we discover that not everyone likes coffee or tea.
No spam, no sharing to third party. Only you and me.
Member discussion