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, :Port => 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.
Comments
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
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
Tried tags, but didn't take
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.
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, :Port => 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
My apologies. The two of the comment lines should be swapped: # my_code_that_should_make_post_request # to http://localhost:4000/foo # request_received.should be_true
Great suggestions folks, thanks - nice ideas to think about.
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?
[...] Ruby Mock Web Server (tags: ruby rack programming via:zite) [...]