Iamvery

The musings of a nerd


Isolated Tests

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

  1. Mock stdout and assert that puts is called with the expected value.
  2. Temporarily capture stdout and assert on it’s value.

Have a look at both of these options.

Mocking stdout

1
2
3
4
5
6
describe Duck do
  it "speaks" do
    expect($stdout).to receive(:puts) { "*quack*" }
    Duck.new.speak
  end
end

Capturing stdout

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

An Important Distinction

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.

Refactor

Both of these concerns may be addresses by applying the Single Responsibility Principal. First, identify the responsibilities:

  • a duck speaks
  • a quack sounds quacky

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.

Integration

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))

© Jay Hayes