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.
8 Comments to Ruby Mock Web Server
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
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
August 21, 2009
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
August 21, 2009
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
January 13, 2010
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.
January 16, 2010
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?
January 18, 2010
Great suggestions folks, thanks – nice ideas to think about.
Leave a comment
Additional comments powered by BackType
Search
What I'm Doing...
- @moustaki, would you recommend an equivalent to music ontology for visual recordings? 11 hrs ago
- @chriskeene Does the uni have it's own local weather system? (http://twitter.com/chriskeene/status/10314171215 and go left) in reply to chriskeene 20 hrs ago
- @_philjohn should I expect a late arrival then? in reply to _philjohn 20 hrs ago
- More updates...
Recent Comments
- Patents are Property – Like it or Not « Chasing the Power Curve on When Patents Go Wrong…
- Arizona Joe on Fixing a plasma TV
- alex_turner11 on Ground roundup of new eReaders at CES on CNN
- negative_charge on Hacking Into Your Account is as Easy as 123456
- infopeep on Hacking Into Your Account is as Easy as 123456
- BenenhaleyBrian on The 18 Mistakes That Kill Startups
- Brian Benenhaley on The 18 Mistakes That Kill Startups
- infopeep on The 18 Mistakes That Kill Startups
- Rob Styles on Ruby Mock Web Server
- Jim on Fixing a plasma TV
Categories
- .Net Technical (8)
- Blog on Blog (6)
- commands I have issued (9)
- Enterprise Architecture (19)
- event (4)
- Fiction Book Review (2)
- Food (2)
- Intellectual Property (9)
- Interaction Design (27)
- Internet Social Impact (43)
- Internet Technical (16)
- IP Law (10)
- Library Tech (19)
- Music (2)
- New Toy (4)
- Non-Fiction Book Review (7)
- Ontologies (6)
- Open Data (7)
- Other Technical (20)
- Personal (36)
- Random Thought (16)
- Resourcing (4)
- Review (1)
- Security And Privacy (11)
- Semantic Web (30)
- Software Business (10)
- Software Engineering (37)
- Talis Technical (9)
- Uncategorized (44)
- Working at Talis (26)
- [grid::blogpaper] (8)
- [grid::fatherhood] (4)
Archives
- February 2010 (1)
- January 2010 (4)
- November 2009 (10)
- October 2009 (4)
- September 2009 (2)
- August 2009 (9)
- July 2009 (12)
- June 2009 (5)
- May 2009 (6)
- April 2009 (7)
- March 2009 (3)
- February 2009 (6)
- January 2009 (10)
- December 2008 (4)
- November 2008 (4)
- October 2008 (9)
- September 2008 (23)
- August 2008 (8)
- July 2008 (1)
- June 2008 (1)
- May 2008 (6)
- April 2008 (14)
- March 2008 (3)
- January 2008 (5)
- December 2007 (6)
- November 2007 (13)
- October 2007 (9)
- July 2007 (2)
- June 2007 (1)
- May 2007 (10)
- April 2007 (5)
- March 2007 (11)
- February 2007 (10)
- January 2007 (13)
- December 2006 (8)
- November 2006 (8)
- September 2006 (2)
- August 2006 (1)
- June 2006 (2)
- February 2006 (2)
- January 2006 (3)
- December 2005 (3)
- November 2005 (2)
- September 2005 (2)
- August 2005 (5)
- July 2005 (8)
- June 2005 (3)
- May 2005 (2)
- February 2005 (1)
- January 2005 (4)
- December 2004 (3)
- November 2004 (6)
- October 2004 (2)
- September 2004 (2)
- August 2004 (5)
- July 2004 (1)
- June 2004 (4)
- May 2004 (4)
- April 2004 (3)
- March 2004 (13)
- February 2004 (6)
- December 2003 (3)
- November 2003 (1)
- August 2003 (2)
- July 2003 (1)
- June 2003 (2)
- May 2003 (1)
- March 2003 (1)
- January 2003 (1)
- October 2002 (1)
- May 2002 (1)
- March 2002 (1)
- August 2001 (1)
- May 2001 (1)
- April 2001 (1)
- January 2001 (1)
- December 2000 (1)
- November 2000 (1)
- December 1999 (1)
- November 1999 (1)
- July 1999 (1)
March 4, 2009