VCR for shell scripts
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
:
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.