In Progress
Unit 1, Lesson 1
In Progress

StringIO Test Fake

Video transcript & code

In Episode #398, we got our first proper introduction to the StringIO standard library. But we only saw one example usage for it. Today I want to demonstrate another area where I find this library to be useful: in tests.

Let's say we have a method called copy_with_backup. Its job is to copy an input file to both an output file and a backup file.

Some other method is responsible for finding and opening the appropriate files. This method is only responsible for performing the copies. In order to do the copying efficiently, it uses IO.copy_stream, which we first encountered in Episode #74.

Note that it also uses the IO method #rewind, in order to reset the input back to the beginning before the second copy.

def copy_with_backup(input, output, backup)
  IO.copy_stream(input, output)
  input.rewind
  IO.copy_stream(input, backup)
end

Now let's say we want to test this method.

We could do it by creating a test input file, and then opening up two test output files and passing their IO objects in. But this would be a lot of test code. We'd have to be very careful to clean up the files afterwards. And working with real files is likely to slow our test suite down if we use that technique for more than a few tests.

require "rspec/autorun"

RSpec.describe "copy_with_backup" do
  it "copies input to output and backup streams" do

  end
end

But what else can we use? Those copy_stream calls are expecting either real IO objects, or something which behaves exactly like them. Not to mention whatever we pass in as input must respond to the #rewind method.

If you've watched Episode #398, you probably know what's coming next. Ruby provides the StringIO class to act as a stand-in for an IO object, one that backed by a simple Ruby string.

Let's apply what we know about StringIO to testing the #copy_with_backup method.

We'll create three StringIO objects: one to stand-in for the input file, complete with some sample text.

A blank one to stand-in for the output file.

And another blank one to stand in for the backup.

Then we'll exercise the method under test, using these fake files objects.

Finally, we'll verify that the string contents of the output and backup StringIO objects matches the contents of the input.

The result is a passing test.

require "rspec/autorun"
require "./copy_with_backup"
require "stringio"

RSpec.describe "copy_with_backup" do
  it "copies input to output and backup streams" do
    input = StringIO.new("SOURCE TEXT")
    output = StringIO.new
    backup = StringIO.new
    copy_with_backup(input, output, backup)
    expect(output.string).to eq("SOURCE TEXT")
    expect(backup.string).to eq("SOURCE TEXT")
  end
end

# >> .
# >>
# >> Finished in 0.00223 seconds (files took 0.13607 seconds to load)
# >> 1 example, 0 failures
# >>

This code runs fast, and doesn't require any special cleanup.

Now, you might be thinking that we're using these StringIO objects as "mock" or "stub" objects. But I want to draw a distinction here.

You might remember from Episode #287 that the cardinal rule of mock objects is: only mock what you own. The IO family of objects is a great example of something we don't own. The Ruby implementation owns those objects.

So rather than trying to mock IO objects, we've instead replaced one thing that the system provides, with a different thing that the system also provides.

In this context, StringIO functions effectively as a test fake: we can use it to stand in for file objects, without knowing or caring what parts of a file object copy_stream makes use of. We just know that the system promises that StringIO is interchangeable with other IO objects, and that's good enough for us.

Happy hacking!

Responses