View
265
Download
0
Category
Preview:
DESCRIPTION
Inside the design decisions of Bane, a test harness for sockets. This talk discusses the key design decisions of Bane, presents some code, and looks at some of Bane's automated tests.
Citation preview
Anatomy of a Ruby Gem
Bane !A test harness for server connections.
March 19, 2014 !
Daniel Wellman @wellman
dan@danielwellman.com
About Me
• Extreme Programming and Test-Driven Development since 2000
• Ruby since 2005, Rails 1.x in 2006
• Helping teams deliver working software safely and reliably for eight years by pairing and coaching (TDD, refactoring, agile development practices, etc.)
Systems Talk to Others
Our Application
FacebookGoogle Authentication
Internal domain servicesPayment
Processors
Sockets
e.g. localhost:3000
Our Application
Stock Quote Server
GOOG
Price: $465.87
Normal Response
Eventually Some System Will Behave
Unexpectedly
Our Application
Stock Quote Server
GOOG
Nobody Home
Our Application
Stock Quote Server
GOOG
... zzz ...
No Response
Our Application
Stock Quote Server
GOOG
!?
Unexpected Response
So What?
Our Application
Stock Quote Server
GOOG
... zzz ...
Photo by Ed Schipul
Our Application
Bane
GOOG
not listening
... zzz ...
Use Bane!
> gem install bane
Installation
Demo
Bane’s Goal: !
Have the common behaviors at your fingertips.
Design Strategy !
Don’t require any additional gems, so we can easily run
anywhere
Behaviors
My Goal: !
I don’t want to write my own server to get this project started
GServer !
(class in the Ruby standard library)
Any kind of protocol, from HTTP to SMTP to
something custom
require 'gserver'# # A server that returns the time in # seconds since 1970.# class TimeServer < GServer def initialize(port=10001, *args) super(port, *args) end def serve(io) io.puts(Time.now.to_s) endendserver = TimeServer.newserver.start
Great! I want to make some behaviors!
class FixedResponse < GServer def serve(io) io.write “Hello, World!” endend
Subclass?
class NeverRespond < GServer def serve(io) # ... endend
class RandomResponse < GServer def serve(io) # ... endend
I’d prefer not
class FixedResponse < GServer def serve(io) io.write “Hello, World!” endend
Testing?
Start a Server for Every Test?
or Test a Private Method?
class FixeResponseTest < Test::Unit::TestCase def test_sends_the_same_message_every_time server = FixedResponse.new(3000) server.start response = # connect to port 3000 and query assert_equal "Hello, World!”, response server.stop end end
Start a Server for Every Test?
• Uses real I/O • Testing GServer
Over and Over
Test a Private Method?
class FixedResponseTest < Test::Unit::TestCase def test_sends_the_same_message_every_time server = FixedResponse.new(3000) # call the serve() method directly server.serve(fake_connection) assert_equal "Hello, World!”, fake_connection.string endend
• Coupled to implementation
Test through the object’s public interface
Delegate!
BehaviorServer FixedResponsehas a
class BehaviorServer < GServer def initialize(port, behavior, host) super(port, host) @behavior = behavior # ... end def serve(io) @behavior.serve(io) endend
class FixedResponse def serve(io) io.write "Hello, world!" end
end
server = BehaviorServer.new(3000, FixedResponse.new, '127.0.0.1')
Test Behavior in Isolation
class FixedResponseTest < Test::Unit::TestCase def test_sends_the_same_message_every_time behavior = FixedResponse.new behavior.serve(fake_connection) assert_equal "Hello, World!", fake_connection.string endend
But how do we know the whole thing works?
TDD Loop
From Freeman & Pryce, “Growing Object-Oriented Software: Guided by Tests”
Acceptance Testsclass BaneAcceptanceTest < Test::Unit::TestCase TEST_PORT = 4000 def test_uses_specified_port_and_server run_server_with(TEST_PORT, FixedResponse) do with_response_from TEST_PORT do |response| assert !response.empty? end end end
# … !end
Acceptance Test Helpers
def run_server_with(port, behavior, &block) # ...enddef with_response_from(port) begin connection = TCPSocket.new "localhost", port yield connection.read ensure connection.close if connection endend
Write Tests in the Language of the Problem Domain
This is almost the production code…
class FixedResponse def serve(io) io.write “Hello, World!” endend
Programmatic Userequire 'bane'include Bane
behavior = Behaviors::FixedResponse.new( message: "Shall we play a game?”) launcher = Launcher.new([ BehaviorServer.new(3000, behavior)])launcher.start
# Sends a static response.# # Options:# - message: The response message to send. Default: "Hello, world!"class FixedResponse def initialize(options = {}) @options = {message: "Hello, world!”} .merge(options) end def serve(io) io.write @options[:message] endend
More Acceptance Testsdef test_serves_http_requests run_server_with(TEST_PORT, HttpRefuseAllCredentials) do assert_match /401/, status_returned_from( "http://localhost:#{TEST_PORT}/url") endend
def status_returned_from(uri) begin open(uri).read rescue OpenURI::HTTPError => e return e.message end flunk "Should have refused access"end
class HttpRefuseAllCredentials UNAUTHORIZED_RESPONSE_BODY = <<EOF<!DOCTYPE html><html>… </html>EOF def serve(io) io.gets response = NaiveHttpResponse.new( 401, "Unauthorized", “text/html", UNAUTHORIZED_RESPONSE_BODY) io.write(response.to_s) endend
Close Immediately
# Closes the connection immediately # after a connection is made.class CloseImmediately def serve(io) # do nothing endend
Echo Response
class EchoResponse def serve(io) while(input = io.gets) io.write(input) end io.close endend
NeverRespond
class NeverRespond def serve(io) sleep endend
NeverRespond
class NeverRespond def serve(io) while !io.closed? sleep 1 end endend
Photo by Sean T. Allen
New Behavior: Server is Not Listening
Socket Lifecycle
1. create
2. bind
3. listen
4. accept
5. close
Never Listen
@server = Socket.new(:INET, :STREAM) address = Socket.sockaddr_in(port, host)@server.bind(address) # Note that we never call listen
Clients that try to connect get an ECONNREFUSED error
How do we fit this into our GServer-based
code?
require 'gserver'# # A server that returns the time in # seconds since 1970.# class TimeServer < GServer def initialize(port=10001, *args) super(port, *args) end def serve(io) io.puts(Time.now.to_s) endendserver = TimeServer.newserver.start
X It’s too late in the socket lifecycle!
class NeverListen def initialize(port, host = Services::LOCALHOST) @port = port @host = host end def start @server = Socket.new(:INET, :STREAM) address = Socket.sockaddr_in(port, host) @server.bind(address) log 'started' end def stop @server.close log 'stopped' end ! # … end
Now We’re Two…
• Small server-independent behaviors that require a GServer (or something) to manage their lifecycle
• Behaviors that use low-level sockets and manage their own lifecycle
…. called what?
Services?
Behaviors?
Two Groups to Name• NeverRespond
• CloseImmediately
• FixedResponse
• EchoResponse
• RandomResponse
• …
• NeverListen
• FullListenQueue
• BehaviorServer
http://github.com/danielwellman/bane
Bane
dan@danielwellman.comTwitter: @wellman
Recommended