Faking I/O with StringIO

Long before Ruby on Rails came along, the first web applications were simple command-line scripts that wrote their content to the console. This Common Gateway Interface (CGI) architecture made it possible to build dynamic websites in nearly any language.[114] All you had to do was read your input from environment variables and write the resulting web page to stdout.

Here’s a CGI script that functions as a simple little Ruby documentation server. If you were to hook this code up to a local web server and visit http://localhost/String/each, it would return a JSON array of all the String methods that begin with each: ["each_byte", "each_char", ...].

 require ​'json'
 
 class​ RubyDocServer
 def​ initialize(​output: ​$stdout)
  @output = output
 end
 
 def​ process_request(path)
  class_name, method_prefix = path.sub(​%r{^/}​, ​''​).split(​'/'​)
  klass = Object.const_get(class_name)
  methods = klass.instance_methods.grep(​/A​​#{​method_prefix​}​​/​).sort
  respond_with(methods)
 end
 
 private
 
 def​ respond_with(data)
  @output.puts ​'Content-Type: application/json'
  @output.puts
  @output.puts JSON.generate(data)
 end
 end
 
 if​ ​__FILE__​.end_with?($PROGRAM_NAME)
  RubyDocServer.new.process_request(ENV[​'PATH_INFO'​])
 end

The web server puts whatever path you visit, such as /String/each, into the PATH_INFO environment variable. We break this text into the String class and the each prefix, get a list of the instance methods that belong to String, and finally narrow them down to the ones that start with each.

We’ve already applied the lessons from earlier in this chapter and injected the output collaborator via constructor injection. It might be tempting to pass in a spy from our tests so that we could check that the CGI script was writing the correct results:

 require ​'ruby_doc_server'
 
 RSpec.describe RubyDocServer ​do
 it​ ​'finds matching ruby methods'​ ​do
  out = get(​'/Array/max'​)
 
 expect​(out).to have_received(​:puts​).with(​'Content-Type: application/json'​)
 expect​(out).to have_received(​:puts​).with(​'["max","max_by"]'​)
 end
 
 def​ get(path)
  output = object_spy($stdout)
  RubyDocServer.new(​output: ​output).process_request(path)
  output
 end
 end

Unfortunately, this spec is quite brittle. It’s coupled not just to the content of the web response, but also to exactly how it gets written.

Ruby’s IO interface is large. It provides several methods just for writing output: puts, print, write, and more. If we refactor our implementation to call write, or even to call puts just once with the entire response, our specs will break. Recall that one of the goals of TDD is to support refactoring. Instead, these specs will stand in our way.

Test Doubles Are Best for Small, Simple, Stable Interfaces

images/aside-icons/info.png

Large interfaces aren’t the only ones that are hard to replace with a double. We also see problems in the following cases:

  • Complex interfaces: The more complex the interface, the harder it is to mimic accurately; we like to use test doubles to steer our design toward simplicity.

  • Unstable interfaces: Every message you expect or allow is a detail your specs are coupled to; the more the interface changes, the more you’re going to have to update your doubles.

Instead of expecting specific IO method calls, we can use the StringIO high-fidelity fake from the Ruby standard library. StringIO objects exist in memory, but act like any other Ruby IO object, such as an open file or Unix pipe.

You can test input-handling code by initializing a StringIO with data and letting your code read from it. Or you can test your output code by letting your code write to a StringIO, and then inspecting the contents via its string method. Here’s how this test looks with a StringIO object injected into the RubyDocServer:

 require ​'ruby_doc_server'
 require ​'stringio'
 
 RSpec.describe RubyDocServer ​do
 it​ ​'finds matching ruby methods'​ ​do
  result = get(​'/Array/min'​)
 
 expect​(result.split(​"​​ ​​"​)).to eq [
 'Content-Type: application/json'​,
 ''​,
 '["min","min_by","minmax","minmax_by"]'
  ]
 end
 
 def​ get(path)
  output = StringIO.new
  RubyDocServer.new(​output: ​output).process_request(path)
  output.string
 end
 end

Now, we’re setting expectations on the contents of the response, rather than on how it was produced. This practice results in much less brittle specs.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset