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