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.