Testing your Fastly config in CI
Automated testing is a critical part of all modern web applications. Our customers need the ability to test VCL configurations before they push them to a live service. It has long been possible, using our API, to set up a service and push VCL via automation tools, then run a test request and examine the results. However, this has suffered from a few inconveniences:
Setting up a service from scratch requires a reasonably significant number of API calls.
While it only takes a few seconds to set up a service and sync configuration, there is no feedback mechanism to tell you it has deployed, so for the rapid turnaround needed for deploying a config and then testing it, this is inconvenient.
You can’t test any internal mechanisms of Fastly. You can only assert over the externally visible effects of your config. This makes it essentially a “black box” test.
If you don’t tear down the test service afterwards, you may end up leaving open security vulnerabilities in your site exposed to the internet.
Recently, I wrote about how we've added unit testing to our Fastly Fiddle tool. This provides a succinct test syntax and a wealth of instrumentation data that you can assert against:
Of course, ideally, it would be great to be able to use this capability to perform CI testing, using familiar tools like Mocha.
$ npm test
> mocha utils/ci-example.js
Service: www.example.com
Request normalisation
GET /?bb=2&ee=e2&ee=e1&&&dd=4&aa=1&cc=3 HTTP/2
✓ clientFetch.status is 200
✓ events.where(fnName=recv).count() is 1
✓ events.where(fnName=recv)[0].url is "/?aa=1&bb=2&cc=3&dd=4&ee=e1&ee=e2"
Robots.txt synthetic
GET /robots.txt HTTP/2
✓ clientFetch.status is 200
✓ clientFetch.resp includes "content-length: 30"
✓ clientFetch.bodyPreview includes "BadBot"
✓ originFetches.count() is 0
GET /ROBOTS.txt HTTP/2
✓ clientFetch.status is 404
10 passing (37s)
Fastly fiddle exposes an API that represents the results of a request travelling through Fastly as a detailed data structure in JSON. We could receive that and then run assertions on it using an assertion library such as Chai. But we wanted to add support for tests as more of a first-class feature of Fiddle.
With the introduction of built in support for testing, the succinct but expressive test syntax we covered last time can be used to bake test cases directly into the fiddle itself. With the Fiddle HTTP API and an understanding of the data structure we use to represent fiddles, we can drive this test engine remotely using automation. To enable this, I've created an abstraction layer for Mocha. First, let's look at how to use it, then we'll dive under the hood. Before we start, please note that Fastly Fiddle is an experimental service offered as part of our Labs program. Please don’t make this service a critical dependency for your code deploys. Please also keep in mind that individual fiddles are designed to be easily accessed without authentication. Your code could be exposed to anyone in possession of the URL of your fiddle.
Running your own CI tests
First, get the code from GitHub, and install dependencies:
$ git clone git@github.com:fastly/demo-fiddle-ci.git fastly-ci-testing
$ cd fastly-ci-testing
$ npm install
That last step assumes that you have NodeJS (10+) installed on your system. Now if you like you can go ahead and run npm test to run the example tests and see output similar to that at the top of this post (test will take around a minute).
All working? Now, the exciting bit: define your own service configuration and write your own tests. Open up src/test.js
and take a look at the top part:
testService('Service: example.com', {
spec: {
origins: ["https://httpbin.org"],
vcl: {
recv: `set req.url = querystring.sort(req.url);\nif (req.url.path == "/robots.txt") {\nerror 601;\n}`,
error: `if (obj.status == 601) {\nset obj.status = 200;\nset obj.response = "OK";synthetic "User-agent: BadBot" LF "Disallow: /";\nreturn(deliver);\n}`
}
},
scenarios: [
...
]
});
This first part, inside the spec property, defines the service configuration that you want to push to the edge for testing. At this point it's handy if you have a fiddle set up with the service config that you want to test. If so, click the menu in the fiddle UI and choose 'EMBED' to see a JSON-encoded version of the fiddle. Copy the vcl and origins properties into the spec section of your tests file. Otherwise, you'll find the data model later on in this post.
Now we can set up some test scenarios. Each scenario will test one feature of our configuration. For example, your service might be doing some geolocation, image optimisation, generating synthetic responses, A/B testing, and so on; but you will need to send different patterns of requests to test each feature.
Let's look at one of the example scenarios:
testService('Service: example.com', {
spec: { ... },
scenarios: [
{
name: "Caching",
requests: [
{
path: "/html",
tests: [
'originFetches.count() is 1',
'events.where(fnName=fetch)[0].ttl isAtLeast 3600',
'clientFetch.status is 200'
]
}, {
path: "/html",
tests: [
'originFetches.count() is 0',
'events.where(fnName=hit).count() is 1',
'clientFetch.status is 200'
]
}
]
},
...
]
});
This scenario is called "Caching", and is testing that if a request is repeated twice, the second time Fastly will have it in cache. Again, if you have your desired configuration in a fiddle already, you can export the request data via the 'EMBED' option in the fiddle menu (you just need the requests
property here), otherwise, later in this post you can find the full data model, minus the test syntax which we talked about in our previous post.
Once you've customised the src/test.js
file to your liking, you can run it with npm test. This command triggers the test script from the package.json
file, and is equivalent to running this in the root of the project:
$ ./node_modules/.bin/mocha src/test.js --exit
Notice that we're running the script using the mocha executable, not just node. This is important, so it's nice that we can use npm test
as a shortcut. Also, some CI tools will be smart about detecting that you have a test script declared in package.json
and will use that to kick off your testing.
Hopefully, Mocha is now running your tests! If so, congrats! Time to set this up to run on your CI tool of choice, and perhaps create a pipeline to deploy your new config to your Fastly account if it passes.
And now, here's how it works...
Fastly Fiddle client
I made a very small and incomplete client for the Fastly Fiddle tool in NodeJS which is included in the project as src/fiddle-client.js
. This exposes methods for publishing, cloning, and executing fiddles:
get(fiddleID)
: Return a fiddlepublish(fiddleData)
: Create or update a fiddle, return normalised fiddle dataclone(fiddleID)
: Clone a fiddle to a new ID, return normalised fiddle dataexecute(fiddleOrID, options)
: Execute a fiddle, and return result data.
To perform operations on fiddles, we therefore need to understand the fiddle data model and the result data model.
Fiddle data model
Fiddle-shaped objects looks like this (cosmetic properties that are only useful in the UI are omitted):
vcl | Obj | Asignación de nombres de subrutinas VCL a código fuente que se debe compilar para esa subrutina. |
.init | Str | VCL code for the initialisation space, outside of any VCL subroutine. Use this space to define custom subroutines, dictionaries, ACLs and directors. |
.recv | Str | Código VCL de la subrutina recv. |
.hash | Str | Código VCL de la subrutina hash. |
.hit | Str | Código VCL de la subrutina hit. |
.miss | Str | Código VCL de la subrutina miss. |
.pass | Str | Código VCL de la subrutina pass. |
.fetch | Str | Código VCL de la subrutina fetch. |
.deliver | Str | Código VCL de la subrutina deliver. |
.error | Str | Código VCL de la subrutina error. |
.log | Str | Código VCL de la subrutina log. |
origins | Array | Lista de orígenes que exponer a VCL. |
[n] | Str | Origen en forma de nombre del host y esquema; por ejemplo, «https://example.com». Se expone a VCL como F_origin_{arrayIndex}. |
requests | Array | Lista de peticiones que realizar cuando se ejecute el fiddle. |
[n] | Obj | Objeto que representa una petición. |
.method | Str | Método HTTP que usar para la petición. |
.path | Str | Ruta de URL a la petición (deberá comenzar por «/»). |
.headers | Str | Encabezados personalizados que enviar con la petición. |
.body | Str | Carga útil del cuerpo de la petición (si se configura, se añade automáticamente un encabezado Content-Length a la petición). |
.enable_cluster | Bool | Whether to enable intra-POP clustering. When enabled, after a cache key is computed, execution is transferred to the storage node that is the canonical storage location for that key. If disabled, processing will happen entirely on the random edge node that initially received the request, and therefore consecutive requests for the same object may not result in a cache hit, since Fastly POPs comprise many separate servers. |
.enable_shield | Bool | Si se activa y el POP que gestiona la petición no es la ubicación «shield» designada, se utiliza la ubicación de protección en lugar del backend, lo que fuerza que un fallo de caché transite por dos ubicaciones de POP de Fastly antes de enviarse al origen. |
.enable_waf | Bool | Whether to filter the request through Fastly's Web Application Firewall. If enabled, an additional 'waf' event will be included in the result data, and the request may trigger an error if WAF rules are matched. The WAF used by Fiddle is configured with a minimal set of rules and is not a suitable simulation of your own services' WAF configuration. |
.conn_type | Str | Un valor de entre «http» (no seguro), «h1», «h2» o «h3». |
.source_ip | Str | Dirección IP que debería aparecer como origen de la petición para funcionalidades de edge cloud como la geolocalización. |
.follow_redirects | Bool | Si se debe insertar una petición adicional automática en caso de que la respuesta sea un redireccionamiento y contenga un encabezado Location válido. |
.tests | Str / Array | Newline-delimited list of test expressions, following the test syntax that we introduced in my previous post. Note that although this is a text blob field you may send test expressions as an array if you wish. |
So now we can create, update and execute fiddles. All methods except execute() promise a fiddle object (ie fitting the above model). The execute method promises a result data object.
Result data model
The data promised by the execute()
method looks like this. Note that the clientFetches[...].tests
property, which is the one we're going to be more interested in here:
id | Str | Identificador de la sesión de ejecución. |
requestCount | Num | Number of client-side requests made to Fastly. Will be equal to the number of items in the clientFetches object. |
clientFetches | Obj | Asignación de las peticiones realizadas desde la aplicación del cliente al servicio Fastly. Las claves son identificadores únicos generados aleatoriamente que corresponden a la propiedad reqID de los objetos dentro de la lista de eventos. |
.${reqID} | Obj | Una petición de cliente único. |
.req | Str | Encabezados de petición, incluida la declaración de petición. |
.resp | Str | Encabezados de respuesta, incluida la línea de estado de respuesta. |
.respType | Str | Valor de encabezado de respuesta Content-type analizado (solo tipo mime). |
.isText | Bool | Si el cuerpo de la respuesta puede o no tratarse como texto. |
.isImage | Bool | Si el cuerpo de la respuesta puede tratarse o no como imagen. |
.status | Num | Estado de la respuesta HTTP. |
.bodyPreview | Str | Vista previa de texto UTF-8 del cuerpo (truncado en 1K). |
.bodyBytesReceived | Num | Cantidad de datos recibidos. |
.bodyChunkCount | Num | Número de fragmentos recibidos. |
.complete | Bool | Si la respuesta está completa o no. |
.trailers | Str | Colas de respuesta HTTP. |
.tests | Array | Lista de las pruebas que se han ejecutado en relación con esta petición del cliente. |
[idx] | Obj | Cada resultado de prueba es un objeto. |
.testExpr | Str | Expresión de la prueba; se basa en la sintaxis de pruebas que aprendimos antes. |
.pass | Bool | Si la prueba se superó o no. |
.expected | Any | Valor que se esperaba (también se incluye una representación de cadena en testExpr). |
.actual | Any | Valor que se observó. |
.detail | Str | Descripción del fallo de la aserción. |
.tags | Array | Marcadores que el analizador sintáctico de pruebas aplica a la prueba y que actualmente incluyen «async» y «slow-async». |
.testsPassed | Bool | Atajo cómodo que registra si se superaron o no todas las pruebas definidas en la propiedad tests de esta petición. |
originFetches | Obj | Asignación de las peticiones realizadas desde el servicio de Fastly y dirigidas a los orígenes. Las claves son identificadores únicos generados aleatoriamente que corresponden a la propiedad vclFlowKey de los objetos dentro de la lista de eventos. |
.${vclFlowKey} | Obj | Una petición a origen única. |
.req | Str | Bloque de peticiones HTTP. Contiene el método de petición, la ruta, la versión HTTP, los pares clave/valor del encabezado y el cuerpo de la petición. |
.resp | Str | Encabezado de respuesta HTTP. Contiene la línea de estado de respuesta y los encabezados de respuesta (no el cuerpo). |
.remoteAddr | Str | Dirección IP de origen resuelta. |
.remotePort | Num | Puerto en el servidor de origen. |
.remoteHost | Str | Nombre del host del servidor de origen. |
.elapsedTime | Num | Tiempo total dedicado a la recuperación de origen (ms). |
events | Array | Lista cronológica de los eventos que ocurrieron durante el procesamiento de la sesión de ejecución. |
[idx] | Obj | Cada elemento de matriz es un evento VCL. |
.vclFlowKey | Str | ID of the VCL flow in which the event occurred. A VCL flow is the innermost correlation identifier. Every one-way trip though the state machine is one VCL flow, therefore for each vclFlowKey there will be a maximum of one event per fnName. VCL flows may trigger an origin fetch, so if there was an origin fetch associated with this VCL flow, it will be recorded in the originFetches collection with this identifier. |
.reqID | Str | ID of the client request that triggered this event. A client request may trigger multiple VCL flows due to restarts, ESI, segmented caching or shielding. The details of the client request will be recorded in the clientFetches collection with this identifier. |
.fnName | Str | The type of the event. May be 'recv', 'hash', 'hit', 'miss', 'pass', 'waf', 'fetch', 'deliver', 'error', or 'log' |
.datacenter | Str | Código de tres letras que identifica la ubicación del centro de datos de Fastly en el que ocurrió este evento; por ejemplo, «LCY», «JFK» o «SYD». |
.nodeID | Str | Identificador numérico del servidor concreto en el que ocurrió este evento. |
. |
Cualquier propiedad que se notifique en la IU del fiddle respecto de un evento se incluirá en los datos de los resultados correspondientes al evento. | |
insights | Array | Lista de advertencias e infracciones de prácticas recomendadas que se detectaron y notificaron durante la ejecución. |
How might this be glued into a test harness? We'll use Mocha, one of the world's most popular test runners.
Mocha test case generator
Mocha uses a fairly conventional mechanism for writing test spec files: A CLI tool is used to invoke a script file, and the CLI will automatically create an environment in which certain functions exist: describe, for creating test suites, and it for specifying individual tests, along with some lifecycle hooks like before
, beforeEach
, after
, and afterEach
:
describe('My application', function()) {
let db;
before(async () => {
db = setupDatabase(); // Imaginary function
});
it("should load a widget", async function() {
const widget = db.get('test-widget');
expect(widget).to.have.key('id');
});
});
The problem with this is that our test cases need to be expressed as data, not code, so can't be declared in the way shown above. Fortunately, Mocha also allows tests to be created via a conventional constructor, if a Mocha
global is imported explicitly:
const Mocha = require('mocha');
const test = new Mocha.Test('should load a widget', function () { ... });
Inside a describe()
callback, this is an instance of the Mocha Suite
object (which is also why it's important not to use an arrow function for the callback). Provided that the callback, when initially executed, synchronously creates at least one test, Mocha will run our before()
code. In that callback, we can then cancel the hard-coded test and create some dynamic ones:
const Mocha = require('mocha');
describe('My application', function()) {
it ("has one hard-coded test", function () { return true });
before(async () => {
this.tests = []; // Delete the hard-coded test
this.addTest(new Mocha.Test('satisfies this dynamic test', function () { ... });
});
});
So, now we need to populate the dynamic tests from the test spec data. The src/test.js
file imports a function that it expects to have a signature of (name, data)
so to begin scaffolding src/fiddle-mocha.js
, that's what we export:
module.exports = function (name, data) {
// Create a testing scope for each Fastly service under test
describe(name, function () {
this.timeout(60000);
let fiddle;
// Push the VCL for this service and get a fiddle ID
// This will sync the VCL to the edge network, which takes 10-20 seconds
before(async () => {
fiddle = await FiddleClient.publish(data.spec);
await FiddleClient.execute(fiddle); // Make sure the VCL is pushed to the edge
});
...
})
};
Each time the exported function is called, we create a test suite representing the service configuration under test. By default, Mocha has a time timeout of 2 seconds, which isn't enough to perform remote tests, so we increase that.
The service configuration can be published to a fiddle in this service-level suite's before() callback. It's also worth triggering an execute because publish will only verify that the config is valid, not that it has yet been successfully deployed to the whole network. execute() will do that.
Now we can create sub-suites for each of the test scenarios:
module.exports = function (name, data) {
describe(name, function () {
...
for (const s of data.scenarios) {
describe(s.name, function() {
it('has some tests', () => true); // Sacrificial test
before(async () => {
this.tests = []; // Remove the sacrificial test from the outer suite
const result = await FiddleClient.execute({
...fiddle,
requests: s.requests
}, { waitFor: 'tests' });
for (const req of Object.values(result.clientFetches)) {
if (!req.tests) throw new Error('No test results provided');
const suite = new Mocha.Suite(req.req.split('\n')[0]);
for (const t of req.tests) {
suite.addTest(new Mocha.Test(t.testExpr, function() {
if (!t.pass) {
const e = new AssertionError(t.detail , {
actual: t.actual, expected: t.expected, showDiff: true
});
throw e;
}
}));
}
this.addSuite(suite);
}
});
});
}
});
};
Let's step through this:
For each of the scenarios, we call
describe()
, passing the scenario name and setting up a callback, which Mocha will invoke immediately.Define a sacrificial test to ensure that the
before()
callback is runIn the
before()
callback, eliminate the sacrificial test, and execute the fiddle, patching it with the requests that are defined for this scenario.waitFor: 'tests'
tells the API client to only resolve the promise when the test results are available.Iterate over each of the client requests that make up the scenario, and create a test suite for the request. We use the first line of the request header (eg. "GET / HTTP/2") as the name of the suite.
For each of the tests that were run on that request, create a new Mocha Test and add it to the test suite. Pass the relevant properties of the test result into the
AssertionError
if it was a fail.AssertionError
is a extension of the native JavaScript error object and defines additional properties forexpected
,actual
andshowDiff
. Mocha knows about these properties so can provide a better user experience in the test report.Add the request suite (
suite
) to the scenario suite (this
)The scenario suite has already been registered with the Mocha runner via a describe call, inside of the service-level suite which is also registered (as the root suite) via a describe call.
Mocha will end up discovering one or more root suites (via calls in test.js
to testService
), those will each contain one or more scenario suites, and each of those will contain one or more request suites. The request suites contain the tests defined on the request.
Wrapping up
Testing in CI is a great way of avoiding surprises when you activate a new version of your Fastly service, and Fiddle is a convenient tool to help you do it. If you already have a CI process that tests pull requests before you merge them, consider integrating some Fastly service tests as well. I’d love to know how it goes for you, so feel free to post your experience in the comments, or drop us an email.
That said, please don’t make this service a critical dependency for your code deploys. This is an experimental service offered as part of our Fastly Labs program, not subject to any SLA, and we’re providing it to gather feedback.