The VCR gem is really handy for testing calls to external web services, such as AWS commands made through Fog. You make your call for real once and VCR records a new “cassette” of the request and response. Subsequent calls use the cassette, so VCR plays back the response it recorded instead of actually reaching out and touching the external service. It’s good for testing how your own program makes requests and how it handles the responses it gets back. I recently had a need for similar functionality but with command line tools. Namely, the ec2-cmd script for importing an instance into Amazon.

ec2-cmd is a script you can use to import a local virtual machine into Amazon EC2. As you can imagine, this process takes a while because several gigabytes of data need to be uploaded. I have a script that makes this call and does other stuff based on the output from ec2-cmd. I needed to test how my script handles different output.

act-like.rb

I found this post about mocking shell scripts. The act-like.sh was exactly what I needed, but Bash isn’t very readable to me and there were also little inconsistencies like md5sum is used in Linux while md5 is what my MacBook had. I rewrote his script in Ruby to normalize things:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/usr/bin/env ruby
require 'digest/md5'
require 'open3'
require 'fileutils'

command = ARGV.join(' ')
program = File.basename(ARGV.shift)
# Compute a hash of the command being run, including its arguments:
hash = Digest::MD5.hexdigest(program + ARGV.join(' '))
test_data_dir = File.expand_path(File.join(File.dirname(__FILE__), '..'))
fixtures = File.join(test_data_dir, program, hash)
stdout_path = File.join(fixtures, 'stdout')
stderr_path = File.join(fixtures, 'stderr')
exit_code_path = File.join(fixtures, 'exit_code')

# If we can't find a directory for this particular command, we need to
# record new cassettes.
unless Dir.exists?(fixtures)
  FileUtils.mkdir_p fixtures
  stdin, stdout, stderr, wait_thr = Open3.popen3(command)
  stdin.close # Assume no further input necessary for the command
  output = stdout.read
  error = stderr.read
  exit_code = wait_thr.value.exitstatus
  # Write cassettes:
  File.open(stdout_path, 'w') {|file| file.puts output }
  File.open(stderr_path, 'w') {|file| file.puts error }
  File.open(exit_code_path, 'w') {|file| file.puts exit_code }
end

# This script responds just as the actual program did, writing the same
# data to stdout and stderr, exiting with the same exit code.
$stdout.print File.read(stdout_path)
$stderr.print File.read(stderr_path)
exit File.read(exit_code_path).to_i

Testing with Rspec

My project is tested with Rspec. I put act-like.rb in spec/data/bin and ran chmod 755 act-like.rb to make it executable. Recorded cassettes end up in spec/data, e.g., spec/data/ec2-cmd. For every expensive or long-running shell script I want to record, I write a simple script of the same name to mock it and put it in spec/data/bin. For ec2-cmd I wrote spec/data/bin/ec2-cmd:

1
2
#!/usr/bin/env bash
act-like.rb $EC2_HOME/bin/ec2-cmd "$@"

This runs act-like.rb instead of the actual script. act-like.rb figures out whether it needs to actually run ec2-cmd (the first time it is ever run with that particular set of parameters) or if it should just replay an existing cassette of stdout, stderr, and exit code. "$@" just passes along all parameters that were given.

I modified my Ruby script that makes the call to ec2-cmd to take an optional string of environment setup, env:

1
command = "#{env} ec2-cmd ImportInstance #{other_params}"

Normally, env is an empty string, but in my test, I set it to:

1
env = "PATH=#{Rails.root.join('spec', 'data', 'bin')}:$PATH"

If you aren’t working in a Rails app, obviously you’d replace Rails.root with something else. Setting the path like this causes my custom spec/data/bin/ec2-cmd to be run instead of the actual ec2-cmd, so act-like.rb takes over.

Caveats

act-like.rb won’t be helpful if you care about local side effects of scripts, such as files being created or moved around. It’s just useful when you want to test how your app responds to stdout, stderr, and exit code values.