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
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.
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
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
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:
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.
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.