The Coffee Machine Series: As a patron, I might want condiments

Packets of sugar
Photo by Mick Haupt / Unsplash

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.

Subscribe to our occasional newsletter

No spam, no sharing to third party. Only you and me.

Member discussion