Image

Testing Cloudflare workers

Dani Hodovic Oct. 22, 2019 10 min read
Responsive image

I like Cloudflare workers. They allow you to execute arbitrary Javascript as close to your users as possible. For simple use cases your scripts probably don't need to be tested, but once they grow in size and complexity you probably want to be confident that they don't break production.

Something I dislike about the serverless trend is that most platforms are hard to test and debug. Testing is poorly documented and examples are far and few between.

I wrote this blog post after implementing a non-trivial Cloudflare worker script for a client. The script used the Caching API, communicated with a backend server and manipulated HTTP headers. It required correctness tests before we rolled it out in production. I dug through the Cloudflare documentation and related blog posts to find testing examples, but there wasn't enough material out there so I figured I'd consolidate the knowledge I have on testing Cloudflare workers.

The starting point for testing workers is in the Cloudflare documentation. However I struggled get the examples to work and it didn't cover common Node.js testing techniques such as stubbing, mocking and running debuggers. This post will cover common testing techniques and scenarios I use to test Cloudflare workers.

Introducing Cloudworker

The engineering team at Dollar Shave Club developed the Cloudworker project to provide a development and testing platform for Cloudflare workers. It's a simulated execution environment, very similar to the environment Cloudflare uses in production.

Cloudworker strives to be as similar to the Cloudflare Worker runtime as possible. A script should behave the same when executed by Cloudworker and when run within Cloudflare Workers. Please file an issue for scenarios in which Cloudworker behaves differently. As behavior differences are found, this package will be updated to match the Cloudflare Worker runtime. This may result in breakage if scripts depended on those behavior differences.

Cloudworker allows you to both run a local server to run worker scripts and to run the environments programmatically, such as in Mocha test suites. We'll cover programmatic use of Cloudworker in this post.

Setup

To run through these examples you'll need a few libraries:

Testing

Unit testing requests and responses

Cloudworker allows us to unit test requests and responses by injecting requests to the worker script and observing the generated response.

Cloudworker.dispatch()

Suppose we are building a simple worker script that returns a JSON response. Cloudflare workers implement the Service worker API. In practise this means that they receive a 'fetch' event containing a request and respond to the request by returning a response.

You have probably dealt with the request and response interfaces before, because they are the same objects as the ones used in the web Fetch API.

Here is our example worker:

async function handleRequest(event) {
  const body = {message: 'Hello mocha!'};
  return new Response(JSON.stringify(body), { status: 200 })
}

// eslint-disable-next-line no-restricted-globals
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event));
});

Now let's write a unit test which injects a request and runs it through the worker to return a response. The test looks like a standard mocha test. The trick is that we'll use the Cloudworker library to simulate a worker execution environment.

When creating the Cloudworker object we read our worker file and pass it as a string to the Cloudworker constructor. It then spawns a separate VM where the worker runs and exposes methods to dispatch requests to the worker.

The dispatch method takes a request and returns a promise containing the response. Using Mocha and Chai we can assert that the properties of the response object, such as HTTP body and status code are correct.

Example unit test:

const fs = require('fs');
const path = require('path');
const Cloudworker = require('@dollarshaveclub/cloudworker');
const { expect } = require('chai');

const workerScript = fs.readFileSync(path.resolve(__dirname, '../worker.js'), 'utf8');

describe('worker unit test', function () {
  this.timeout(60000);
  let worker;

  beforeEach(() => {
    // Create the simulated execution environment
    worker = new Cloudworker(workerScript);
  });

  it('tests requests and responses', async () => {
    const request = new Cloudworker.Request('https://mysite.com/api')
    // Pass the request through Cloudworker to simulate script exectuion
    const response = await worker.dispatch(request);
    const body = await response.json();
    expect(response.status).to.eql(200);
    expect(body).to.eql({message: 'Hello mocha!'});
  });
});

HTTP integration testing with an upstream server

Cloudflare workers usually communicate with an upstream backend. It could be a Rails, Django or Express server. We want to test the interaction between the worker script and the upstream backend. Since the worker uses HTTP to interact with the backend in production, we can create a simple HTTP API for testing purposes.

Image of Cloudworker.dispatch + HTTP upstream

If you've written your backend in Node.js you could technically import it as a library and use it in tests. If not you can use Express or Nock to build a testing API that returns identical responses to your production backend.

async function handleRequest(event) {
  const { request } = event;
  // Fetch the response from the backend
  const response = await fetch(request);
  // The response is originally immutable, so we have to clone it in order to
  // set the cache headers.
  // https://community.cloudflare.com/t/how-can-we-remove-cookies-from-request-to-avoid-being-sent-to-origin/35239/2
  const newResponse = new Response(response.body, response);
  newResponse.headers.set('my-header', 'some token');
  return newResponse;
}

// eslint-disable-next-line no-restricted-globals
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event));
});

The worker will issue actual HTTP requests to the backend server running on localhost. In this case the worker adds an additional HTTP header to the response before returning it to the client. We will test that the new header is indeed added to the response.

In our tests we'll create an Express testing server. The testing server will listen on localhost and respond to the worker over HTTP.

const fs = require('fs');
const path = require('path');
const Cloudworker = require('@dollarshaveclub/cloudworker');
const { expect } = require('chai');
const express = require('express');

const workerScript = fs.readFileSync(path.resolve(__dirname, '../upstream-worker.js'), 'utf8');

function createApp() {
  const app = express();
  app.get('/', function (req, res) {
    res.json({message: 'Hello from express!'});
  });
  return app;
}

describe('worker unit test', function () {
  this.timeout(60000);
  let serverAddress;
  let worker;

  beforeEach(() => {
    const upstream = createApp().listen();
    serverAddress = `http://localhost:${upstream.address().port}`
    worker = new Cloudworker(workerScript);
  });

  it('tests requests and responses', async () => {
    const req = new Cloudworker.Request(serverAddress);
    const res = await worker.dispatch(req);
    const body = await res.json();
    expect(res.headers.get('my-header')).to.eql('some token');
    expect(body).to.eql({message: 'Hello from express!'});
  });
});

Using nock for HTTP mocking

It's also possible to use github.com/nock/nock to simplify the creation of the testing server.

const fs = require('fs');
const path = require('path');
const Cloudworker = require('@dollarshaveclub/cloudworker');
const { expect } = require('chai');
const nock = require('nock');

const workerScript = fs.readFileSync(path.resolve(__dirname, '../upstream-worker.js'), 'utf8');

describe('upstream server test', function () {
  this.timeout(60000);
  let worker;

  beforeEach(() => {
    worker = new Cloudworker(workerScript);
  });

  it('uses Nock upstream server', async () => {
    const url = 'http://my-api.test';
    nock(url)
      .get('/')
      .reply(200, {message: 'Hello from Nock!'});

    const request = new Cloudworker.Request(url);
    const response = await worker.dispatch(request);
    const body = await response.json();
    expect(body).to.eql({message: 'Hello from Nock!'});
  });
});

Running a Cloudworker server

So far we've used the Cloudworker dispatch() method to inject Request objects and return Response objects. But what if we want to send real HTTP requests to the worker just as we would in production? Luckily Cloudworker can run as a standalone HTTP server on localhost, similar to how Cloudflare runs the worker in production. The .listen() method starts a HTTP server that binds to a random port on localhost.

Image of http client + HTTP upstream

We can now use our favourite HTTP client library such as axios or request to send HTTP requests to the Cloudworker.

const fs = require('fs');
const path = require('path');
const Cloudworker = require('@dollarshaveclub/cloudworker');
const { expect } = require('chai');
const axios = require('axios');

const workerScript = fs.readFileSync(path.resolve(__dirname, '../simple-worker.js'), 'utf8');

describe('http client test', function () {
  this.timeout(60000);
  let serverAddress;

  beforeEach(() => {
    const worker = new Cloudworker(workerScript);
    const server = worker.listen();
    serverAddress = `http://localhost:${server.address().port}`
  });

  it('uses axios', async () => {
    const response = await axios.get(serverAddress);
    expect(response.status).to.eql(200);
    expect(response.data).to.eql({message: 'Hello mocha!'});
  });
});

Spying and mocking with Sinon.js

Sometimes you want to test the internals of your worker script. For example, if your worker uses caching you may want to test that it returns cached responses rather than making upstream HTTP requests.

For this reason we may want to spy on the fetch HTTP client the worker uses. This allows us to assert on all the method calls made with fetch.

The Cloudworker library was written to simulate the production execution at Cloudflare at closely as possible and therefore each worker runs in a separate VM sandbox. This makes mocking difficult because we can't access or modify variables defined inside of the worker. The library instead allows you to create bindings at creation time to replace Service worker primitives with our own mock variants.

We can replace the built-in fetch HTTP client with a mock version to check how many times and with what arguments fetch was called.

worker = new Cloudworker(workerScript, {
  bindings: {
    fetch: fetchMock
  }
});

Here we'll use Sinon.js to mock the fetch API inside of the worker and assert it was called once with the expected request.

const fs = require('fs');
const path = require('path');
const Cloudworker = require('@dollarshaveclub/cloudworker');
const fetch = require('@dollarshaveclub/node-fetch');
const sinon = require('sinon');
const nock = require('nock');

const workerScript = fs.readFileSync(path.resolve(__dirname, '../upstream-worker.js'), 'utf8');

describe('unit test', function () {
  this.timeout(60000);
  let worker;
  let fetchMock;

  beforeEach(() => {
    fetchMock = sinon.fake(fetch);
    worker = new Cloudworker(workerScript, {
        // Inject our mocked fetch into the worker script
        bindings: {
          fetch: fetchMock
        }
      }
    );
  });

  it('uses Sinon.js spies to assert calls', async () => {
    const url = 'http://my-api.test';
    nock(url)
      .get('/')
      .reply(200, {message: 'Hello from Nock!'});

    const request = new Cloudworker.Request(url)
    await worker.dispatch(request);

    const expected = new Cloudworker.Request(url);
    sinon.assert.calledWith(fetchMock, expected);
  });
});

Tips & tricks

Debugging with NDB

NDB is a Node.js debugger that runs in Chrome. It allows us to stop the execution of a worker script and inspect the call stack or the scope variables.

The caveat when running a debugger in Cloudworker is that breakpoints within NDB do not work because the worker script is eval()ed at runtime. However we can workaround this by adding debugger; statements to the source code.

For example:

async function handleRequest(event) {
  const body = {message: 'Hello mocha!'};
  debugger;
  return new Response(JSON.stringify(body), { status: 200 })
}

// eslint-disable-next-line no-restricted-globals
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event));
});

Running ndb allows us to stop and inspect a worker during execution.

ndb mocha test/unit.test.js

Image debugger

Debugging 500 errors

By default Cloudworker will return a 500 error as a repose anytime things go wrong. This is frustrating to debug as you have to guess where the error happened and inject a debugger statement.

I like to add a helper function to automatically debug exceptions that arise in the worker process. In production the debugging code isn't executed, but in tests and development it is.

The helper function wraps the main handler and catches any errors that happen.

async function handleRequest(event) {
  const body = {message: 'Hello mocha!'};
  return new Response(JSON.stringify(body), { status: 200 })
}

// A wrapper function which only debugs errors the DEBUG_ERRORS variable is set
async function handle(event) {
  // If we're in production the DEBUG_ERRORS variable will not be set
  if (typeof DEBUG_ERRORS === 'undefined' || !DEBUG_ERRORS) {
    return handleRequest(event);
  }

  // Debug crashes in test and development
  try {
    const res =  await handleRequest(event);
    return res;
  } catch(err) {
    console.log(err);
    debugger;
  }
}

// eslint-disable-next-line no-restricted-globals
addEventListener('fetch', event => {
  event.respondWith(handle(event));
});

We use Cloudworker bindings to inject the variable DEBUG_ERRORS into the global state of the worker.

const fs = require('fs');
const path = require('path');
const Cloudworker = require('@dollarshaveclub/cloudworker');

const workerScript = fs.readFileSync(path.resolve(__dirname, '../worker-debug-errors.js'), 'utf8');

describe('unit test', function () {
  this.timeout(60000);
  let worker;

  beforeEach(() => {
    worker = new Cloudworker(workerScript, {
        bindings: {
          // Add a global variable that enables error debugging
          DEBUG_ERRORS: true,
        }
      }
    );
  });

  it('uses worker.dispatch() to test requests', async () => {
    const req = new Cloudworker.Request('https://mysite.com/api')
    await worker.dispatch(req);
  });
});

Running mocha with ndb enabled will now ensure the debugger stops anytime unexpected errors happen.

ndb mocha test/

Enabling the Cache API

By default Cloudworker does not enable the caching, presumably because it's still an experimental feature. To test caching you need to manually enable it when creating the Cloudworker object.

const worker = new Cloudworker(script, {
  enableCache: true
});

Examples

The example project can be found on Github.