/ testing

Isolating integration tests from external HTTP services with Talkback

A good test (be it unit or integration) should be fast, isolated, repeatable and in total control of its context.
If you're doing integration tests that make real HTTP calls to external services, even if it's another service you own, you're instantly breaking those 4 requirements. Let's see why.

  • Not fast: your test's duration directly depends on how fast/slow the other service is. You might be adding hundreds of ms to your test case with each HTTP call.
  • Not isolated: what if the other service is temporary down? or the internet is down?
  • Not repeatable: If you make a call to create an object on the other service (like a user with a unique email), can you make that same call again and expect the same result?
  • Not in control of context: You might not be able to setup all the data that the other service needs to return edge cases. Also, it might be very difficult, or even impossible, to clean up the "environment" after your test runs.

From these points we can see that mocking HTTP requests might be a good idea.

It's important to note that exercising your app using the real services can add very valuable information. But I think that's something more suitable for a suite of smoke tests, where you pick a very small subset of tests that you know can be run again and again without major consequences.

How to mock?

One option could be to mock objects that make HTTP calls with fake ones that instead serve fixed data that we control through our tests.
That might be a good option for unit testing, but for integration, I'd like to stay as close to the real thing as I can. I want to make sure I'm testing the HTTP library, parsers, request building, etc...

In the ruby world, there's a very popular tool called vcr that takes care of this.
With VCR, every time your app tries to make a HTTP request, VCR will look at all the requests it currently knows about (tapes) and if any of them matches, it will immediately return the recorded response. If the request doesn't match anything, it will continue the HTTP request to the real service and create a new tape saving the response to disk. The next time your test runs, no HTTP request will be made.

I find the concept very simple and maintanable in the long run - if any request changes, just re-run your test and let the library update its tapes after hitting the real service.

Introducing talkback

Talkback is heavily inspired by a similar tool made by flickr called yakbak. Unfortunately, yakbak lost some traction, so I decided to write my own tool and solve some issues I found with it.

Talkback is a node.js library that acts as a HTTP proxy, saving requests and playing them back in the future, much like VCR.

A big difference with VCR is that talkback is intended to be run as a separate process alongside your application.
Being a separate process has the benefit of language independence. You can use talkback with a Node app, a Java app or even a Ruby app. I like it that you can use the same tool across completely different projects.
The downside is that you will need to be able to run node in your environment too. However, if your app has a frontend, there's a good chance that node was already in your ecosystem.

Let's see an example to show how talkback works.
Imagine that your app needs to connect with Github's API.

In a javascript file talkback.js, we setup our talkback instance:

var talkback = require("talkback");

var server = talkback({
  host: "https://api.github.com",
  path: __dirname + "/tapes",
  port: 9090
});
server.start();

and then we can start it with node talkback.js.

Now, every request made to http://localhost:9090/ will be proxied to Github in case it's a new request, or served from existing tapes in case it's a known one.

For example, if we make the following request
curl http://localhost:9090/repos/ijpiantanida/talkback
talkback will proxy it to Github and create the following tape under path.

{
    meta: {
        createdAt: "2017-12-03T23:37:48.247Z",
        host: "https://api.github.com",
        resHumanReadable: true
    },
    req: {
        url: "/repos/ijpiantanida/talkback",
        method: "GET",
        headers: {
            "user-agent": "curl/7.54.0",
            accept: "*/*"
        },
        body: ""
    },
    res: {
        status: 200,
        headers: {
            server: [
                "GitHub.com"
            ],
            date: [
                "Wed, 03 Dec 2017 23:37:49 GMT"
            ],
            "content-type": [
                "application/json; charset=utf-8"
            ],
            "content-length": [
                "5283"
            ]
            // ... more headers ...
        },
        body: "{\n  \"id\": 103073370,\n  \"name\": \"talkback\",\n  \"full_name\": \"ijpiantanida/talkback\",\n  \"owner\": {\n    \"login\": \"ijpiantanida\"," // ... etc ...
    },
}

If we repeat the curl command again
curl http://localhost:9090/repos/ijpiantanida/talkback
we will get the exact same response, but this time talkback will not proxy the request to Github, but use the tape instead, which is super-fast.

In order for a request to be considered a match, everything in req should be the same (unless you exclude some headers in the config).

You can edit anything in the tape, or create new ones to match totally different requests. Talkback will pick and serve all the tapes in path.
Rename the tapes to something that represents the scenario, so they're easy to navigate later on.
Tapes use the JSON5 format, which has some interesting extra features over normal JSON (like comments!).

Tapes summary

After some time you will probably have tons of different tapes for your app's test suite. It can be difficult to keep track of which ones are still under use.
To help with this, when talkback exits, it will print a list of all the tapes that have NOT been used and a list of all the new tapes created under that session.
After getting a green build, you can safely delete all the tapes that have not been used.

===== SUMMARY =====
New tapes:
- unnamed-4.json5
Unused tapes:
- not-valid-request.json5
- user-profile.json5

We've been using talkback in some of our projects and we're quite happy with it, so hopefully you'll find it useful too. Make sure to read the README for some additional configurations.
Pull requests, new features or any comment are welcome.

Happy test writing!