Skip to content

Commit

Permalink
feat: add testing helpers and instructions
Browse files Browse the repository at this point in the history
This commit adds some helpers for unit testing cloud functions along
with some documentation about how to use them.

I also refactored some of our out tests to use the helpers.
  • Loading branch information
matthewrobertson committed Nov 20, 2021
1 parent 71eb43f commit 7844b3d
Show file tree
Hide file tree
Showing 6 changed files with 395 additions and 101 deletions.
1 change: 1 addition & 0 deletions docs/README.md
Expand Up @@ -3,6 +3,7 @@
This directory contains advanced docs around the Functions Framework.

- [Testing events and Pub/Sub](events.md)
- [Testing Functions](testing-functions.md)
- [Debugging Functions](debugging.md)
- [Running and Deploying Docker Containers](docker.md)
- [Writing a Function in Typescript](typescript.md)
Expand Down
200 changes: 200 additions & 0 deletions docs/testing-functions.md
@@ -0,0 +1,200 @@
<!--
# @title Testing Functions
-->

# Testing Functions

This guide covers writing unit tests for functions using the Functions Framework
for Node.js.

## Overview of function testing

One of the benefits of the functions-as-a-service paradigm is that functions are
easy to test. In many cases, you can simply call a function with input, and test
the output. You do not need to set up (or mock) an actual server.

The Functions Framework provides utility methods that streamline the process of
setting up functions and the environment for testing, constructing input
parameters, and interpreting results. These are available in the
`@google-cloud/functions-framework/testing` module.

## Loading functions for testing

The easiest way to get started unit testing Node.js cloud functions is to explicitly
export the functions you wish to unit test.

```javascript
const functions = require('@google-cloud/functions-framework');

// Declare a cloud function
export const HelloTests = (req, res) => {
res.send('Hello, World!');
};

// register the HelloTests with the Functions Framework
functions.http('HelloTests', HelloTests);

// Export the function so it can be imported in unit tests
module.exports = HelloTests;
```

This is perfectly acceptable approach that allows you to keep your application
code decoupled from the Functions Framework, but it also has some drawbacks.
It violates the [DRY principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself),
and you won't automatically benefit from the implicit type hints and autocompletion
that are available when you pass a callback to `functions.http` directly:

```javascript
const functions = require('@google-cloud/functions-framework');

// register the HelloTests with the Functions Framework
functions.http('HelloTests', (req, res) => {
// req and res are strongly typed here
});
```

The testing module provides a `getFunction` helper method that can be used to
access a function that was registered with the Functions Framework. To use it in
your unit test you must first load the module that registers the function you wish
to test.

```javascript
const {getFunction} = require('@google-cloud/functions-framework/testing');

describe('HelloTests', () => {
before(() => {
// load the module that defines HelloTest
require('./HelloTests');
});

it('is testable', () => {
// get the function using the name it was registered with
const sut = getFunction('HelloTest');
// ...
});
});
```

## Testing HTTP functions

Testing an HTTP function is generally as simple as generating a request, calling
the function, and asserting against the response.

HTTP functions are passed an [express.Request](https://expressjs.com/en/api.html#req)
and an [express.Response](https://expressjs.com/en/api.html#res) as arguments. It is
not difficult to create simple stubs to use in unit tests.

```javascript
const {getFunction} = require('@google-cloud/functions-framework/testing');

describe('HelloTests', () => {
before(() => {
// load the module that defines HelloTest
require('./HelloTests');
});

it('is testable', () => {
// get the function using the name it was registered with
const HelloTest = getFunction('HelloTest');

// a Request stub with a simple JSON payload
const req = {
body: {foo: 'bar'}
};
// a Request stub that captures the sent response
let result;
const res = {
send: (x) => {result = x}
};

// invoke the function
HelloTest(req, res);

// assert the response matches the expected value
assert.equal(result, 'Hello, World!')
});
});
```

## Testing CloudEvent functions

Testing a CloudEvent function works similarly. The `testing` module provides
methods to help construct example CloudEvent objects that can be used to invoke
the function.

Unlike HTTP functions, event functions do not accept a response argument. Instead, you
will need to test side effects. A common approach is to replace your function's
dependencies with [mock objects](https://martinfowler.com/articles/mocksArentStubs.html)
that can be used to verify its behavior. The [sinonjs](https://sinonjs.org/) is a
standalone library for creating mocks that work with any Javascript testing framework:

```javascript
const sinon = require("sinon");
const {getFunction, testCloudEvent} = require("@google-cloud/functions-framework/testing");

const {MyDependency} = require("./MyDependency");

describe("HelloCloudEvent", function () {

before(() => {
// load the module that defines HelloCloudEvent
require('./HelloCloudEvent');
});

const sandbox = sinon.createSandbox();

beforeEach(function () {
sandbox.spy(MyDependency);
});

afterEach(function () {
sandbox.restore();
});

it("uses MyDependency", () => {
const HelloCloudEvent = getFunction("HelloCloudEvent");
HelloCloudEvent(testCloudEvent());
// assert that the cloud function invoked `MyDependency.someMethod()`
assert(MyDependency.someMethod.calledOnce);
});

});
```

## Integration Testing With SuperTest

The `testing` module also includes utilities that help you write high-level, integration
tests to verify the behavior of the Functions Framework HTTP server that invokes your function
to respond to requests. The [SuperTest](https://github.com/visionmedia/supertest) library
provides a developer friendly API for writing HTTP integration tests in javascript. The
`testing` module includes a `getTestServer` helper to help you test your functions using
SuperTest.

```javascript
const supertest = require("supertest");
const {getTestServer} = require("@google-cloud/functions-framework/testing");


describe("HelloTests", function () {

before(() => {
// load the module that defines HelloTests
require('./HelloTests')
});


it("uses works with SuperTest", () => {
// Call getTestServer with the name of function you wish to test
const server = getTestServer("HelloTests");

// Invoke HelloTests with SuperTest
await supertest(server)
.post('/')
.send({some: 'payload'})
.set('Content-Type', 'application/json')
.expect('Hello, World!')
.expect(200);
});

});
```
125 changes: 125 additions & 0 deletions src/testing.ts
@@ -0,0 +1,125 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// This module provides a set of utility functions that are useful for unit testing Cloud Functions.
import {CloudEventsContext, HandlerFunction} from '.';
import {getRegisteredFunction} from './function_registry';
import {getServer} from './server';

/**
* The default CloudEvent returned by testCloudEvent
*/
const TEST_CLOUD_EVENT = {
specversion: '1.0',
type: 'com.google.cloud.functions.test',
source: 'https://github.com/GoogleCloudPlatform/functions-framework-nodejs',
id: 'aaaaaa-1111-bbbb-2222-cccccccccccc',
time: '2020-05-13T01:23:45Z',
datacontenttype: 'application/json',
data: {
test: 'payload',
},
};

/**
* The CloudRvent fields that should be sent as HTTP headers in binary
* CloudEvent requests.
*/
const BINARY_CLOUD_EVENT_HEADER_FIELDS: (keyof CloudEventsContext)[] = [
'specversion',
'type',
'source',
'subject',
'id',
'time',
'datacontenttype',
];

/**
* Testing utitlity for retrieving a function registered with the Functions Framework
* @param functionName the name of the function to get
* @returns a function that was registered with the Functions Framework
*
* @beta
*/
export const getFunction = (
functionName: string
): HandlerFunction | undefined => {
return getRegisteredFunction(functionName)?.userFunction;
};

/**
* Create an Express server that is configured to invoke a function that was
* registered with the Functions Framework. This is a useful utility for testing functions
* using [supertest](https://www.npmjs.com/package/supertest).
* @param functionName the name of the function to wrap in the test server
* @returns a function that was registered with the Functions Framework
*
* @beta
*/
export const getTestServer = (functionName: string): Server => {
const registeredFunction = getRegisteredFunction(functionName);
if (!registeredFunction) {
throw new Error(
`The provided function "${functionName}" was not registed. Did you forget to require the module that defined it?`
);
}
return getServer(
registeredFunction.userFunction,
registeredFunction.signatureType
);
};

/**
* Create a dummy CloudEvent object that can be used to invoke CloudEvent functions
* @param properties a set of properties to overried in the CloudEvent
* @returns an object that can be used to invoke a CloudEvent function
*
* @beta
*/
export const testCloudEvent = (
properties: Partial<CloudEventsContext> = {}
): CloudEventsContext => {
return {...TEST_CLOUD_EVENT, ...properties};
};

/**
* Create the HTTP headers and body to send as a binary CloudEvent HTTP request
* @param cloudEvent the CloudEvent to convert
* @returns the headers and body to send in an HTTP request
*
* @beta
*/
export const toBinaryCloudEvent = (
cloudEvent: CloudEventsContext
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): {headers: Record<string, string | undefined>; body: any} => {
const headers: {[key: string]: string} = {
'Content-Type': 'application/json',
};
BINARY_CLOUD_EVENT_HEADER_FIELDS.forEach(field => {
if (cloudEvent[field]) {
headers[`ce-${field}`] = cloudEvent[field];
}
});

if (cloudEvent.traceparent) {
headers['traceparent'] = cloudEvent.traceparent;
}

return {
headers,
body: cloudEvent.data,
};
};

0 comments on commit 7844b3d

Please sign in to comment.