— Dec 08, 2015
Testing interfaces in isolation leads to good software design. Coupling becomes glaringly obvious when mocks and stubs get out of hand. You may have heard this called unit testing, but not everyone agrees that implies isolation. Names are important, but let’s not get caught up in terminology. The real benefit of isolated in tests is the affect it has on design.
Consider a duck.
1
2
3
4
5
class Duck
def speak
puts "*quack*"
end
end
To test that the duck can speak, you would have try one of:
puts
is called with the expected value.Have a look at both of these options.
1
2
3
4
5
6
describe Duck do
it "speaks" do
expect($stdout).to receive(:puts) { "*quack*" }
Duck.new.speak
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe Duck do
require "stringio"
def capture_stdout
stdout = $stdout
$stdout = StringIO.new
yield
$stdout.string
ensure
$stdout = stdout
end
it "speaks" do
output = capture_stdout { Duck.new.speak }
expect(output.chomp).to eq("*quack*")
end
end
At a cursory glance, you might side with the first option. It’s less code. Regardless, the fact is a passing test implies “a duck quacks”.
Testing that a duck quacks is more than testing that a duck speaks. It is interesting to consider that if you change what a speaking duck “sounds like” the failing test implies that a duck can no longer speak. Said differently, altering the perception of quacking destroys our understanding of the duck.
Additionally, as illustrated by the second test implementation, our duck is coupled tightly to standard output.
Both of these concerns may be addresses by applying the Single Responsibility Principal. First, identify the responsibilities:
Now that you recognize a quack as having responsibility of its own, it’s easy to imagine it being an object.
Below we implement Quack
with an injectable interface for IO.
This allows us to test the quack itself in isolation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Quack
attr_reader :io
def initialize(io: STDOUT)
@io = io
end
def utter
io.puts "*quack*"
end
end
require "stringio"
describe Quack do
it "quacks" do
io = StringIO.new
quack = Quack.new(io: io)
quack.utter
expect(io.string.chomp).to eq("*quack*")
end
end
With this new concept realized, it’s easy to imagine giving the duck a voice.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Duck
attr_reader :voice
def initialize(voice: Quack.new)
@voice = voice
end
def speak
voice.utter
end
end
describe Duck do
it "speaks" do
quack = double
duck = Duck.new(voice: quack)
expect(quack).to receive(:utter)
duck.speak
end
end
Testing that a duck speaks is just a matter of making sure that it utters its voice. This frees you to change what it means to utter a quack without affecting the means the duck uses to speak.
Of course these tests do not ensure that a duck actually quacks. Such would be done with an integration test that gives you confidence in the collaboration between objects in your system.
Isolated tests free you to refactor and encourage flexible software design.
Maybe you never considered a duck quacking to a file. Well now it’s nbd.
1
2
quackings = File.open("quackings", "w")
Duck.new(voice: Quack.new(io: quackings))