Ruby Tutorials

Tutorials on Ruby specific concepts.

21

APR
2006

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)
  1. Ross Bamford
    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.

    1. Reply (using GitHub Flavored Markdown)

      Comments on this blog are moderated. Spam is removed, formatting is fixed, and there's a zero tolerance policy on intolerance.

      Ajax loader
  2. Gregory Brown
    Gregory Brown April 21st, 2006 Reply Link

    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!

    1. Reply (using GitHub Flavored Markdown)

      Comments on this blog are moderated. Spam is removed, formatting is fixed, and there's a zero tolerance policy on intolerance.

      Ajax loader
  3. Haris Skiadas
    Haris Skiadas April 21st, 2006 Reply Link

    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!

    1. Reply (using GitHub Flavored Markdown)

      Comments on this blog are moderated. Spam is removed, formatting is fixed, and there's a zero tolerance policy on intolerance.

      Ajax loader
  4. Benjamin Kudria
    Benjamin Kudria July 14th, 2007 Reply Link

    Wow, that's helpful. Finally, Ruby unit testing made understandable. Thanks a bunch.

    1. Reply (using GitHub Flavored Markdown)

      Comments on this blog are moderated. Spam is removed, formatting is fixed, and there's a zero tolerance policy on intolerance.

      Ajax loader
  5. Daniel Lindsley
    Daniel Lindsley November 29th, 2007 Reply Link

    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)

    1. Reply (using GitHub Flavored Markdown)

      Comments on this blog are moderated. Spam is removed, formatting is fixed, and there's a zero tolerance policy on intolerance.

      Ajax loader
    2. James Edward Gray II
      James Edward Gray II November 29th, 2007 Reply Link

      A good place to view IRC details is this site.

      1. Reply (using GitHub Flavored Markdown)

        Comments on this blog are moderated. Spam is removed, formatting is fixed, and there's a zero tolerance policy on intolerance.

        Ajax loader
  6. Nabin
    Nabin December 20th, 2008 Reply Link

    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.

    1. Reply (using GitHub Flavored Markdown)

      Comments on this blog are moderated. Spam is removed, formatting is fixed, and there's a zero tolerance policy on intolerance.

      Ajax loader
    2. James Edward Gray II
      James Edward Gray II December 20th, 2008 Reply Link

      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.

      1. Reply (using GitHub Flavored Markdown)

        Comments on this blog are moderated. Spam is removed, formatting is fixed, and there's a zero tolerance policy on intolerance.

        Ajax loader
Leave a Comment (using GitHub Flavored Markdown)

Comments on this blog are moderated. Spam is removed, formatting is fixed, and there's a zero tolerance policy on intolerance.

Ajax loader