Let’s say you need to write a Ruby program that takes in a CSV of number pairs and outputs another CSV with those number pairs summed. Easy. You whip this up in 5 minutes:

#!/usr/bin/env ruby
require 'csv'

in_file_path = ARGV[0]
out_file_path = ARGV[1]

# Sum each pair of numbers
sums = CSV.readlines(in_file_path).map do |num1, num2|
  num1.to_i + num2.to_i
end

# Write the result to a file
CSV.open(out_file_path, "wb") do |csv|
  sums.each { |sum| csv << [sum] }
end

This works really well. Running ./add_numbers_untestable.rb numbers.csv sums.csv takes in

10,10
15,10
20,10
11,11
22,22

and outputs

20
25
30
22
44

You commit the code and push it to master, satisfied with a job well done. As it turns out, the script is extremely helpful to your coworkers. They begin to use it on a daily basis, and as it becomes more popular, you realize that you should probably write specs for it to make sure it doesn’t break in the future.

You quickly realize that this code is untestable. How can you call a script from RSpec? How can you pass in a fake input file and output file? How can you make sure the numbers are being summed properly?

Making It Testable

To write a spec for the script, we first need to make some modifications to the script to make it easy to test. RSpec is good at testing classes, so let’s put the business logic of the script into a class:

#!/usr/bin/env ruby
require 'csv'

class AddNumbers
  attr_accessor :in_file_path, :out_file_path

  def initialize(args)
    @in_file_path = args[0]
    @out_file_path = args[1]
  end

  def run
    # Business logic goes here
  end
end

# Use Ruby constants to make the file runnable from the command line
if $PROGRAM_NAME == __FILE__
  AddNumbers.new(ARGV).run
end

Now it’s easy to call the script from a spec. We’ve parameterized the path of the input file and the path of the output file, we’ve asked the caller of the class to supply the array of arguments rather than using the constant ARGV, and we’ve retained the ability to run the script from the command line using Ruby constants. To call the script from a spec, we now simply need to do the following:

require_relative './add_numbers.rb'
require 'rspec'

RSpec.describe AddNumbers do
  subject { described_class.new(arguments).run }

  let(:arguments) { ['test.csv', 'test_out.csv'] }

  it 'runs' do
    subject
  end
end

Filling out the business logic gives us:

#!/usr/bin/env ruby
require 'csv'

class AddNumbers
  attr_accessor :in_file_path, :out_file_path

  def initialize(args)
    @in_file_path = args[0]
    @out_file_path = args[1]
  end

  def run
    number_pairs.each do |pair|
      out_file << [pair[0].to_i + pair[1].to_i]
    end

    out_file.close
  end

  private

  def number_pairs
    @number_pairs ||= CSV.readlines(in_file_path)
  end

  def out_file
    @out_file ||= CSV.open(out_file_path, 'wb')
  end
end

# Use Ruby constants to make the file runnable from the command line
if $PROGRAM_NAME == __FILE__
  AddNumbers.new(ARGV).run
end

Faking the Files

Running the above spec fails because test.csv doesn’t exist. We need some way of providing a fake file to the spec. There are numerous ways to do this, from using double to mock the filesystem, to including a gem that mocks the filesystem completely. The best way I’ve found, however, is to use actual files that live only for the duration of the spec. Ruby’s Tempfile is perfect for this.

For the output file, all we need to do is create a blank Tempfile:

let(:test_out_file) { Tempfile.new('csv') }

For the input file, we need to write pairs of numbers to it when it is created. We can call tap on the file when it’s created and write rows of numbers to it in the spec:

let(:test_in_file) do
  Tempfile.new('csv').tap do |f|
    pairs.each do |pair|
      f << pair.join(',') + "\r"
    end

    f.close
  end
end

let(:pairs) do
  [
    [10,10],
    [20,40],
    [30,50]
  ]
end

As the Tempfile documentation suggests, we call unlink on the files after each spec to ensure that the files get deleted.

after do
  test_in_file.unlink
  test_out_file.unlink
end

And that’s all we need to test the script on arbitrary sets of numbers! The complete spec with one example is below:

require_relative './add_numbers.rb'
require 'rspec'
require 'tempfile'

RSpec.describe AddNumbers do
  subject { described_class.new(arguments).run }

  let(:arguments) { [test_in_file.path, test_out_file.path] }

  let(:test_out_file) { Tempfile.new('csv') }
  let(:test_in_file) do
    Tempfile.new('csv').tap do |f|
      pairs.each do |pair|
        f << pair.join(',') + "\r"
      end

      f.close
    end
  end

  let(:pairs) do
    [
      [10,10],
      [20,40],
      [30,50]
    ]
  end

  after do
    test_in_file.unlink
    test_out_file.unlink
  end

  it 'writes the sums to a file' do
    subject
    expect(CSV.open(test_out_file.path).readlines).to eq(
      [["20"], ["60"], ["80"]]
    )
  end
end

Testing the Edge Cases

What was the point of writing all of that setup if we’re only going to have one example? Using let to set up the spec variables makes it easy to test out the edge cases of our script. For an example, we’ll add a pair of numbers where one is negative to make sure the script still works:

context 'with negative numbers in the input file' do
  let(:pairs) { super() << [-9, 20] }

  it 'writes the sums to a file' do
    subject
    expect(CSV.open(test_out_file.path).readlines).to eq(
      [["20"], ["60"], ["80"], ["11"]]
    )
  end
end

Luckily, it still works. And we can be confident that it will as long as the specs continue to pass.