Summary: How can I best write tests that override socket-based communication for a library, capturing what is sent over the socket, and simulating responses based on what is sent?
Details
I'm adding a new feature to ruby-mpd, and it's extensive enough that I'd like to add tests covering many scenarios.
The library communicates with MPD using a socket connection, pushing and receiving streams of information, parsing that information and presenting it helpfully.
Although MPD is stateful, I'm not interesting in testing the stateful nature. (That's up to the MPD team.) Instead, I want to test:
- when I invoke certain code, specific strings are sent over the socket
- when I invoke that code—and my test returns a reasonable response over the socket—that the results of parsing that response are what I expect
I do not want to have to instantiate an MPD server and mock song library to run these tests.
Example
For example, I need to test that this code…
ids = @mpd.command_list(:values) do
clear
mysongs.each{ |song| addid(song) }
end
…causes this to be sent over the socket:
command_list_begin
clear
addid foo
addid bar
addid jim
command_list_end
And then I need to simulate that after sending that it receives this over the socket…
Id: 1060
Id: 1061
Id: 1062
OK
…and that, as a result, the return value ids
is [1060,1061,1062]
.
What I've Done
My current plan (which may not be the best one) is to replace the real socket with a delegator that intercepts puts
and gets
. There's a recording socket to associate streams of commands and associate them with responses. There's a playback socket that consumes this association and returns lines of responses from the previous stream of commands.
This setup does not require that sets of commands be issued in the same order, but does require that each set of commands is issued exactly the same.
Current Code
Recorder
class RecordingSocket
def initialize(real_socket)
@socket = real_socket
@current = @command = []
@responses = { @command=>[] }
end
def puts(*a)
@socket.puts(*a).tap{ @command.concat(a.empty? ? [nil] : a) }
end
def gets
@socket.gets.tap do |result|
unless @command.empty?
@current,@command=@command,[]
@responses[@current] = []
end
@responses[@current] << result
end
end
def save_recording(filename=nil)
File.open(filename || "socket_recording-#{Time.now.strftime '%Y%m%d_%H%M%S'}.marshal",'wb'){ |f| f << Marshal.dump(@responses) }
end
def method_missing(*a)
@socket.send(*a)
end
end
class RecordingMPD < MPD
def socket
@recording_socket ||= RecordingSocket.new(super)
end
def save_recording(filename=nil)
@recording_socket.save_recording(filename)
end
end
Recording Responses
m = RecordingMPD.new('music.local').tap(&:connect)
begin
songs = %w[test1.mp3 test2.mp3 test3.mp3]
m.command_list do
clear
s.each{ |f| addid(f) }
end
m.queue
m.playlists.find{ |pl| pl.name=='user-gkistner' }.songs
ensure
m.save_recording 'mpd-responses.marshal'
end
Playback Setup
require_relative '../lib/ruby-mpd'
class PlaybackSocket
def initialize(filename=nil)
@responses={}
@command = []
load_recording(filename) if filename
end
def load_recording(filename)
@responses = Marshal.load(File.open(filename,'rb',&:read))
self
end
def last_messages
@current
end
def puts(*a)
@command.concat(a.empty? ? [nil] : a)
end
def gets
@current,@command=@command,[] unless @command.empty?
if @responses[@current]
@responses[@current].shift
else
raise "PlaybackSocket has no (further) recording for #{@current}"
end
end
def method_missing(*a)
raise "PlaybackSocket has no support for #{a.shift}(#{a.map(&:inspect).join(', ')})"
end
end
class PlaybackMPD < MPD
attr_reader :socket
def initialize( socket_recording_file=nil )
super()
load_recording(socket_recording_file) if socket_recording_file
end
def load_recording(filename)
@socket = PlaybackSocket.new.load_recording(filename)
self
end
def last_messages
@socket.last_messages
end
end
Running the Tests
require 'minitest/autorun'
class TestQueue < MiniTest::Unit::TestCase
def setup
@mpd = PlaybackMPD.new 'mpd-responses.marshal'
end
def test_songs
songs = @mpd.queue
assert_equal ["playlistinfo"], @mpd.last_messages
assert_equal 5, songs.length
assert songs.all?{ |value| value.is_a? MPD::Song }
assert_equal [273, 289, 129, 258, 347], songs.map(&:track_length)
end
def test_command_lists
ids = @mpd.command_list(:values) do
clear
%w[test1.mp3 test2.mp3 test3.mp3].each{ |f| addid(f) }
end
assert_equal(
["command_list_begin", "clear", "addid test1.mp3", "addid test2.mp3",
"addid test3.mp3", "command_list_end"],
@mpd.last_messages
)
assert_equal [101,102,103], ids
end
end
Specific Questions
- Is there a better way to simulate sample data from the socket?
- Is there a better way to record the series-of-puts and associate them with the responses I get? I feel like there might be a bug lurking with that
unless @command.empty?
logic.