Software QA FYI - SQAFYI

Unit Testers Get More Chicks

By: James Edward Gray

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 and 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:

1. 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.

2. 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.

3. 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.

4. 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.

5. 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 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’m done with the cookie now. 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 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.replace("")  # Why doesn't String have a 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 now 
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 not 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? or 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? and 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? or 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]:
 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
puts "Usage #{$PROGRAM_NAME} IRC_URL NICKNAME CHANNEL"
  exit
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…

Other Resource

... to read more articles, visit http://sqa.fyicenter.com/art/

Unit Testers Get More Chicks