The Coffee Machine Series: As a patron, I want coffee
Welcome to the second 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 in the series: What is complexity?
The Coffee Machine
It's time to get started on our little coffee machine application! Our first user story is: As a patron, I want coffee.
First, a bit of background...
We — you, me, and Fito — are the application team at our new coffee machine startup. We have a sibling hardware team who've promised to deliver an API that we can use to control the hardware from our application layer code. The interface upon which we agreed looks like this:
dispense_coffee_beans()
dispense_cup()
dispense_filter()
dispense_water()
engage_boiler(seconds:)
engage_grinder(seconds:)
empty_boiler()
empty_filter()
empty_grinder()
The code behind this interface probably sends specific voltages to different pieces of hardware to control the physical machine. We don't know. And, we don't care. It's none of our business.
As the application layer team, we only care that the implementation of the API is trustworthy. In other words, a cup should fall out of the cup hopper into the right position to receive coffee when we call dispense_cup
.
This is our first lesson in complexity:
Trustworthy abstractions are at the heart of writing simple source code.
We'll see this pattern repeated later. But, for now, suffice to say that abstracting away the hardware interactions frees us up to focus on the high-level sequence of events that will produce a delicious cup of coffee using the machine's hardware.
Brewing Coffee
So, what does that look like? When a patron deposits their money, the machine should immediately prepare them a cup of coffee. Let's think through the steps the machine should perform in order to prepare the coffee. It should:
- Dispense a cup
- Heat some water
- Grind some coffee beans
- Pour the hot water over the grounds
- And, dispose of the grounds
Tests First
Okay. Let's get started on some code...
To create our application layer code, we're going to write tests first, then write the code that makes the tests pass. We'll start with an integration test that ensures that the various methods on the driver get called in the right sequence to make a delicious cup of coffee. To do that, we'll need a mock implementation of our driver:
class MockDriver
def dispense_cup; end
def dispense_coffee_beans; end
def dispense_water; end
def engage_boiler(seconds:); end
def engage_filter; end
def engage_grinder(seconds:); end
def empty_boiler; end
def empty_filter; end
def empty_grinder; end
end
So, our RSpec integration test will look like this:
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_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
end
end
end
Note the comments in ALL CAPS. These comments are pointing out the three A's of good test design: arrange, act, and assert. When you structure a test like this, it's clear which commands are setup, which one is the method under test, and which ones are checking the results of the command.
We did not always write tests like this, with all three A's inside the it
(or in this case example
) block. We used to structure our tests quite differently. The arrange statements were usually in let
blocks. The act statement was usually in a before
block. And, only the assert statements went in the actual test block (often one assert per it
).
But, after years of working in large code bases, where the spec files are gigantic and the tests can take dozens of lines to configure, the let
statements at the top of the file (which are often shared across multiple tests) end up hundreds of lines away from the actual tests at the bottom of the file.
What's more, there are often intermediate let
statements at the top of each nested context
block that modify those at the top of the file. So, in order to understand the setup for a specific test, you need to trace through the entire file to reconstruct its setup.
Colocate the setup with the test — even if it means duplicating setup across tests. Your future self will thank you.
This brings us to our second lesson in complexity:
There is a balance to be struck between code reuse and code clarity.
As tempting as it might be to DRY up a bunch of tests with let
statements, the larger the file gets, the further away they'll be from the code that uses them, making the tests themselves harder to understand.
Your tests need to be the most understandable bit of your codebase. They are the living documentation of what your software should do. If they're not clear, there's very little chance that the code will be either.
Our friends at thoughtbot have a couple of good posts on this subject:
- Let's Not by Joe Ferris
- The Case for WET Tests by Amanda Beiner
The Code
Okay. With our first test in hand, it's time to write the production code:
class CoffeeMachine
attr_reader :driver
def initialize(driver:)
@driver = driver
end
def vend
driver.dispense_cup
driver.dispense_water
driver.engage_boiler(seconds: 30)
driver.dispense_coffee_beans
driver.engage_grinder(seconds: 15)
driver.engage_filter
driver.empty_grinder
driver.empty_boiler
driver.empty_filter
end
end
This code passes the test. But, how does it compare to the high-level process we outlined before we wrote the test? Well, if we're being totally honest with ourselves, the act of brewing coffee is obscured by all the concrete details of manipulating the hardware, making this code hard to follow.
At this point, we might be tempted to annotate our code with comments:
class CoffeeMachine
attr_reader :driver
def initialize(driver:)
@driver = driver
end
def vend
# Dispense a cup
driver.dispense_cup
# Heat some water
driver.dispense_water
driver.engage_boiler(seconds: 30)
# Grind some coffee beans
driver.dispense_coffee_beans
driver.engage_grinder(seconds: 15)
driver.engage_filter
driver.empty_grinder
# Pour the hot water over the grounds
driver.empty_boiler
# Dispose of the grounds
driver.empty_filter
end
end
This is a common pattern. But, this is a tacit admission that the underlying code itself is hard to understand.
When Fito and I find ourselves confronted with code like this, we ensure the method is covered by tests (check!), run the tests to make sure they're passing (check!), and refactor the commented blocks of code into private methods, like this:
class CoffeeMachine
attr_reader :driver
def initialize(driver:)
@driver = driver
end
def vend
dispense_cup
heat_water
grind_coffee_beans
dispense_hot_water
dispose_grounds
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
end
Now, the public #vend
method clearly describes the process of brewing coffee, not how to use the machine to do it. This brings us to our third lesson:
Public methods should express what the class does, not how.
Leave the details to private methods with intention revealing names, rather than peppering your public methods with intention revealing comments. Those comments will start off truthful. But, in the end, they will always lie to you because they are not executable.
Conclusion
So, there you go! We've completed our first user story by writing a test, writing some code, and refactoring the code for clarity. Along the way, we learned three lessons:
- Trustworthy abstractions are at the heart of writing simple source code.
- There is a balance to be struck between code reuse and code clarity.
- Public methods should express what the class does, not how.
We've reached the end of our second post in the series. In our next post, we'll tackle another user story: As a patron, I want tea.
No spam, no sharing to third party. Only you and me.
Member discussion