Celebrating 10 Years!

profile picture

How to Write IRC: Part 4

August 24, 2017 - Roundwall Software

This is a continuation of the article published yesterday: How To Write IRC: Part 3

Async Integration Testing

Some of you might be reading along and think to yourself, "This guy hasn't tested anything! He doesn't assert anything. What kind of lies is he trying to sell?". To that I say, "Give me a minute, it gets better". One of the nice things about code is that nothing you write really needs to be permanent. Especially when you're starting a thing, it's fine if you have to change it a bunch. You don't need to know exactly what you're going to do right when you start. That would be boring.

The very next thing I wrote was an actual test with actual assertions.

func testServerDelegateGetsServerMessages() {
  let user = IRCUser(username: "sgoodwin", realName: "Samuel Goodwin", nick: "mukman")
  let server = IRCServer.connect("irc.freenode.org", port: 6667, user: user)

  struct ServerDelegate: IRCServerDelegate {
    var buffer = [String]()
  }

  let serverDelegate = ServerDelegate()
  server.delegate = serverDelegate

  XCTAssert(serverDelegate.buffer.count > 0)
}

The test says that if I connect to a server, I can expect some data to be sent from the server and it should collect in my server delegate. The API I decided on required a server object to have a delegate to signal with messages from the server (such as the message-of-the-day or MOTD). This test fails as we expect because nothing is actually connecting to anything and there aren't any delegate methods. Then I set to work making the test pass.

I added a method to the IRCServerDelegate protocol:

protocol IRCServerDelegate {
  func didRecieveMessage(_ server: IRCServer, message: String)
}

implemented the method in my delegate object:

func didRecieveMessage(_ server: IRCServer, message: String) {
  buffer.append(message)
// In real life, the developer would maybe update the UI or something here. They're free to do what they want.
}

and finally updated the IRCServer class to make the test pass:

class IRCServer {
  var delegate: IRCServerDelegate? {
    didSet {
      delegate?.didRecieveMessage(self, message: "This is a message from the server!")
    }
  }

  static func connect(_ hostname: String, port: Int, user: IRCUser) -> IRCServer {
     return IRCServer()
  }

  func join(_ channelName: String) -> IRCChannel {
     return IRCChannel()
  }
}

With this I knew that the server object would probably need some sort of buffer inside it. It would begin to receive server messages as soon as it connected, but wouldn't be able to send them to the delegate until one has been assigned. I either needed this or I needed to require a delegate upon creation. I decided I liked giving the developer control over when they got messages, so I made a buffer.

Next I wanted to make this test work using a URLSession instead of hard-coding what data to send back. For this I didn't want the test to depend on the internet so I planned to use URLProtocol to make a fake version of an IRC connection. This method was explained in NSHipster (one of my favorite blogs) back in the day. With this I could move my test code that actually uses URLSession into my new IRCServer class and try to get things working.

First I needed to modify the test because server messages don't come instantaneously.

func testServerDelegateGetsServerMessages() {

  let user = IRCUser(username: "sgoodwin", realName: "Samuel Goodwin", nick: "mukman")
  let server = IRCServer.connect("irc.freenode.org", port: 6667, user: user)

  class ServerDelegate: IRCServerDelegate {
    var buffer = [String]()
    let expectation = XCTestExpectation(description: "Any message receieved")

    func didRecieveMessage(_ server: IRCServer, message: String) {
      expectation.fulfill()
      // In real life, the developer would maybe update the UI or something here. They're free to do whatever they want.
    }
  }

  let serverDelegate = ServerDelegate()
  server.delegate = serverDelegate

  wait(for: [serverDelegate.expectation], timeout: 1.0)
}

and I modified the server to actually use URLSession based on my test code I had in my view controller.

class IRCServer {
  var delegate: IRCServerDelegate? {
    didSet {
      guard let delegate = delegate else {
        return
      }

      buffer.forEach { (line) in
        delegate.didRecieveMessage(self, message: line)
      }
      buffer = []
    }
  }

  private var buffer = [String]()
  private lazy var session: URLSession = {
    URLSession(configuration: URLSessionConfiguration.default, delegate: nil, delegateQueue: nil)
  }()
  private var task: URLSessionStreamTask!

  private init(hostname: String, port: Int, user: IRCUser) {
    task = session.streamTask(withHostName: hostname, port: port)
    task.resume()
    read()
  }

  private func read() {
    task.readData(ofMinLength: 0, maxLength: 9999, timeout: 0) { (data, atEOF, error) in
      guard let data = data, let message = String(data: data, encoding: .utf8) else {
        return
      }

      let input = IRCServerInputParser.parseServerMessage(message)
      switch input {
        case .serverMessage(_, let message):
          if let delegate = self.delegate {
            delegate.didRecieveMessage(self, message: message)
          } else {
            self.buffer.append(message)
          }
        default:
        print("Some other stuff")
      }

      self.read()
    }
  }
    
  static func connect(_ hostname: String, port: Int, user: IRCUser) -> IRCServer {
    return IRCServer(hostname: hostname, port: port, user: user)
  }

  func join(_ channelName: String) -> IRCChannel {
    return IRCChannel()
  }
}

I took out every bit of code that wasn't needed to make the test pass. I wasn't worried about sending data yet and also not worried about handling error cases. I also didn't care about any of the other types of messages that might come in, for now just the server ones. Beyond the test code, I added the logic for handling a buffer of server messages. If there's no delegate, I made it save them up and when there was a delegate it could unload them.

The test passed, but it depended on the internet. Gross! This would also flood poor freenode with a bunch of connection attempts while I was testing too, no server wants that.

Normally this is where I would reach for URLProtocol to fake out the internet reactions. It's a super handy way to supplant HTTP interaction in your tests without having to do anything special in your actual application/framework code. Unfortunately though, since I was using a streaming task and not the more-commonly-used data task, this wouldn't work. (And I spent a day or so finding that out.) Instead I opted to setup my own local IRC server which I could connect to. I thought this could be even more useful down the line when I wanted to implement more elaborate interactions like operator status and such later. After flipping through some setup tutorials online, I thought I'd try ircd-hybrid. It doesn't matter really which server you use, there are however a few things you should make sure to configure

Once I got the local server running, I replaced the address in all my tests with 127.0.0.1 and re-ran to verify everything works with my server. Success! From here I could get on to implementing more stuff. One other thing to note though is that this test is an integration-level test. Ideally there wouldn't be tons of them, because each one has to wait for its timeout or a response from the server and that time adds up if you have 1000 tests. Verifying that my code handles specific responses from the server correctly was taken care of by testing the parser. The test to connect to the server was largely making sure all the different parts are connected as expected (one might even say integrated).

The work continues in How To Write IRC: Part 5