Running RSpec tests from the browser

As a fun demo of the flexibility of jstest, I thought I’d show how you can use it to run tests that aren’t even JavaScript. This is a silly example but it actually demonstrates the power of the framework and I’ve used these capabilities to solve real testing problems when running JavaScript on unusual platforms.

jstest has a plugin system for changing the output format; all the different output formats and runner adapters come from objects that all implement a standard interface; the test runner invokes these methods on the reporter to notify it of what’s going on so the reporter can produce output. The event objects passed to these methods are self-contained JavaScript data structures that can be easily serialized as JSON, and indeed this JSON stream is one of the built-in output formats.

The nice thing about the JSON format is it makes it easy to sent reporter data over a wire, parse it and give to another reporter running in a different process, so you can print browser results in a terminal and vice-versa. This is how the PhantomJS integration works: the JSON reporter writes to the browser console, and PhantomJS can pick this data up and reformat it using one of the text-based output formats in the terminal.

The docs for the JSON reporter show an example of running a server-side test suite using the browser UI, by making the server process emit JSON, sending this JSON over a WebSocket and handing it to the browser reporter. The nice thing about this is the WebSocket and browser don’t care where the JSON came from – any process that emits jstest-compatible JSON will do. So, we can use this system to run Ruby tests!

To begin with, let’s write a little RSpec test:

// spec/ruby_spec.rb

describe Array do
  before do
    @strings = %w[foo bar]
  end

  it 'returns a new array by mapping the elements through the block' do
    @strings.map(&:upcase).should == %w[FOO BAR]
  end
end

Now we just need to make RSpec emit JSON, which we can do using a custom formatter:

$ rspec -r ./spec/json_formatter -f JsonFormatter ./spec
{"jstest":["startSuite",{"children":[],"size":1,"eventId":0,"timestamp":1372708952811}]}
{"jstest":["startContext",{"fullName":"Array","shortName":"Array","context":[],"children":["map"],"eventId":1,"timestamp":1372708952811}]}
{"jstest":["startContext",{"fullName":"Array map","shortName":"map","context":["Array"],"children":[],"eventId":2,"timestamp":1372708952812}]}
{"jstest":["startTest",{"fullName":"Array map returns a new array by mapping the elements through the block","shortName":"returns a new array by mapping the elements through the block","context":["Array","map"],"eventId":3,"timestamp":1372708952812}]}
{"jstest":["update",{"passed":true,"tests":1,"assertions":1,"failures":0,"errors":0,"eventId":4,"timestamp":1372708952812}]}
{"jstest":["endTest",{"fullName":"Array map returns a new array by mapping the elements through the block","shortName":"returns a new array by mapping the elements through the block","context":["Array","map"],"eventId":5,"timestamp":1372708952812}]}
{"jstest":["endContext",{"fullName":"Array map","shortName":"map","context":["Array"],"children":[],"eventId":6,"timestamp":1372708952812}]}
{"jstest":["endSuite",{"passed":true,"tests":1,"assertions":1,"failures":0,"errors":0,"eventId":7,"timestamp":1372708952812}]}

Next, we need a server, specifically a WebSocket server that will trigger a test run each time a connection is made. When we open a WebSocket to ws://localhost:8888/?test=map, the server should run this command and pipe the output into the WebSocket, sending each line of output as a separate message:

$ rspec -r ./spec/json_formatter -f JsonFormatter ./spec -e map

This is easily accomplished using the faye-websocket and split modules from npm:

// server.js

var child     = require('child_process'),
    http      = require('http'),
    url       = require('url'),
    split     = require('split'),
    WebSocket = require('faye-websocket')

var bin  = 'rspec',
    argv = ['-r', './spec/json_formatter', '-f', 'JsonFormatter', './spec']

var server = http.createServer()

server.on('upgrade', function(request, socket, body) {
  var ws = new WebSocket(request, socket, body),

      params  = url.parse(request.url, true).query,
      tests   = JSON.parse(params.test),

      options = tests.reduce(function(o, t) { return o.concat(['-e', t]) }, []),
      proc    = child.spawn(bin, argv.concat(options))

  proc.stdout.pipe(split()).pipe(ws)
})

server.listen(8888)

And finally, we need a web page that will open a socket, and channel the messages into the jstest browser reporter. We have a special class for this: JS.Test.Reporters.JSON.Reader takes lines of JSON output, parses them and dispatches the data to a reporter, making sure the messages are replayed in the right order.

By using JS.Test.Runner to get the current run options, we can tell which tests the user has selected to run, and send their names to the server that will pass these names on to rspec.

<!-- browser.html -->

<!doctype>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>RSpec in the browser</title>
  </head>
  <body>

    <script type="text/javascript" src="./node_modules/jstest/jstest.js"></script>

    <script type="text/javascript">
      var options = new JS.Test.Runner().getOptions(),

          R       = JS.Test.Reporters,
          browser = new R.Browser(options),
          reader  = new R.JSON.Reader(browser)

      var test = encodeURIComponent(JSON.stringify(options.test)),
          ws   = new WebSocket('ws://localhost:8888/?test=' + test)

      ws.onmessage = function(event) {
        reader.read(event.data)
      }
    </script>

  </body>
</html>

If you start the server and open the web page, you should see the results in the browser!

rspec

Clicking the green arrows next to the tests reloads the page with that test selected, so we can use this to run a subset of our Ruby tests.

As I said, this is a silly example but it shows the power of the jstest reporting API. You can use this approach in reverse to send browser test results to the terminal and do other useful things.

For example, a while ago I was working on a Spotify application. It’s quite hard to make Spotify reload the page without completely shutting it down and restarting it. I wanted to drive the tests quickly from the terminal, so I made a little script to help me do this. I made page in my app that opened a Faye connection to a server, and when it received a certain message it would reload the page using window.location and re-run my tests. The tests used a custom reporter to send updates over another Faye channel. My script would send the reload command, then listen for progress updates and channel them into one of jstest’s text reporters. This only took about a page of code and hugely improved my productivity when building my app.

This demonstrates the power of having a simple serializable data format for test events, and reporters that work on any platform out of the box.