DEV Community

loading...

Dealing with untestable code

delbetu profile image M Bellucci Updated on ・3 min read

If I ask you to write a test for this function:

def sum(*nums)
  nums.inject(:+)
end

your answer would be immediate

describe '#sum' do
 it { expect(sum(1,2,3,4)).to eq(10) }
end

But what happens when I ask you to write a test for this code:

#### Print a rand number every three seconds ####
while sleep(3) do
  puts rand(10)
end

Stop for a moment and try to write a test for it.

You may face these difficulties:

1) This code never ends (the test will never end)
2) There are no inputs (how do I think of different cases?)
3) There are no outputs (I should assert against what?)
4) How do we execute this code?

For each of these points I would say:

1) The test must make it finish
2) False, we need to recognize them
3) False, we need to recognize them
4) We can wrap the code into a function

Generalizing our understanding of input/output

Input: Any data that can affect the result of the execution.

  • Read from database
  • Get data from an external source (api-call)
  • Return value from a collaborator (Time.now, File.read, I18n.locale)
  • Parameters
  • Shared variables (inside an object could be the instance variables)

Output:

  • Return value
  • Call collaborator
  • Side effects
    • Http call
    • Write to a Database
    • Shared variables mutated
    • Write to a file
    • Internal state mutation

Recognizing input/output

Outputs:
In the first example, the output is a return value.
In the second example, the output is a call to a collaborator self.puts.

Inputs:
In the first example, the input values are the parameters.
In the second example, the input values come from a call to a collaborator self.sleep(3) and self.rand(10).

In the first example, the input values are passed by parameter by the caller while in the second example, the input values are gathered by the code itself.

So in order to state multiple inputs' combinations, we need to mock the input gathering part of the code.

def execute
  while sleep(3) do
    puts rand(10)
  end
end

require 'rspec/autorun'

describe '#execute' do
  it 'prints a random number' do
    # Pending: Simulate input gathering
    execute
    # checking the output
    expect(self).to have_received(:puts)
  end
end

How can we simulate the input-gathering part?

def execute
  while sleep(3) do
    puts rand(10)
  end
end

require 'rspec/autorun'

describe '#execute' do
  it 'prints a random number' do
    allow(self).to receive(:sleep).and_return(true, true, false)
    rand1, rand2 = 10, 20
    allow(self).to receive(:rand).and_return(rand1, rand2)

    execute

    expect(self).to have_received(:puts).with(rand1).ordered
    expect(self).to have_received(:puts).with(rand2).ordered
  end
end

Leason learned

Why the second case wasn't trivial to test?
it is because input/output is not easy to recognize.
Would it be easier to write a test if we make it obvious by passing them as parameters?

Think now, how would you test this code?:

def execute(loop_condition, next_number, print_callback)
  while loop_condition.() do
    print_callback.(next_number.())
  end
end

execute(->{sleep(3)}, ->{rand(10)}, ->(x){puts(x)})

What do you think, is it easier to test?


require 'rspec/autorun'

describe '#execute' do
  it 'prints a random number' do
    condition = double; allow(condition).to receive(:call).and_return(true, false)
    next_number = double(call: 1234)
    print = double(call: nil)

    execute(condition, next_number, print)

    expect(print).to have_received(:call).with(1234)
  end
end

Isolating side effects into collaborator objects

Instead of isolating side effects behind Procs let's use Objects which will act as collaborators to the execute method.

  interval = Class.new { def wait; sleep(3); end  }.new
  numbers_stream = Class.new { def next; rand(10); end  }.new
  screen = Class.new { def print(n); puts(n); end }.new

  def execute(interval, numbers_stream, screen)
    while interval.wait do
      screen.print(numbers_stream.next)
    end
  end

  require 'rspec/autorun'

  describe '#execute' do
    it 'prints a random number' do
      interval = double
      allow(interval).to receive(:wait).and_return(true, true, false)

      numbers = double
      n1, n2 = 10, 20
      allow(numbers).to receive(:next).and_return(n1, n2)

      screen = double('screen', print: nil)
      execute(interval, numbers, screen)

      expect(screen).to have_received(:print).with(n1).ordered
      expect(screen).to have_received(:print).with(n2).ordered
    end
  end

Discussion

pic
Editor guide