21
APR2006
Unit Testers Get More Chicks
Just recently, a developer I respect very much was caught uttering the this surprising statement: "Unit tests just really aren't my thing." Now, I still respect this developer very much and I can tell you that the person single-handedly created one of my very favorite pieces of software. However, I do think the developer is dead wrong on this point. This is my attempt to change the mind of everyone that thinks similar things about unit testing.
My belief is that unit testing is for everyone and, in fact, I'll go so far as to say that I believe becoming a test-driven developer is the single best change a programmer can make in their day to day routine. Here are just some of the reasons why:
- Though counterintuitive, I swear that it makes you code faster. No one tends to believe me on this, but most test-driven developers come to this realization eventually. The reason is simple: you spend much less time debugging the 935 errors from your two hour code sprees.
- The tests are my memory. My head is too full of all the languages, frameworks, APIs, and family birthdays I am expected to know to remember everything I've ever done on top of that. This week at work I've touched three separate projects all with over 1,000 lines of code. I'm sure I had good ideas when I wrote those lines, but now I doubt I can tell you what they are. My tests can though. They remember so I don't have to. If I go into the code and change something I don't remember was needed or why, my tests will remind me immediately.
- In team development, my test powered memory even travels to the machines of the other developers! How cool is that? When someone goes into the code and says, "Why on Earth did James do this? We don't need this. I'm going to change that…" my tests will look after my interests for me.
- I'm a lot more confident in my software. I use to say things like, "I just finished this, so it hasn't been used much yet and probably still has plenty of issues." Now by the time I finish something my tests have been using the heck out of it. I won't kid you and tell you that my software now springs fully formed from the head of my tests, but it definitely comes out farther along the track.
- Environment changes seldom surprise me anymore. When I move my software to a different box and it can't cope for whatever reason, I not only know the first time I run the tests, I have an excellent idea of exactly where the problem is.
Believe me I could go on and on. I have to the poor developers who have set me off in the past. I'll spare you my sermon though and try something else. Let's test drive the creation of some software!
Scribe
I've recently discovered the joys of IRC. (IRC, or Internet Relay Chat, is kind-of an underground chat network for the Internet. You can Google for more details if you need them.) Yes, I know I am the last geek on Earth to be enlightened, but late or not I finally made it to the party.
Shortly after this discovery, I did what any good programmer would do: I built an IRC bot. I learned just enough of the protocol to get something running and turned it loose. Just between you and me, that bot sucks. But I now know enough to build a better one!
Let's do just that. I will call my new creation Scribe. Let's start sketching out some tests for Scribe…
Mock Socket
IRC is a pretty simple protocol over TCP/IP sockets. What makes it easy to deal with is that it is a line based protocol, so we can just print command lines to it and read response lines back very similar to how we often deal with ordinary files.
Now, we don't want the bot to actually connect to an IRC server when we are testing it. There are a lot of reasons for that, but my biggest one is that we don't want to be waiting on the network. Tests need to be fast, so they are painless to run and we don't get bogged down using them.
If we just had a trivial fake server we could feed lines to and then it would slowly handout those lines to our bot as requested, we could be reasonably sure it is working as intended. This won't be a completely real IRC environment, but it should allow us to ensure the core behaviors. We can work out the networking oddities once we get that far. So, I created a file scribe/test/mock/tcp_socket.rb
with the following contents:
#!/usr/local/bin/ruby -w
$server_responses = Array.new
$client_output = String.new
class MockTCPSocket
def initialize(host, port)
# we don't need +host+ or +port+--just keeping the interface
end
def gets
if $server_responses.empty?
raise "The server is out of responses."
else
$server_responses.shift + "\r\n"
end
end
def print(line)
$client_output << line
end
end
Alright, before you guys can fire up the mail clients and pour on the hate mail, let's address the two sins I just committed in that tiny bit of code. First, I used global variables. Yes, I hesitated for at least half a second when I typed them and I'm sure someone will be quick to point out that $server_responses
could be a class variable with an append method to use it. That's true. Of course, it's a touch longer to build that way, and worse, a little bit longer to use each time since I need the class name followed by a method call. And for that added annoyance what do you gain? Zip. It's still global to all the connections. Some might say it's easier to clobber the global data than go through the explicit class interface but I'm writing the mock, the tests, and the actual bot and I promise to be good. Given that, I choose the easy way out.
Second sin: there are no tests for this code! Here I am bragging about how tests cure cancer, and the first thing I do is write code without tests. It's a world gone mad. OK, let's clear this up early or it is going to be a very bumpy ride through the rest of this process: tests are for covering risks. Above I made an Array
and a String
, and provided a very, very trivial interface to them.
The risk factor so far is about 0.125%. I'm a gambling man so I'm willing to take my chances there. It cracks me up when I see someone write an attr_reader()
call, then go write some tests for it. That is Ruby's concern not yours. Stick to your own challenges, because there will be plenty of those to keep you busy.
Login Tests
When we first connect to an IRC server, we have to start the conversation with two messages. One is the NICK
command, for the nickname you want the bot to use. It looks like this:
NICK scribe
Let's get a connection sending just that much and call it a start. I start with the tests, because it makes me think about the interface before I write the code to do it. I find that saves me the trouble of building something only to find out that it's a pain to use and I need to rethink it. Let's create another file scribe/test/tc_irc_connection.rb
and add a test:
#!/usr/local/bin/ruby -w
require "test/unit"
require "scribe/irc/connection"
require "mock/tcp_socket"
class TestIRCConnection < Test::Unit::TestCase
def test_construction
@con = Scribe::IRC::Connection.new( "some.host.net",
:socket_class => MockTCPSocket )
assert_not_nil(@con)
assert_instance_of(Scribe::IRC::Connection, @con)
end
end
Oops, I went to build a nick()
method and realized I need to construct a connection first. These are just a couple of tests to make sure we get a connection to use in all the other tests.
Now let's add one more file scribe/lib/scribe/irc/connection.rb
. Here's enough code to create a connection:
#!/usr/local/bin/ruby -w
require "socket"
module Scribe
module IRC
class Connection
DEFAULT_OPTIONS = { :port => 6667,
:socket_class => TCPSocket }.freeze
def initialize(host, options = Hash.new)
options = DEFAULT_OPTIONS.merge(options)
@host, @port = host, options[:port]
@irc_server = options[:socket_class].new(@host, @port)
end
end
end
end
There shouldn't be any surprises in there. I provided an option to allow you to switch classes for testing purposes. Since I already need two options and I am not sure if it is going to grow more, I went ahead and used an option Hash
to give myself plenty of room for expansion.
That should be enough to get us going. Let's see if they run:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
.
Finished in 0.000344 seconds.
1 tests, 2 assertions, 0 failures, 0 errors
No mistakes yet? I deserve a cookie! Be right back… OK, I have my treat and am getting crumbs all over the keyboard. We can move on now.
I would love to claim that all just worked on the first try because I am a brilliant programmer, but the truth is that it works because I am moving in small steps. There's not a lot of room for error. This is yet another benefit of unit testing: it encourages you to write small methods. Small methods mean you make less mistakes and when you do screw up, you only have a few lines to bug hunt in. It's easy.
OK, my stalling worked. I finished the cookie. Let's move ahead.
Now I am really ready for the NICK
tests:
class TestIRCConnection < Test::Unit::TestCase
def setup
test_construction
end
# ...
def test_nick
assert_nothing_raised(Exception) { @con.nick("scribe") }
assert_equal("NICK scribe\r\n", $client_output)
end
end
I'm using new testing trick here. You can define a setup()
method and it will be called before every single test method is executed. Since I already have a method for building an object, we can just run that. (That's why I had that method use an instance variable instead of a local, but you already knew that didn't you?) This has another side effect, it artificially inflates the test count which is just great for my ego!
Alright, let's see if we can add enough code to the library to get that running:
module Scribe
module IRC
class Connection
# ...
def nick(name)
send_command "NICK #{name}"
end
private
def send_command(cmd)
@irc_server.print "#{cmd}\r\n"
end
end
end
end
How are we doing?
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
..
Finished in 0.000854 seconds.
2 tests, 8 assertions, 0 failures, 0 errors
Eight passing tests and it feels like they are just flying right by. We rock.
The other half of an IRC login is much the same. We need to send a USER
command, in this format:
USER scribe <host_name> <server_name> :"scribe" IRC bot
We don't need to dwell on host_name
and server_name
too long since the IRC RCF literally says that they are normally ignored from clients for security reasons. Let's create a test for this method:
class TestIRCConnection < Test::Unit::TestCase
# ...
def test_user
assert_nothing_raised(Exception) do
@con.user("scribe", "127.0.0.1", "127.0.0.1", '"scribe" IRC bot')
end
assert_equal( "USER scribe 127.0.0.1 127.0.0.1 :\"scribe\" IRC bot\r\n",
$client_output )
end
end
The library code for that is also pretty trivial:
module Scribe
module IRC
class Connection
# ...
def user(nick, host_name, server_name, full_name)
send_command "USER #{nick} #{host_name} #{server_name} :#{full_name}"
end
# ...
end
end
end
Great. Let's see how we did:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
..F
Finished in 0.01194 seconds.
1) Failure:
test_user(TestIRCConnection) [test/tc_irc_connection.rb:34]:
<"USER scribe 127.0.0.1 127.0.0.1 :\"scribe\" IRC bot\r\n"> expected but was
<"NICK scribe\r\nUSER scribe 127.0.0.1 127.0.0.1 :\"scribe\" IRC bot\r\n">.
3 tests, 12 assertions, 1 failures, 0 errors
Ouch! Our first failure. What went wrong?
Luckily, the failure includes all the info we need to get right to the heart of the problem. The line number shows us that $client_output
didn't hold what we expected it to. Reading on, the failure message shows that the line we wanted is in there, but the NICK
line is also still in there. Oops, we never cleared the globals. Let's add a method to the tests that handles that:
class TestIRCConnection < Test::Unit::TestCase
# ...
def teardown
$server_responses.clear
$client_output.clear
end
# ...
end
This method is the opposite of setup()
. It is called after each test method is run, and thus can handle cleanup tasks like the one we use it for here.
Does that fix our tests?
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
...
Finished in 0.001129 seconds.
3 tests, 12 assertions, 0 failures, 0 errors
Sure does.
Now that I have put both of those in, I saw a way to refactor them a bit. What's great is that I can do this and use the tests to make sure I don't screw anything up. Let's make the change. First I comment out nick()
and user()
. Then I add:
module Scribe
module IRC
class Connection
# ...
COMMAND_METHODS = [:nick, :user].freeze
# ...
def method_missing(meth, *args, &block)
if COMMAND_METHODS.include?(meth)
params = args.dup
params[-1] = ":#{params.last}" if params[-1] =~ /\A[^:].* /
send_command "#{meth.to_s.upcase} #{params.join(' ')}"
else
super
end
end
# ...
end
end
end
That's not a huge change for just nick()
and user()
, but as the software grows and we add more output methods it is now as simple as adding a new method name to the Array. (I use this Array
to keep method_missing()
from swallowing too many methods.) That should pay us back eventually.
Let's make sure the tests didn't change:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
...
Finished in 0.001103 seconds.
3 tests, 12 assertions, 0 failures, 0 errors
Perfect. I'm happy and the tests are happy. I can now delete the two obsolete methods.
IRC Talks Back
When we get this far, things are about to get a lot more interesting. IRC is about to start talking back to us. Now we need to listen as well as send. Getting a command from the IRC server is easy enough, so let's start there:
class TestIRCConnection < Test::Unit::TestCase
# ...
def test_recv_command
mes = ":calvino.freenode.net 001 scribe " +
":Welcome to the freenode IRC Network scribe"
$server_responses << mes
assert_equal(mes, @con.recv_command)
end
end
And here is the library code for that method:
module Scribe
module IRC
class Connection
# ...
def recv_command
cmd = @irc_server.gets
cmd.nil? ? cmd : cmd.sub(/\r\n\Z/, "")
end
# ...
end
end
end
Checking the tests:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
....
Finished in 0.001335 seconds.
4 tests, 15 assertions, 0 failures, 0 errors
Looking good. Except I don't like the idea of using that command all the time. Those messages have a set format and I would rather get them bundled up in an easy to deal with Ruby object. Let's see if we can add a programmer friendly layer of command wrapping on there. Now how would I want that to look? (Tests are just thinking-in-code…)
class TestIRCConnection < Test::Unit::TestCase
# ...
def test_recv
$server_responses << ":calvino.freenode.net 001 scribe " +
":Welcome to the freenode IRC Network scribe"
cmd = @con.recv
assert_not_nil(cmd)
assert_instance_of(Scribe::IRC::Connection::Command, cmd)
assert_equal("calvino.freenode.net", cmd.prefix)
assert_equal("001", cmd.command)
assert_equal( ["scribe", "Welcome to the freenode IRC Network scribe"],
cmd.params )
assert_equal( "Welcome to the freenode IRC Network scribe",
cmd.last_param )
end
end
Notice that I did sneak in a little duplication with that last test. The last parameter of an IRC command is often special, since it is the only one that can contain spaces. Because of that, it might be nice to be able to treat that one a little differently.
Also notice that the colons have been removed from the prefix and the final parameter. Those are part of the IRC protocol, not the items themselves.
Let's add the code to the library for this:
module Scribe
module IRC
class Connection
Command = Struct.new(:prefix, :command, :params, :last_param)
# ...
def recv
cmd = recv_command
return cmd if cmd.nil?
cmd =~ / \A (?::([^\040]+)\040)? # prefix
([A-Za-z]+|\d{3}) # command
((?:\040[^:][^\040]+)*) # params, minus last
(?:\040:?(.*))? # last param
\Z /x or raise "Malformed IRC command."
Command.new($1, $2, $3.split + [$4], $4)
end
# ...
end
end
end
I know, I know, scary Regexp usage in there. I really took it pretty much right out of the IRC RFC though. (Hint for breaking it down: \040
is just a space, but needed when using the /x
Regexp modifier.)
It even seems to work:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
.....
Finished in 0.001553 seconds.
5 tests, 23 assertions, 0 failures, 0 errors
Now remember what I said about risk? I'm not feeling super sure about this code yet, since the difficultly just ramped up a notch. The risk went up, so I'm going to counter with some more tests:
class TestIRCConnection < Test::Unit::TestCase
# ...
def test_recv
$server_responses << ":calvino.freenode.net 001 scribe " +
":Welcome to the freenode IRC Network scribe"
cmd = @con.recv
assert_not_nil(cmd)
assert_instance_of(Scribe::IRC::Connection::Command, cmd)
assert_equal("calvino.freenode.net", cmd.prefix)
assert_equal("001", cmd.command)
assert_equal( ["scribe", "Welcome to the freenode IRC Network scribe"],
cmd.params )
assert_equal( "Welcome to the freenode IRC Network scribe",
cmd.last_param )
$server_responses << "TIME"
cmd = @con.recv
assert_not_nil(cmd)
assert_instance_of(Scribe::IRC::Connection::Command, cmd)
assert_nil(cmd.prefix)
assert_equal("TIME", cmd.command)
assert_equal(Array.new, cmd.params)
assert_nil(cmd.last_param)
end
end
Let's see if it can handle that correctly as well:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
..F..
Finished in 0.012277 seconds.
1) Failure:
test_recv(TestIRCConnection) [test/tc_irc_connection.rb:63]:
<[]> expected but was
<[nil]>.
5 tests, 28 assertions, 1 failures, 0 errors
Bingo. My finely tuned rocky code instincts triumph again! (It could also be dumb luck, but I'm an optimist.)
Let's see if we can snap that edge case into shape:
module Scribe
module IRC
class Connection
# ...
def recv
cmd = recv_command
return cmd if cmd.nil?
cmd =~ / \A (?::([^\040]+)\040)? # prefix
([A-Za-z]+|\d{3}) # command
((?:\040[^:][^\040]+)*) # params, minus last
(?:\040:?(.*))? # last param
\Z /x or raise "Malformed IRC command."
params = $3.split + ($4.nil? ? Array.new : [$4])
Command.new($1, $2, params, params.last)
end
# ...
end
end
end
That got us there:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
.....
Finished in 0.001852 seconds.
5 tests, 29 assertions, 0 failures, 0 errors
I added one more test just to temp fate again, but it passed and I felt like I had now applied the proper level of paranoia. I won't bore you with that code though, since it looks the same as the last two. I also flipped recv_command()
to a private method (recv()
became the public interface) and adjusted the test to deal with that, but that wasn't too exciting. Let's move on.
Now, we still have a tiny problem with input. Let me show you the first few commands I see from an actual IRC connection (sending NICK
, then USER
, then just reading):
NOTICE AUTH :*** Looking up your hostname...
NOTICE AUTH :*** Found your hostname, welcome back
NOTICE AUTH :*** Checking ident
NOTICE AUTH :*** No identd (auth) response
:calvino.freenode.net 001 scribe :Welcome to the freenode IRC Network scribe
The server did OK my login (command 001
is RPL_WELCOME
), but while I was sending NICK
and USER
, the sever sent me a little chatter and now the response I am looking for is buried. We need to deal with that. Here are some tests that flesh out the interface I have in mind:
class TestIRCConnection < Test::Unit::TestCase
# ...
def test_recv_until
$server_responses <<
"NOTICE AUTH :*** Looking up your hostname..." <<
"NOTICE AUTH :*** Found your hostname, welcome back" <<
"NOTICE AUTH :*** Checking ident" <<
"NOTICE AUTH :*** No identd (auth) response" <<
":calvino.freenode.net 001 scribe " +
":Welcome to the freenode IRC Network scribe"
cmd = @con.recv_until { |c| c.command =~ /\A(?:43[1236]|46[12]|001)\Z/ }
assert_not_nil(cmd)
assert_instance_of(Scribe::IRC::Connection::Command, cmd)
assert_equal("calvino.freenode.net", cmd.prefix)
assert_equal("001", cmd.command)
assert_equal( ["scribe", "Welcome to the freenode IRC Network scribe"],
cmd.params )
assert_equal( "Welcome to the freenode IRC Network scribe",
cmd.last_param )
cmd = @con.recv_until { |c| c.last_param.include? "welcome" }
assert_not_nil(cmd)
assert_instance_of(Scribe::IRC::Connection::Command, cmd)
assert_nil(cmd.prefix)
assert_equal("NOTICE", cmd.command)
assert_equal( ["AUTH", "*** Found your hostname, welcome back"],
cmd.params )
assert_equal("*** Found your hostname, welcome back", cmd.last_param)
remaining = [ ["NOTICE", "AUTH", "*** Looking up your hostname..."],
["NOTICE", "AUTH", "*** Checking ident"],
["NOTICE", "AUTH", "*** No identd (auth) response"] ]
remaining.each do |raw|
cmd = Scribe::IRC::Connection::Command.new( nil,
raw[0],
raw[1..-1],
raw[-1] )
assert_equal(cmd, @con.recv)
end
assert_raise(RuntimeError) { @con.recv }
end
end
Don't let all that code fool you, I'm not working anywhere near that hard. I'm making liberal use of copy and paste, then just tweaking the expected values. While frowned upon in library or application code, there's no problem with writing tests that way.
We're ready to implement recv_until()
now, but it requires changes to a couple of methods:
module Scribe
module IRC
class Connection
# ...
def initialize(host, options = Hash.new)
# ...
@cmd_buffer = Array.new
end
# ...
def recv
return @cmd_buffer.shift unless @cmd_buffer.empty?
# ...
end
def recv_until
skipped_commands = Array.new
while cmd = recv
if yield(cmd)
@cmd_buffer.unshift(*skipped_commands)
return cmd
else
skipped_commands << cmd
end
end
end
# ...
end
end
end
The additions to initialize()
and recv()
just add and make use of a command buffer, to hold input we bypassed. We aren't interested in it, but others might be and we don't want to discard it. From there, recv_until()
is easy enough. Read input until we find what we are after and toss the extra commands in the buffer for future reading.
How are the tests looking now?
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
......
Finished in 0.003009 seconds.
6 tests, 53 assertions, 0 failures, 0 errors
Good enough for the girl I go with.
Last step for a login, I promise. All we need to do now is wrap nick()
, user()
, and the verification call of recv_until()
in an easy-to-use whole. Here's the test for the method:
class TestIRCConnection < Test::Unit::TestCase
# ...
def test_login
$server_responses <<
"NOTICE AUTH :*** Looking up your hostname..." <<
"NOTICE AUTH :*** Found your hostname, welcome back" <<
"NOTICE AUTH :*** Checking ident" <<
"NOTICE AUTH :*** No identd (auth) response" <<
":calvino.freenode.net 001 scribe " +
":Welcome to the freenode IRC Network scribe"
assert_nothing_raised(Exception) do
@con.login("scribe", "127.0.0.1", "127.0.0.1", '"scribe" IRC bot')
end
assert_equal( "NICK scribe\r\n" +
"USER scribe 127.0.0.1 127.0.0.1 :\"scribe\" IRC bot\r\n",
$client_output )
remaining = [ ["NOTICE", "AUTH", "*** Looking up your hostname..."],
[ "NOTICE", "AUTH",
"*** Found your hostname, welcome back" ],
["NOTICE", "AUTH", "*** Checking ident"],
["NOTICE", "AUTH", "*** No identd (auth) response"] ]
remaining.each do |raw|
cmd = Scribe::IRC::Connection::Command.new( nil,
raw[0],
raw[1..-1],
raw[-1] )
assert_equal(cmd, @con.recv)
end
assert_raise(RuntimeError) { @con.recv }
$server_responses <<
"NOTICE AUTH :*** Looking up your hostname..." <<
"NOTICE AUTH :*** Found your hostname, welcome back" <<
"NOTICE AUTH :*** Checking ident" <<
"NOTICE AUTH :*** No identd (auth) response" <<
":niven.freenode.net 431 :No nickname given"
assert_raise(RuntimeError) do
@con.login("", "127.0.0.1", "127.0.0.1", '"scribe" IRC bot')
end
end
end
Here's the method that does what we need:
module Scribe
module IRC
class Connection
# ...
def login(nickname, host_name, server_name, full_name)
nick(nickname)
user(nickname, host_name, server_name, full_name)
rpl = recv_until { |c| c.command =~ /\A(?:43[1236]|46[12]|001)\Z/ }
if rpl.nil? || rpl.command != "001"
raise "Login error: #{rpl.last_param}."
end
end
# ...
end
end
end
Finally, here's the proof that we did it right:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
.......
Finished in 0.003969 seconds.
7 tests, 63 assertions, 0 failures, 0 errors
Playing PING PONG
There is one more little issue we need to deal with, to maintain an IRC connection for a sustained period of time. IRC servers regularly "ping" their connections, to make sure you are still connected. The server will send PING
commands with some random parameter content, and the client is expected to return a PONG
command with the same content.
Here's an example of what I am talking about:
class TestIRCConnection < Test::Unit::TestCase
# ...
def test_ping
commands = [ "*** Looking up your hostname...",
"*** Found your hostname, welcome back",
"*** Checking ident",
"*** No identd (auth) response" ]
commands.each { |cmd| $server_responses << "NOTICE AUTH :#{cmd}" }
$server_responses.insert( rand($server_responses.size),
"PING :calvino.freenode.net" )
commands.each do |expected|
cmd = @con.recv
assert_not_nil(cmd)
assert_instance_of(Scribe::IRC::Connection::Command, cmd)
assert_nil(cmd.prefix)
assert_equal("NOTICE", cmd.command)
assert_equal(["AUTH", expected], cmd.params)
assert_equal(expected, cmd.last_param)
end
assert_raise(RuntimeError) { @con.recv }
assert_equal("PONG :calvino.freenode.net\r\n", $client_output)
end
end
We can sneak this functionality in at the lowest level so the user never needs to be bothered by it:
module Scribe
module IRC
class Connection
# ...
private
def recv_command
cmd = @irc_server.gets
if not cmd.nil? && cmd =~ /\APING (.*?)\r\n\Z/
send_command("PONG #{$1}")
recv_command
else
cmd.nil? ? cmd : cmd.sub(/\r\n\Z/, "")
end
end
# ...
end
end
end
Nothing too tough there and we are still passing the tests:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
.........
Finished in 0.005391 seconds.
9 tests, 93 assertions, 0 failures, 0 errors
Getting Social
Now if we want our bot to do anything social on the IRC networks, we need to be able to join channels and send messages. Let's add tests for those operations:
class TestIRCConnection < Test::Unit::TestCase
# ...
def test_join
$server_responses << ":niven.freenode.net 403 scribe junk " +
":That channel doesn't exist"
assert_raise(RuntimeError) { @con.join("junk") }
$server_responses <<
":herbert.freenode.net 332 GrayBot ##textmate " +
":r961 available as ‘cutting edge’ || " +
"http://macromates.com/wiki/Polls/WhichLanguageDoYouUse"
assert_nothing_raised(Exception) { @con.join("##textmate") }
end
def test_privmsg
assert_nothing_raised(Exception) do
@con.privmsg("##textmate", "hello all")
end
assert_equal("PRIVMSG ##textmate :hello all\r\n", $client_output)
end
end
And here is the matching library code:
module Scribe
module IRC
class Connection
# ...
COMMAND_METHODS = [:nick, :join, :privmsg, :user].freeze
# ...
def join_channel(channel)
join(channel)
rpl = recv_until do |c|
c.command =~ /\A(?:461|47[13456]|40[35]|332|353)\Z/
end
if rpl.nil? || rpl.command !~ /\A3(?:32|53)\Z/
raise "Join error: #{rpl.last_param}."
end
end
# ...
end
end
end
Let's check my work:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
.F........
Finished in 0.017689 seconds.
1) Failure:
test_join(TestIRCConnection) [test/tc_irc_connection.rb:178]:
<RuntimeError> exception expected but none was thrown.
10 tests, 98 assertions, 1 failures, 0 errors
Oops, I blew it. That's what I get for trying to skip steps, I suppose. Luckily, the failure tells me it's join()
that I didn't get right. Actually, I did. I'm just not testing it correctly. join()
is the low-level method and I should really be testing join_channel()
. Let's fix that:
class TestIRCConnection < Test::Unit::TestCase
# ...
def test_join_channel
$server_responses << ":niven.freenode.net 403 scribe junk " +
":That channel doesn't exist"
assert_raise(RuntimeError) { @con.join_channel("junk") }
$server_responses <<
":herbert.freenode.net 332 GrayBot ##textmate " +
":r961 available as ‘cutting edge’ || " +
"http://macromates.com/wiki/Polls/WhichLanguageDoYouUse"
assert_nothing_raised(Exception) { @con.join_channel("##textmate") }
end
# ...
end
Make sure I got it right:
$ ruby -I lib:test test/tc_irc_connection.rb
Loaded suite test/tc_irc_connection
Started
..........
Finished in 0.005689 seconds.
10 tests, 99 assertions, 0 failures, 0 errors
Bingo.
Summary
Though this post is quite long, detailing my process and showing each little step, the library code is still under 100 lines. I also wrote it very fast, if you don't count the time spent with this write-up. I'm confident software is working well, though I've intentionally left out error handling code to keep things simple. Don't take my word for it though. Here's a trivial greeter bot showing off the code:
#!/usr/local/bin/ruby -w
require "scribe/irc/connection"
unless ARGV.size == 3
abort "Usage #{$PROGRAM_NAME} IRC_URL NICKNAME CHANNEL"
end
irc, nick, channel = ARGV
begin
server = Scribe::IRC::Connection.new(irc)
server.login(nick, "hostname", "servername", "\"#{nick}\" Greeter Bot")
server.join_channel(channel)
while cmd = server.recv
if cmd.command == "JOIN"
who = cmd.prefix.sub(/[!@].*\Z/, '')
next if who == nick
if rand(3).zero?
server.privmsg( channel, "I know you... " +
"You're the one my master covets!" )
else
server.privmsg(channel, "Howdy #{who}.")
end
end
end
rescue
puts "An error has occurred: #{$!.message}"
end
If I put that guy in some room and join it a few times, we can see his quirky sense of humor at work:
greet_bot: Howdy JEG2.
greet_bot: Howdy JEG2.
greet_bot: I know you... You're the one my master covets!
Now go forth my friends spreading unit tests into the world…
Comments (8)
-
Ross Bamford April 21st, 2006 Reply Link
Nice article, James. It reminded me of a perpetually on-hold toy project I have to recreate Spip (can't find a link, but he was based off of Infobot - http://www.infobot.org/) in Ruby.
Especially liked the step-by-step approach - for me one of the biggest gains in unit testing is that it enforces small steps but often articles don't fully get across the short turnaround time that can give, but here it's plain to see. Good stuff.
-
I sent you my rather lengthy coments for the lengthy article via email, but I really enjoyed this post! There a bunch of neat tricks in there and I also liked the step by step approach.
Very cool!
-
I've only been through the first 1/5th of the article, but so far I am loving it! Finally, a sufficiently complete unit testing example. I didn't need to be sold on the unit testing idea since I loved it the very second I heard about it, but this long example was much needed. Thanks!
-
Wow, that's helpful. Finally, Ruby unit testing made understandable. Thanks a bunch.
-
Thank you very much for the excellent article. This helped me where other tutorials failed by actually verbosely demonstrating what ought to be tested and what kinds of test one would perform. Most tutorials simply toss out the methods supported by
Test::Unit
, provide 1-2 simple cases and move on. My appreciation, sir.As a suggestion, I would provide a link to an IRC reference/specification toward the top of the article. The testing aspect was reasonably clear but at times it would've been nice to have something to look at for more information. While this information is Google-able, one source for everyone reading and playing along could be nice. (Shrug)
-
A good place to view IRC details is this site.
-
-
Excellent!!!A very understandable way of explaining Ruby testing. It would great to have more of such posts in Ruby testing.
Great way of explaining the topic.
Can you please provide us your second post on Ruby Testing or some similar links elsewhere.Or do you have any books in plan or already published.Really looking forward to it.
-
Thanks for the high praise.
There are links to the books I've written in the footer of this page, but I haven't done a book just on testing.
-