Welcome to Container Industries Ltd. As the new engineer here you've been assigned the job of developing the company's container functionality.
The following requirements have made there way over to your desk:
- Items can be added to a container
- Items are not added to a container if doing so would exceed the container's weight limit.
Seems nice and simple. Let's use this as an excuse to explore the world of Test Driven Development (TDD) together. There's a really nice rhythm I want to get across in the this post:
- Write the tests first
- Run the tests often
- Only start adding new functionality when the tests are passing
- When the tests are failing, your sole intention should be to get them to a passing state. Sometimes this means fudging the code, but that's OK; it's all about taking tiny steps and iterating frequently to meet the requirements
1. Items can be added to a container
Here's the initial test.
# container_spec.rb
describe Container do
it "can add items" do
end
end
After running it we get:
$ rspec container_spec.rb
/Users/andy/dev/Ruby/tdd/container_spec.rb:2:in `<top (required)>': uninitialized constant Container (NameError)
from /Users/andy/.gem/ruby/2.4.0/gems/rspec-core-3.5.4/lib/rspec/core/configuration.rb:1435:in `load'
from /Users/andy/.gem/ruby/2.4.0/gems/rspec-core-3.5.4/lib/rspec/core/configuration.rb:1435:in `block in load_spec_files'
from /Users/andy/.gem/ruby/2.4.0/gems/rspec-core-3.5.4/lib/rspec/core/configuration.rb:1433:in `each'
from /Users/andy/.gem/ruby/2.4.0/gems/rspec-core-3.5.4/lib/rspec/core/configuration.rb:1433:in `load_spec_files'
from /Users/andy/.gem/ruby/2.4.0/gems/rspec-core-3.5.4/lib/rspec/core/runner.rb:100:in `setup'
from /Users/andy/.gem/ruby/2.4.0/gems/rspec-core-3.5.4/lib/rspec/core/runner.rb:86:in `run'
from /Users/andy/.gem/ruby/2.4.0/gems/rspec-core-3.5.4/lib/rspec/core/runner.rb:71:in `run'
from /Users/andy/.gem/ruby/2.4.0/gems/rspec-core-3.5.4/lib/rspec/core/runner.rb:45:in `invoke'
from /Users/andy/.gem/ruby/2.4.0/gems/rspec-core-3.5.4/exe/rspec:4:in `<top (required)>'
from /Users/andy/.gem/ruby/2.4.0/bin/rspec:22:in `load'
from /Users/andy/.gem/ruby/2.4.0/bin/rspec:22:in `<main>'
Looks like we need to create the Container
definition. We are going to be iterating frequently, so let's do the simplest thing we can right now to get the tests to pass. We'll add the Container
definition right at the top of the spec file.
class Container
end
describe Container do
it "can add items" do
end
end
$ rspec container_spec.rb
.
Finished in 0.00091 seconds (files took 0.66202 seconds to load)
1 example, 0 failures
Our first passing test! Yess
It isn't actually asserting anything though, so let's start adding real tests.
How about we work backwards, writing the test under the assumption that all the variables we need already exist?
class Container
end
describe Container do
it "can add items" do
container.add pasta
container.add cake
expect(container.items).to include pasta
expect(container.items).to include cake
end
end
$ rspec container_spec.rb
F
Failures:
1) Container can add items
Failure/Error: container.add pasta
NameError:
undefined local variable or method `container' for #<RSpec::ExampleGroups::Container:0x007fcafe150f60>
Did you mean? contain_exactly
# ./container_spec.rb:5:in `block (2 levels) in <top (required)>'
Finished in 0.00065 seconds (files took 1.28 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./container_spec.rb:4 # Container can add items
Alright, looks like we need to define the container
attribute in the test.
class Container
end
describe Container do
let(:container) { Container.new }
it "can add items" do
container.add pasta
container.add cake
expect(container.items).to include pasta
expect(container.items).to include cake
end
end
$ rspec container_spec.rb
F
Failures:
1) Container can add items
Failure/Error: container.add pasta
NameError:
undefined local variable or method `pasta' for #<RSpec::ExampleGroups::Container:0x007fd3a1974d58>
# ./container_spec.rb:7:in `block (2 levels) in <top (required)>'
Finished in 0.00071 seconds (files took 0.27835 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./container_spec.rb:6 # Container can add items
…and we need to define pasta
and cake
too.
# ...
describe Container do
let(:container) { Container.new }
let(:pasta) { "pasta" }
let(:cake) { "cake" }
it "can add items" do
container.add pasta
container.add cake
expect(container.items).to include pasta
expect(container.items).to include cake
end
end
$ rspec container_spec.rb
F
Failures:
1) Container can add items
Failure/Error: container.add pasta
NoMethodError:
undefined method `add' for #<Container:0x007f969fa0ba58>
# ./container_spec.rb:9:in `block (2 levels) in <top (required)>'
Finished in 0.00078 seconds (files took 0.27569 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./container_spec.rb:8 # Container can add items
The test is failing because we haven't implemented the add
method. So let's add that next.
class Container
def add(item)
end
end
# ...
$ rspec container_spec.rb
F
Failures:
1) Container can add items
Failure/Error: expect(container.items).to include pasta
NoMethodError:
undefined method `items' for #<Container:0x007fce65a16b08>
# ./container_spec.rb:13:in `block (2 levels) in <top (required)>'
Finished in 0.00068 seconds (files took 0.25235 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./container_spec.rb:10 # Container can add items
Now, it's time to tell Container
that it has some items
class Container
attr_reader :items
def add(item)
end
end
# ...
$ rspec container_spec.rb
F
Failures:
1) Container can add items
Failure/Error: expect(container.items).to include pasta
expected nil to include "pasta", but it does not respond to `include?`
# ./container_spec.rb:14:in `block (2 levels) in <top (required)>'
Finished in 0.25862 seconds (files took 0.32494 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./container_spec.rb:11 # Container can add items
This test is failing because our add
method isn't implemented yet. So let's tackle that next.
class Container
attr_reader :items
def initialize
@items = []
end
def add(item)
items << item
end
end
# ...
$ rspec container_spec.rb
.
Finished in 0.00198 seconds (files took 0.22488 seconds to load)
1 example, 0 failures
Here's what container_spec.rb
looks like so far:
class Container
attr_reader :items
def initialize
@items = []
end
def add(item)
items << item
end
end
describe Container do
let(:container) { Container.new }
let(:pasta) { "pasta" }
let(:cake) { "cake" }
it "can add items" do
container.add pasta
container.add cake
expect(container.items).to include pasta
expect(container.items).to include cake
end
end
2. Items are not added to a container if doing so would exceed the container's weight limit
OK, here's my test:
# ...
it "does not add items that would exceed the weight limit" do
container.add pasta
container.add house
expect(container.items).to include pasta
expect(container.items).to_not include house
end
# ...
$ rspec container_spec.rb
.F
Failures:
1) Container does not add items that would exceed the weight limit
Failure/Error: container.add house
NameError:
undefined local variable or method `house' for #<RSpec::ExampleGroups::Container:0x007fe2a68d2cd8>
# ./container_spec.rb:26:in `block (2 levels) in <top (required)>'
Finished in 0.00256 seconds (files took 0.24082 seconds to load)
2 examples, 1 failure
Failed examples:
rspec ./container_spec.rb:24 # Container does not add items that would exceed the weight limit
This fails because we haven't told the test what a house is.
What does it mean to be a house? Let's say it means a string with the value "house" for now.
describe Container do
let(:container) { Container.new }
let(:pasta) { "pasta" }
let(:cake) { "cake" }
let(:house) { "house" }
# ...
$ rspec container_spec.rb
.F
Failures:
1) Container does not add items that would exceed the weight limit
Failure/Error: expect(container.items).to_not include house
expected ["pasta", "house"] not to include "house"
# ./container_spec.rb:29:in `block (2 levels) in <top (required)>'
Finished in 0.1118 seconds (files took 0.44737 seconds to load)
2 examples, 1 failure
Failed examples:
rspec ./container_spec.rb:25 # Container does not add items that would exceed the weight limit
Cool, a proper failure! The house was added to the container, when instead we should have cast it asunder.
What's the quickest way we can get the test to pass? Before we figure out the logic for deciding if we should add an item based on its weight, let's get the tests green by only adding the item if it isn't equal to "house".
class Container
attr_reader :items
def initialize
@items = []
end
def add(item)
items << item unless item == "house"
end
end
# ...
$ rspec container_spec.rb
..
Finished in 0.00256 seconds (files took 0.21494 seconds to load)
2 examples, 0 failures
Whoa, the tests pass!
That's a bit weird, right? We fudged the code to make the tests pass. The key thing here is that we have a really tight red, green, refactor cycle; we write the tests, do the bear minimum to get them passing, then refactor to clean things up. We're doing the smallest thing we can to get to a "safe" (tests passing) state, before adding more code. That way we don't stray too far away from the safety of passing tests and don't get sidetracked implementing everything at once.
Now that the tests are passing we can refactor our add
method to take into account an item
's weight.
class Container
attr_reader :items
def initialize
@items = []
end
def add(item)
items << item unless item_too_heavy? item
end
private
def item_too_heavy? item
item == "house"
end
end
# ...
$ rspec container_spec.rb
..
Finished in 0.00367 seconds (files took 0.24654 seconds to load)
2 examples, 0 failures
We've extracted the logic that checks if an item is too heavy into its own method. The tests pass, but we don't really have any way to define how heavy an item is. Let's amend the tests to specify the weights of items.
# ...
describe Container do
let(:container) { Container.new }
let(:pasta) { Item.new(5) }
let(:cake) { Item.new(5) }
let(:house) { Item.new(20) }
# ...
end
$ rspec container_spec.rb
FF
Failures:
1) Container can add items
Failure/Error: let(:pasta) { Item.new(5) }
NameError:
uninitialized constant Item
# ./container_spec.rb:20:in `block (2 levels) in <top (required)>'
# ./container_spec.rb:25:in `block (2 levels) in <top (required)>'
2) Container does not add items that would exceed the weight limit
Failure/Error: let(:pasta) { Item.new(5) }
NameError:
uninitialized constant Item
# ./container_spec.rb:20:in `block (2 levels) in <top (required)>'
# ./container_spec.rb:32:in `block (2 levels) in <top (required)>'
Finished in 0.00088 seconds (files took 0.23031 seconds to load)
2 examples, 2 failures
Failed examples:
rspec ./container_spec.rb:24 # Container can add items
rspec ./container_spec.rb:31 # Container does not add items that would exceed the weight limit
The tests fail because we haven't defined Item
yet. Let's do that now (still inside container_spec.rb
for now):
# ...
class Item
attr_reader :weight
def initialize(weight)
@weight = weight
end
end
describe Container do
let(:container) { Container.new }
let(:pasta) { Item.new(5) }
let(:cake) { Item.new(5) }
let(:house) { Item.new(20) }
# ...
end
$ rspec container_spec.rb
.F
Failures:
1) Container does not add items that would exceed the weight limit
Failure/Error: expect(container.items).to_not include house
expected [#<Item:0x007fa7481ab5e8 @weight=5>, #<Item:0x007fa7481ab138 @weight=20>] not to include #<Item:0x007fa7481ab138 @weight=20>
Diff:
@@ -1,2 +1,2 @@
-[#<Item:0x007fa7481ab138 @weight=20>]
+[#<Item:0x007fa7481ab5e8 @weight=5>, #<Item:0x007fa7481ab138 @weight=20>]
# ./container_spec.rb:41:in `block (2 levels) in <top (required)>'
Finished in 0.1253 seconds (files took 0.26481 seconds to load)
2 examples, 1 failure
Failed examples:
rspec ./container_spec.rb:37 # Container does not add items that would exceed the weight limit
Our implementation of the item_too_heavy?
method only checks if an item
is equal to "house". Since we're now using Items
, and not strings, this test fails! Let's perform a similar check, but this time look for an item
's weight being equal to 20 (the same as the house).
class Container
attr_reader :items
def initialize
@items = []
end
def add(item)
items << item unless item_too_heavy? item
end
private
def item_too_heavy? item
item.weight == 20
end
end
# ...
$ rspec container_spec.rb
..
Finished in 0.00213 seconds (files took 0.22381 seconds to load)
2 examples, 0 failures
Now we have some passing tests we can refactor our item_too_heavy?
method. The container needs to know about its weight limit to check if an item is too heavy.
class Container
attr_reader :items
def initialize
@items = []
end
def add(item)
items << item unless item_too_heavy? item
end
private
def item_too_heavy? item
weight_limit = 19
item.weight > weight_limit
end
end
# ...
$ rspec test.rb
..
Finished in 0.00234 seconds (files took 0.22688 seconds to load)
2 examples, 0 failures
Cool, we have our item_too_heavy?
method taking in to account the container's weight limit.
Since the tests are passing, we can look into cleaning this method up. One thing that jumps out here is that we aren't storing what the container's current weight is. That's important, so let's write a test for it.
# ...
describe Container do
# ...
it "updates the weight when an item is added" do
container.add pasta
expect(container.weight).to eq pasta.weight
container.add cake
expect(container.weight).to eq pasta.weight + cake.weight
end
end
$ rspec container_spec.rb
..F
Failures:
1) Container updates the weight when an item is added
Failure/Error: expect(container.weight).to eq pasta.weight
NoMethodError:
undefined method `weight' for #<Container:0x007fae3f1764d0>
# ./container_spec.rb:47:in `block (2 levels) in <top (required)>'
Finished in 0.0026 seconds (files took 0.23364 seconds to load)
3 examples, 1 failure
Failed examples:
rspec ./container_spec.rb:45 # Container updates the weight when an item is added
The Container
class doesn't know about its current weight, so the test is failing. Let's add a weight
attribute to the Container
.
class Container
attr_reader :items, :weight
def initialize
@items = []
@weight = 0
end
def add(item)
items << item unless item_too_heavy? item
end
private
def item_too_heavy? item
weight_limit = 19
weight + item.weight > weight_limit
end
end
# ...
$ rspec container_spec.rb
..F
Failures:
1) Container updates the weight when an item is added
Failure/Error: expect(container.weight).to eq pasta.weight
expected: 5
got: 0
(compared using ==)
# ./container_spec.rb:48:in `block (2 levels) in <top (required)>'
Finished in 0.16023 seconds (files took 0.26932 seconds to load)
3 examples, 1 failure
Failed examples:
rspec ./container_spec.rb:46 # Container updates the weight when an item is added
Although we're storing the weight on the container, we're never updating the value when we add items. This is why we expected a weight of 5 when it was actually 0.
class Container
# ...
def add(item)
items << item unless item_too_heavy? item
@weight += item.weight
end
# ...
end
# ...
$ rspec container_spec.rb
...
Finished in 0.00337 seconds (files took 0.26162 seconds to load)
3 examples, 0 failures
At this point our add
method is looking OK, and I'm happy to move on. We can now look into refactoring other parts of the class. I think item_too_heavy?
would be a good next step. It breaks the Single Responsibilty Principle in that it's responsible for setting the weight limit of the container and checking if an item is too heavy. By extracting this assignment into the constructor we are ensuring that item_too_heavy?
only has one reason to change.
class Container
attr_reader :items, :weight
def initialize
@items = []
@weight = 0
@weight_limit = 19
end
def add item
items << item unless item_too_heavy? item
@weight += item.weight
end
private
def item_too_heavy? item
weight + item.weight > @weight_limit
end
end
# ...
$ rspec container_spec.rb
...
Finished in 0.00394 seconds (files took 0.21574 seconds to load)
3 examples, 0 failures
That's a bit nicer, but I'm still not really happy. We're hard coding the weight limit of our containers, which introduces a reason for the class to change in the future. Say Barry wants to update the weight limit to 15, he might be tempted to dive into the class and change the value. This introduces a risk that he'll accidentally break something while he's in there (you know what he's like). A more robust approach would be to inject the value of the container weight limit, that way Barry can have containers of varying weight limits without having to modify the Container
class. Score!
Let's update the tests to inject the weight limit into the container
when we instantiate it.
describe Container do
let(:container) { Container.new(weight_limit: 19) }
let(:pasta) { Item.new(5) }
let(:cake) { Item.new(5) }
let(:house) { Item.new(20) }
# ...
end
$ rspec container_spec.rb
FFF
Failures:
1) Container can add items
Failure/Error:
def initialize
@items = []
@weight = 0
@weight_limit = 19
end
ArgumentError:
wrong number of arguments (given 1, expected 0)
# ./container_spec.rb:4:in `initialize'
# ./container_spec.rb:28:in `new'
# ./container_spec.rb:28:in `block (2 levels) in <top (required)>'
# ./container_spec.rb:34:in `block (2 levels) in <top (required)>'
2) Container does not add items that would exceed the weight limit
Failure/Error:
def initialize
@items = []
@weight = 0
@weight_limit = 19
end
ArgumentError:
wrong number of arguments (given 1, expected 0)
# ./container_spec.rb:4:in `initialize'
# ./container_spec.rb:28:in `new'
# ./container_spec.rb:28:in `block (2 levels) in <top (required)>'
# ./container_spec.rb:41:in `block (2 levels) in <top (required)>'
3) Container updates the weight when an item is added
Failure/Error:
def initialize
@items = []
@weight = 0
@weight_limit = 19
end
ArgumentError:
wrong number of arguments (given 1, expected 0)
# ./container_spec.rb:4:in `initialize'
# ./container_spec.rb:28:in `new'
# ./container_spec.rb:28:in `block (2 levels) in <top (required)>'
# ./container_spec.rb:48:in `block (2 levels) in <top (required)>'
Finished in 0.00117 seconds (files took 0.21716 seconds to load)
3 examples, 3 failures
Failed examples:
rspec ./container_spec.rb:33 # Container can add items
rspec ./container_spec.rb:40 # Container does not add items that would exceed the weight limit
rspec ./container_spec.rb:47 # Container updates the weight when an item is added
Cool, we broke everything. Let's update the initialize
method in Container
to accept the weight limit.
class Container
attr_reader :items, :weight
def initialize(weight_limit:)
@items = []
@weight = 0
@weight_limit = weight_limit
end
# ...
end
# ...
$ rspec container_spec.rb
...
Finished in 0.01101 seconds (files took 0.24302 seconds to load)
3 examples, 0 failures
Now that the tests are passing, we can perform more clean up. For example, we used a named argument for weight_limit
in Container
. Let's do the same thing for Item
and its weight
.
First, the tests…
# ...
describe Container do
let(:container) { Container.new(weight_limit: 19) }
let(:pasta) { Item.new(weight: 5) }
let(:cake) { Item.new(weight: 5) }
let(:house) { Item.new(weight: 20) }
# ...
end
$ rspec container_spec.rb
FFF
Failures:
1) Container can add items
Failure/Error: weight + item.weight > @weight_limit
TypeError:
Hash can't be coerced into Integer
# ./container_spec.rb:18:in `+'
# ./container_spec.rb:18:in `item_too_heavy?'
# ./container_spec.rb:11:in `add'
# ./container_spec.rb:34:in `block (2 levels) in <top (required)>'
2) Container does not add items that would exceed the weight limit
Failure/Error: weight + item.weight > @weight_limit
TypeError:
Hash can't be coerced into Integer
# ./container_spec.rb:18:in `+'
# ./container_spec.rb:18:in `item_too_heavy?'
# ./container_spec.rb:11:in `add'
# ./container_spec.rb:41:in `block (2 levels) in <top (required)>'
3) Container updates the weight when an item is added
Failure/Error: weight + item.weight > @weight_limit
TypeError:
Hash can't be coerced into Integer
# ./container_spec.rb:18:in `+'
# ./container_spec.rb:18:in `item_too_heavy?'
# ./container_spec.rb:11:in `add'
# ./container_spec.rb:48:in `block (2 levels) in <top (required)>'
Finished in 0.00106 seconds (files took 0.29553 seconds to load)
3 examples, 3 failures
Failed examples:
rspec ./container_spec.rb:33 # Container can add items
rspec ./container_spec.rb:40 # Container does not add items that would exceed the weight limit
rspec ./container_spec.rb:47 # Container updates the weight when an item is added
Now we can fix up the Item
class by updating the initialize
method, just like we did for the Container
class.
# ...
class Item
attr_reader :weight
def initialize(weight:)
@weight = weight
end
end
# ...
$ rspec container_spec.rb
...
Finished in 0.01256 seconds (files took 0.25995 seconds to load)
3 examples, 0 failures
Time to refactor!
This is what our Container
's item_too_heavy?
method currently looks like:
def item_too_heavy? item
weight + item.weight > @weight_limit
end
Notice that we aren't using an accessor method to get the weight limit; we are calling @weight_limit
to access it directly. Although this is fine here, it's generally best to use an accessor because it changes the statement from calling data (@weight_limit
) to calling behaviour (weight_limit
, the attr_reader
method). This is useful because it means there is one place in our code that defines what it means to be a weight_limit
. This perhaps sounds pedantic, but what if (for safety reasons) there was an update to weight limits, which meant that they were reduced by 5 when the inspectors were around. We would need to find everywhere we were calling the data @weight_limit
and subtract 5 if the inspectors were near. We'd be duplicating behaviour and it'd be likely that something would break. By using an accessor method we could simply override the weight_limit
method to include the suitable logic, and our other methods that relied on the accessor would not need to change.
Let's make use of Ruby's accessor methods to get the weight limit.
class Container
attr_reader :items, :weight, :weight_limit
def initialize(weight_limit:)
@items = []
@weight = 0
@weight_limit = weight_limit
end
def add item
items << item unless item_too_heavy? item
@weight += item.weight
end
private
def item_too_heavy? item
weight + item.weight > weight_limit
end
end
# ...
$ rspec container_spec.rb
...
Finished in 0.00201 seconds (files took 0.13739 seconds to load)
3 examples, 0 failures
While we're on removing unnecessary direct calls to instance variables, how about we clean up the add
method by replacing
@weight += item.weight
with a call to an accessor method too?
class Container
attr_reader :items, :weight, :weight_limit
def initialize(weight_limit:)
@items = []
@weight = 0
@weight_limit = weight_limit
end
def add item
items << item unless item_too_heavy? item
self.weight += item.weight
end
private
def item_too_heavy? item
weight + item.weight > weight_limit
end
def weight=(value)
@weight = value
end
end
# ...
$ rspec container_spec.rb
...
Finished in 0.00586 seconds (files took 0.28069 seconds to load)
3 examples, 0 failures
At this point, our container_spec.rb
file is getting pretty hefty, so let's extract out the classes into their own files.
# container.rb
class Container
attr_reader :items, :weight, :weight_limit
def initialize(weight_limit:)
@items = []
@weight = 0
@weight_limit = weight_limit
end
def add item
items << item unless item_too_heavy? item
self.weight += item.weight
end
private
def item_too_heavy? item
weight + item.weight > weight_limit
end
def weight=(value)
@weight = value
end
end
# item.rb
class Item
attr_reader :weight
def initialize(weight:)
@weight = weight
end
end
# container_spec.rb
require "./container"
require "./item"
describe Container do
let(:container) { Container.new(weight_limit: 19) }
let(:pasta) { Item.new(weight: 5) }
let(:cake) { Item.new(weight: 5) }
let(:house) { Item.new(weight: 20) }
it "can add items" do
container.add pasta
container.add cake
expect(container.items).to include pasta
expect(container.items).to include cake
end
it "does not add items that would exceed the weight limit" do
container.add pasta
container.add house
expect(container.items).to include pasta
expect(container.items).to_not include house
end
it "updates the weight when an item is added" do
container.add pasta
expect(container.weight).to eq pasta.weight
container.add cake
expect(container.weight).to eq (pasta.weight + cake.weight)
end
end
$ rspec container_spec.rb
...
Finished in 0.00203 seconds (files took 0.11261 seconds to load)
3 examples, 0 failures
Amazing, the tests still pass! Container Industries Ltd are proud of your hard work and you've been rewarded with an almost brand new tupperware container.
I really enjoy the TDD approach. In the past I've found myself trying to implement all the features at once and getting in too deep quite early on. In the end the code coverage was usually not as high and the code quality was lower than when I used TDD.
Do you use TDD regularly? What do you think of it? Do you use an alternative approach?