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