I first saw Betamax a couple of years ago when Rob Fletcher created it. More recently, I read about it being used with Scala in a blog post by Rob Slifka at Sharethrough, which got me thinking about how we might tidy up using it in various Scala test frameworks.
Betamax is originally written in Groovy, but as a JVM language, its compiled classes can be used with any application written in a JVM language. Its simplest use is in a JUnit test, or any test framework that supports features of the JUnit API, e.g. Spock. However, for whatever reason, JUnit hasn’t really gained as much traction in Scala as other test frameworks, with users often preferring to use ScalaTest or specs2.
JUnit in Scala
Let’s take a look at a simple Java example using JUnit first (ruthlessly pinched from the Betamax homepage):
import co.freeside.betamax.Betamax;
import co.freeside.betamax.Recorder;
import org.junit.*;
public class MyTest {
@Rule public Recorder recorder = new Recorder();
@Betamax(tape="my tape")
@Test
public void testMethodThatAccessesExternalWebService() {
// test me
}
}
What this is doing is registering a Recorder
as a JUnit @Rule
- this is a JUnit
feature that basically registers the instance as an object that’s going to interact with
each test, in this case a MethodRule
that interacts with each test, rather than the
class as a whole. JUnit will call the implemented MethodRule.apply
method on the rule
object before each test is run. The Betamax recorder then checks the test method that is
about to be run to see whether it has a @Betamax
annotation, and if so, handles the
test using the specified tape name.
Well that seems pretty simple, doesn’t it, so let’s try the same thing in a Scala JUnit test (I’m using the OpenWeatherMap API as a simple XML webservice - you can see the source here):
import org.junit.Test
import org.junit.Assert._
import org.junit.Rule
import co.freeside.betamax.Recorder
import co.freeside.betamax.Betamax
class WeatherTest {
@Rule val recorder = new Recorder
@Betamax(tape="junit")
@Test def findLondon = assertEquals("London, GB", WeatherClient.weatherFor("london,gb").location)
}
At first glance, this seems simple enough - we can pack up and go home. Unfortunately,
when we try and run this test we get an error from JUnit saying that an object with a
@Rule
annotation must be public. Obviously there’s something odd about the way Scala
processes annotations on a value (all values are public unless declared otherwise), or
the value itself, that JUnit’s reflection can’t pick it up. Fortunately, a change was
made in JUnit for this very problem, and we can instead annotate a function that returns
the value:
val recorder = new Recorder
@Rule def recorderDef = recorder
JUnit is happy with this, but when we run the test the Betamax recorder still doesn’t
get used. It turns out this is because of a
bug with methods annotated with @Rule
that return a MethodRule
- hopefully this will get fixed, and then JUnit + Betamax
should work fine.
specs2
So now let’s look at the library that Rob Slifka was having problems with - specs2. If you read Rob’s blog post you’ll remember that he had a test that is fetching JSON from Twitter:
".apply" should {
"fetch and parse JSON from the Twitter endpoint" in {
val url = "http://www.buzzfeed.com/despicableme2/15-reasons-we-wish-we-were-steve-carell/"
var tw = Twitter(url)
tw.url must_== url
tw.tweets must_== 29
}
}
To use Betamax around his var tw = Twitter(url)
he ended up with a helper function:
def withTape(tapeName:String, functionUnderTest:() => Any) = {
synchronized {
val recorder = new Recorder
val proxyServer = new ProxyServer(recorder)
recorder.insertTape(tapeName)
proxyServer.start()
try {
functionUnderTest()
} finally {
recorder.ejectTape()
proxyServer.stop()
}
}
}
He could then use the helper in the middle of his test:
var tw:Twitter = null
BetamaxHelper.withTape("Twitter.apply", () => {
tw = Twitter(url)
})
This works fine, but for me it’s a bit distracting in the middle of the test to have
the BetamaxHelper
getting involved. Also, using null
in Scala is a bit of a code
smell if you ask me, so I thought I’d see if we could do it in a slightly tidier way.
Here’s my simple specs2 test:
import org.specs2.mutable._
class WeatherSpec extends Specification {
"The Weather Client" should {
"find London, GB" in {
WeatherClient.weatherFor("london,gb").location must beEqualTo("London, GB")
}
}
}
One thing to notice about specs2 tests is that there are no explicit function
definitions, so there’s nowhere to put our @Betamax
annotation - I believe this was
the reason for Rob’s findings in the last paragraph of The Journey in his blog. The
reason for there being no function to annotate is because, for the sake of
readability, the tests are just created on construction by having an implicit
function that turns your string into a test fragment and allows you to join it to
other fragments using functions like should
and in
. The test fragments are then
filtered and run after the class has been initialised (and your code has been stored
as a partially applied function for execution when needed).
Looking through the specs2 documentation we discover that there’s a trait for wrapping around a test specification, Around
.
We can implement that to do the Betamax jiggery-pokery without interfering with the
readability of the test:
package specs2
import org.specs2.mutable.Around
import org.specs2.execute.AsResult
import co.freeside.betamax.Recorder
import co.freeside.betamax.proxy.jetty.ProxyServer
import co.freeside.betamax.TapeMode
class Betamax(tape: String, mode: Option[TapeMode] = None) extends Around {
def around[T: AsResult](t: => T) = Betamax.around(t, tape, mode)
}
object Betamax {
// syntactic sugar does away with 'new' in tests
def apply(tape: String, mode: Option[TapeMode] = None) = new Betamax(tape, mode)
def around[T: AsResult](t: => T, tape: String, mode: Option[TapeMode]) = {
synchronized {
val recorder = new Recorder
val proxyServer = new ProxyServer(recorder)
recorder.insertTape(tape)
recorder.getTape.setMode(mode.getOrElse(recorder.getDefaultMode()))
proxyServer.start()
try {
AsResult(t)
} finally {
recorder.ejectTape()
proxyServer.stop()
}
}
}
}
We can then use this in our test spec:
"The Weather Client" should {
"find London, GB" in Betamax("weather client") {
WeatherClient.weatherFor("london,gb").location must beEqualTo("London, GB")
}
}
I think this looks a bit tidier, and closer to the Betamax examples using annotations.
However, there is one small disadvantage of doing it this way - having the Around
implementation in the companion object with the body wrapped in a synchronized
statement means that tests can be run in parallel, but it means that the whole test
runs in parallel rather than just the section of the test that’s actually doing the
HTTP request. For me, that’s a price worth paying (at least you can still run all your
non-HTTP tests in parallel) in return for more readable tests, but I’ll admit it’s a
matter of personal preference.
ScalaTest
Now onto our last test framework. Here’s a simple ScalaTest test:
import org.scalatest.FunSuite
class WeatherSuite extends FunSuite {
test("weather for london") {
assert(WeatherClient.weatherFor("london,gb").location === "London, GB")
}
}
As with specs2, we see that ScalaTest has gone down a similar route of not using
explicit function definitions for defining tests, and instead using the test
function to allow a partially applied anonymous function that implements the test
to be stored against a test name string.
Unfortunately, I couldn’t find any way to hook into the test specification. What I would have liked is to be able to do something like:
test("weather for london") with Betamax("tape name") {
assert(WeatherClient.weatherFor("london,gb").location === "London, GB")
}
I just couldn’t work out a way to get this to work - I’d be very happy for someone
to point me in the right direction. Anyway, instead I implemented a Betamax
trait
that allows me to use a testWithBetamax
function:
trait Betamax {
protected def test(testName: String, testTags: Tag*)(testFun: => Unit)
def testWithBetamax(tape: String, mode: Option[TapeMode] = None)(testName: String, testTags: Tag*)(testFun: => Unit) = {
test(testName, testTags: _*) {
val recorder = new Recorder
val proxyServer = new ProxyServer(recorder)
recorder.insertTape(tape)
recorder.getTape.setMode(mode.getOrElse(recorder.getDefaultMode()))
proxyServer.start()
try {
testFun
} finally {
recorder.ejectTape()
proxyServer.stop()
}
}
}
}
This allows us to use Betamax in our test as follows:
class WeatherSuite extends FunSuite with Betamax {
testWithBetamax("scala-test", Some(TapeMode.READ_ONLY))("weather for london") {
assert(WeatherClient.weatherFor("london,gb").location === "London, GB")
}
}
Conclusion
So there we go, using Betamax to record and replay HTTP requests in Scala tests is definitely possible, and without having to compromise much in the way of test readability.
If you’d like the full source code that I worked on, it’s available on GitHub.
Update
I just couldn’t leave the ScalaTest example like that - it just wasn’t readable enough for me. You’ll remember that my ideal usage would look like this:
test("weather for london") with Betamax("tape name") {
assert(WeatherClient.weatherFor("london,gb").location === "London, GB")
}
The first problem with this is that the with
keyword is reserved in Scala - it’s
how you add extra traits beyond the one that’s being extended - so instead let’s
use the word using
.
The next problem is that we need to insert our code between the first and second
parameter list to the test
method. We can do this using a partially applied
function, by adding _
after the first parameter list, so our test declaration
now needs to look something like:
test("weather for london") _ using betamax("tape name") { ... }
Now the problem we have is that there’s no such method as using
on a function.
We need to transform it into an object that does have a using function - a trait
with an implicit conversion should do the trick:
trait Wrapped {
implicit def wrapPartialFunction(f: (=> Unit) => Unit) = new wrapped(f)
class wrapped(f: (=> Unit) => Unit) {
def using(f1: => Unit) = f {
f1
}
}
}
We’ve now created the ability to insert any extra function we like between the
test
function’s two parameter lists - indeed this trait could be used to add all
sorts of different resources around a ScalaTest suite, and should work equally well
with Should-In and Given-When-Then ScalaTest language.
All that remains to do now is to implement the Betamax
trait to allow us to use
the betamax
“keyword”:
import co.freeside.betamax.TapeMode
import co.freeside.betamax.Recorder
import co.freeside.betamax.proxy.jetty.ProxyServer
trait Betamax extends Wrapped{
def betamax(tape: String, mode: Option[TapeMode] = None)(testFun: => Unit) = {
println("Starting Betamax")
val recorder = new Recorder
val proxyServer = new ProxyServer(recorder)
recorder.insertTape(tape)
recorder.getTape.setMode(mode.getOrElse(recorder.getDefaultMode()))
proxyServer.start()
try {
testFun
} finally {
recorder.ejectTape()
proxyServer.stop()
}
}
}
Simple! And finally, our test looks like:
import org.scalatest.FunSuite
import co.freeside.betamax.TapeMode
class WeatherSuite extends FunSuite with Betamax {
test("weather for london using betamax") _ using betamax("scala-test", Some(TapeMode.READ_ONLY)) {
assert(WeatherClient.weatherFor("london,gb").location === "London, GB")
}
}
Ah, that’s better. I’m happy now :)
Implicit functions are very useful little things - if you look at the Specs2 source,
you’ll find them being used liberally all over the place to turn string test names
into rich test fragments, and so on. In this example you could of course also get
rid of the Some
from the TapeMode
, by declaring a function with signature
implicit def optionalTapeMode(t: TapeMode): Option[TapeMode]
.