Ruby Mock Web Server

I spent the afternoon today working with Sarndeep, our very smart automated test guy. He’s been working on extending what we can do with rspec to cover testing of some more interesting things.

Last week he and Elliot put together a great set of tests using MailTrap to confirm that we’re sending the right mails to the right addresses under the right conditions. Nice tests to have for a web app that generates email in a few cases.

This afternoon we were working on a mock web server. We use a lot of RESTful services in what we’re doing and being able to test our app for its handling of error conditions is important. We’ve had a static web server set up for a while, this has particular requests and responses configured in it, but we’ve not really liked it because the responses are all separate from the tests and the server is another apache vhost that has to be setup when you first checkout the app.

So, we’d decided a while ago that we wanted to put in a little Ruby based web server that we could control from within the rspec tests and that’s what we built a first cut of this afternoon.

require File.expand_path(File.dirname(__FILE__) + "/../Helper")
require 'rubygems'
require 'rack'
require 'thin'
class MockServer
  def initialize()
    @expectations = []
  end
  def register(env, response)
    @expectations << [env, response]
  end
  def clear()
    @expectations = []
  end
  def call(env)
    #puts "starting call\n"
    @expectations.each_with_index do |expectation,index|
      expectationEnv = expectation[0]
      response = expectation[1]
      matched = false
      #puts "index #{index} is #{expectationEnv} contains #{response}\n\n"
      expectationEnv.each do |envKey, value|
        puts "trying to match #{envKey}, #{value}\n"
        matched = true
        if value != env[envKey]
          matched = false
          break
        end
      end
      if matched
        @expectations.delete_at(index)
        return response
      end
    end
    #puts "ending call\n"
  end
end
mockServer = MockServer.new()
mockServer.register( { 'REQUEST_METHOD' => 'GET' }, [ 200, { 'Content-Type' => 'text/plain', 'Content-Length' => '11' }, [ 'Hello World' ]])
mockServer.register( { 'REQUEST_METHOD' => 'GET' }, [ 200, { 'Content-Type' => 'text/plain', 'Content-Length' => '11' }, [ 'Hello Again' ]])
Rack::Handler::Thin.run(mockServer, :P ort => 4000)

The MockServer implements the Rack interface so it can work within the Thin web server from inside the rspec tests. The expectations are registered with the MockServer and the first parameter is simply a hashtable in the same format as the Rack Environment. You only specify the entries that you care about, any that you don’t specify are not compared with the request. Expectations don’t have to occur in order (expect where the environment you give is ambiguous, in which case they match first in first matched).

As a first venture into writing more in Ruby than an rspec test I have to say I found it pretty sweet – There was only one issue with getting at array indices that tripped me up, but Ross helped me out with that and it was pretty quickly sorted.

Plans for this include putting in a verify() and making it thread safe so that multiple requests can come in parallel. Any other suggestions (including improvements on my non-idiomatic code) very gratefully received.

9 thoughts on “Ruby Mock Web Server

  1. Small rewrite which adds:

    * Ability to specify an expectation as not being transient – i.e. not deleted after its response is served
    * Run in a thread after instantiation (makes tests simpler to write)
    * Facility to pass a lambda in as the expectation, to execute the value from the request against – allows you to do arbitrary matching of request elements against expectations

    require ‘rubygems’
    require ‘rack’
    require ‘thin’

    # Mock server for testing bad or heavyweight server responses; runs on localhost:4000 by default
    #
    # Example of use
    #
    # In setup method:
    #
    # @mock_server = MockServer.new # creating a new instance spawns a thread running the TCP server
    # @mock_server.register( { ‘REQUEST_METHOD’ => ‘GET’ }, [ 200, { 'Content-Type' => 'text/plain', 'Content-Length' => '11' }, [ 'Hello World' ]])
    # @mock_server.register( { ‘REQUEST_METHOD’ => ‘GET’ }, [ 200, { 'Content-Type' => 'text/plain', 'Content-Length' => '11' }, [ 'Hello Again' ]])
    #
    # After each test, to remove all expectations:
    #
    # @mock_server.clear
    #
    # In teardown method:
    #
    # @mock_server.stop

    class MockServer

    def initialize(options={})
    host = options[:host] || ’127.0.0.1′
    port = options[:port] || 4000
    @expectations = []
    @server = Thin::Server.new(host, port, self)
    @thread = Thread.new { @server.start }
    end

    def stop
    @server.stop!
    Thread.kill(@thread)
    end

    # env should be a hash mapping elements of a Rack env to expected values (see examples above);
    # note that an expected value can be a Proc which will be passed the value from the request
    # and executed – if the Proc returns true on execution, the expectation is met
    #
    # For example, to check that the querystring contains the value ’1234′, env could be:
    #
    # { ‘QUERY_STRING’ => lambda { |qs| !((qs =~ /1234/).nil?) } }
    #
    # response should be a Rack-formatted response; i.e. [response_code, {'header' => 'value', ...}, response_body]
    #
    # options:
    # :transient => false to prevent a response being removed after it has been served (default is true)
    def register(env, response, options={})
    transient = options[:transient]
    transient = true if transient.nil?

    @expectations < ‘text/plain’}, “Bad, bad, bad – couldn’t map request to expectation”]

    @expectations.each_with_index do |expectation, index|
    expectation_env, response, transient = expectation
    matched = false

    expectation_env.each do |env_key, value|
    puts “Trying to match #{env_key} => #{value} to request”
    matched = true

    req_value = env[env_key]

    if value.is_a? Proc
    req_element_matches = value.call(req_value)
    else
    req_element_matches = (value == req_value)
    end

    unless req_element_matches
    puts ” Value NOT matched: request value was #{env[env_key]} (needed #{value} to match)”
    matched = false
    break
    end

    end

    if matched and transient
    @expectations.delete_at(index)
    break
    end
    end

    response
    end

    end

  2. Bad, bad me. My code had an error in it. Fixed in this version:

    require 'rubygems'
    require 'rack'
    require 'thin'

    # Mock server for testing bad or heavyweight server responses; runs on localhost:4000 by default
    #
    # Example of use
    #
    # In setup method:
    #
    # @mock_server = MockServer.new # creating a new instance spawns a thread running the TCP server
    # @mock_server.register( { 'REQUEST_METHOD' => 'GET' }, [ 200, { 'Content-Type' => 'text/plain', 'Content-Length' => '11' }, [ 'Hello World' ]])
    # @mock_server.register( { 'REQUEST_METHOD' => 'GET' }, [ 200, { 'Content-Type' => 'text/plain', 'Content-Length' => '11' }, [ 'Hello Again' ]])
    #
    # After each test, to remove all expectations:
    #
    # @mock_server.clear
    #
    # In teardown method:
    #
    # @mock_server.stop

    class MockServer

    def initialize(options={})
    host = options[:host] || '127.0.0.1'
    port = options[:port] || 4000
    @expectations = []
    @server = Thin::Server.new(host, port, self)
    @thread = Thread.new { @server.start }
    end

    def stop
    @server.stop!
    Thread.kill(@thread)
    end

    # env should be a hash mapping elements of a Rack env to expected values (see examples above);
    # note that an expected value can be a Proc which will be passed the value from the request
    # and executed - if the Proc returns true on execution, the expectation is met
    #
    # For example, to check that the querystring contains the value '1234', env could be:
    #
    # { 'QUERY_STRING' => lambda { |qs| !((qs =~ /1234/).nil?) } }
    #
    # response should be a Rack-formatted response; i.e. [response_code, {'header' => 'value', ...}, response_body]
    #
    # options:
    # :transient => false to prevent a response being removed after it has been served (default is true)
    def register(env, response, options={})
    transient = options[:transient]
    transient = true if transient.nil?

    @expectations < 'text/plain'}, "Bad, bad, bad - couldn't map request to expectation"]

    @expectations.each_with_index do |expectation, index|
    expectation_env, matched_response, transient = expectation
    matched = false

    expectation_env.each do |env_key, value|
    puts "Trying to match #{env_key} => #{value} to request"
    matched = true

    req_value = env[env_key]

    if value.is_a? Proc
    req_element_matches = value.call(req_value)
    else
    req_element_matches = (value == req_value)
    end

    unless req_element_matches
    puts " Value NOT matched: request value was #{env[env_key]} (needed #{value} to match)"
    matched = false
    break
    end

    end

    if matched
    if transient
    @expectations.delete_at(index)
    end
    response = matched_response
    break
    end

    end

    response
    end

    end

  3. Thanks for this! Here’s my attempt:
    - make the mock *less* clever, put the logic you require in your specs
    - eg. move testing of expectations and (programmatic) creation of rack response back into the test code using a supplied block
    - ensure exceptions in the server thread (perhaps from expectations failing in your block) are passed back to the thread running the tests, so that the tests then fail.

    Tested informally on Ruby 1.9.1
    I’ve only used it a little so far, so I’m unsure how much mileage I will get with this approach. Here’s the code:

    #
    require ‘rack’

    #
    ## Bring up server in a new thread (do once?):
    # @mock_server = MockServer.new(4000, 0.5)
    #
    #
    ## Pull down server:
    # @mock_server.stop
    #
    #
    ## Expectations (rspec example):
    # request_received = false
    # @mock_server.attach do |env|
    # request_received = true
    # env['REQUEST_METHOD'].should == ‘POST’
    # env['PATH_INFO'].should == ‘/foo’
    # [ 200, { 'Content-Type' => 'text/plain', 'Content-Length' => '40' }, [ 'This gets returned from the HTTP request' ]]
    # end
    # request_received.should be_true
    # my_code_that_should_make_post_request # to http://localhost:4000/foo
    #
    #
    ## After each test:
    # @mock_server.detach
    #
    #

    class MockServer
    def initialize(port = 4000, pause = 1)
    @block = nil
    @parent_thread = Thread.current
    @thread = Thread.new do
    Rack::Handler::WEBrick.run(self, :P ort => port)
    end

    sleep pause # give the server time to fire up… YUK!
    end

    def stop
    Thread.kill(@thread)
    end

    def attach(&block)
    @block = block
    end

    def detach()
    @block = nil
    end

    def call(env)
    begin
    raise “Specify a handler for the request using attach(block), the block should return a valid rack response and can test expectations” unless @block
    @block.call(env)
    rescue Exception => e
    @parent_thread.raise e
    [ 500, { 'Content-Type' => 'text/plain', 'Content-Length' => '13' }, [ 'Bad test code' ]]
    end
    end
    end

  4. Hi This got me thinkering and tinkering today. I’m curious about one thing. It seems a (unstated) requirement is that you can’t use an existing framework, such as Sinatra, or another? Otherwise this would be a few lines in the RSpec’s before(:all) and specing Rack’s last_response contents. Or have I misunderstood the constraints/requirements?

    @Chris Tierney: How do you invoke http://localhost:4000/foo so that MockServer#call method is given the right data? Sinatra gave me grief and ended up just using Rack::Test. Seems much simpler, exercieses the route and all I can see is that the MockServer call is never invoked – puzzled.

  5. I’d like to recant some the Rack::Test comments I made. After digging into the code I shuddered. Whetever you are exercising/testing with Rack::Test it is very likely not what you are running in production. My context was an asyncronous server and it was a nightmare. It might suffice for synchronous server code?

  6. Pingback: links for 2011-05-25 « Bloggitation

Leave a Reply

Your email address will not be published. Required fields are marked *

*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>