Skip to content

Commit

Permalink
feat: add testing helpers and instructions (#392)
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 Dec 1, 2021
1 parent e716d9b commit 9c25913
Show file tree
Hide file tree
Showing 6 changed files with 294 additions and 18 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
201 changes: 201 additions & 0 deletions docs/testing-functions.md
@@ -0,0 +1,201 @@
<!--
# @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.

```js
// hello_tests.js
import * as functions from '@google-cloud/functions-framework';

// declare a cloud function and export it so that it can be
// imported in unit tests
export const HelloTests = (req, res) => {
res.send('Hello, World!');
};

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

This is a perfectly acceptable approach that allows you to keep your application
code decoupled from the Functions Framework, but it also has some drawbacks.
You won't automatically benefit from the implicit type hints and autocompletion
that are available when you pass a callback to `functions.http` directly:

```js
// hello_tests.js
import * as functions from '@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.

```js
import {getFunction} from "@google-cloud/functions-framework/testing";

describe("HelloTests", () => {
before(async () => {
// load the module that defines HelloTests
await import("./hello_tests.js");
});

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

## 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. You can
create simple stubs to use in unit tests.

```js
import assert from "assert";
import {getFunction} from "@google-cloud/functions-framework/testing";

describe("HelloTests", () => {
before(async () => {
// load the module that defines HelloTests
await import("./hello_tests.js");
});

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

// a Request stub with a simple JSON payload
const req = {
body: { foo: "bar" },
};
// a Response 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
[JavaScript SDK for CloudEvents](https://github.com/cloudevents/sdk-javascript) provides
APIs to create stub CloudEvent objects for use in tests.

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 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:

```js
import assert from "assert";
import sinon from "sinon";
import {CloudEvent} from "cloudevents";
import {getFunction} from "@google-cloud/functions-framework/testing";

import {MyDependency} from "./my_dependency.js";

describe("HelloCloudEvent", () => {
before(async () => {
// load the module that defines HelloCloudEvent
await import("./hello_cloud_event.js");
});

const sandbox = sinon.createSandbox();

beforeEach(() => {
sandbox.spy(MyDependency);
});

afterEach(() => {
sandbox.restore();
});

it("uses MyDependency", () => {
const HelloCloudEvent = getFunction("HelloCloudEvent");
HelloCloudEvent(new CloudEvent({
type: 'com.google.cloud.functions.test',
source: 'https://github.com/GoogleCloudPlatform/functions-framework-nodejs',
}));
// 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.

```js
import supertest from 'supertest';
import {getTestServer} from '@google-cloud/functions-framework/testing';

describe("HelloTests", function () {
before(async () => {
// load the module that defines HelloTests
await import("./hello_tests.js");
});

it("uses works with SuperTest", async () => {
// 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);
});
});
```
4 changes: 4 additions & 0 deletions package.json
Expand Up @@ -8,6 +8,10 @@
"repository": "GoogleCloudPlatform/functions-framework-nodejs",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"exports": {
".": "./build/src/index.js",
"./testing": "./build/src/testing.js"
},
"dependencies": {
"body-parser": "^1.18.3",
"express": "^4.16.4",
Expand Down
55 changes: 55 additions & 0 deletions src/testing.ts
@@ -0,0 +1,55 @@
// 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 {Server} from 'http';

import {HandlerFunction} from '.';
import {getRegisteredFunction} from './function_registry';
import {getServer} from './server';

/**
* Testing utility 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 registered. Did you forget to require the module that defined it?`
);
}
return getServer(
registeredFunction.userFunction,
registeredFunction.signatureType
);
};
22 changes: 15 additions & 7 deletions test/integration/cloud_event.ts
Expand Up @@ -13,9 +13,9 @@
// limitations under the License.

import * as assert from 'assert';
import * as functions from '../../src/functions';
import * as functions from '../../src/index';
import * as sinon from 'sinon';
import {getServer} from '../../src/server';
import {getTestServer} from '../../src/testing';
import * as supertest from 'supertest';

// A structured CloudEvent
Expand All @@ -31,13 +31,21 @@ const TEST_CLOUD_EVENT = {
some: 'payload',
},
};

const TEST_EXTENSIONS = {
traceparent: '00-65088630f09e0a5359677a7429456db7-97f23477fb2bf5ec-01',
};

describe('CloudEvent Function', () => {
let clock: sinon.SinonFakeTimers;

let receivedCloudEvent: functions.CloudEventsContext | null;
before(() => {
functions.cloudEvent('testCloudEventFunction', ce => {
receivedCloudEvent = ce;
});
});

beforeEach(() => {
clock = sinon.useFakeTimers();
// Prevent log spew from the PubSub emulator request.
Expand Down Expand Up @@ -256,15 +264,15 @@ describe('CloudEvent Function', () => {
body: {
...TEST_CLOUD_EVENT.data,
},
expectedCloudEvent: {...TEST_CLOUD_EVENT, ...TEST_EXTENSIONS},
expectedCloudEvent: {
...TEST_CLOUD_EVENT,
...TEST_EXTENSIONS,
},
},
];
testData.forEach(test => {
it(`${test.name}`, async () => {
let receivedCloudEvent: functions.CloudEventsContext | null = null;
const server = getServer((cloudEvent: functions.CloudEventsContext) => {
receivedCloudEvent = cloudEvent as functions.CloudEventsContext;
}, 'cloudevent');
const server = getTestServer('testCloudEventFunction');
await supertest(server)
.post('/')
.set(test.headers)
Expand Down
29 changes: 18 additions & 11 deletions test/integration/http.ts
Expand Up @@ -13,11 +13,26 @@
// limitations under the License.

import * as assert from 'assert';
import {getServer} from '../../src/server';
import * as supertest from 'supertest';
import {Request, Response} from '../../src/functions';

import * as functions from '../../src/index';
import {getTestServer} from '../../src/testing';

describe('HTTP Function', () => {
let callCount = 0;

before(() => {
functions.http('testHttpFunction', (req, res) => {
++callCount;
res.send({
result: req.body.text,
query: req.query.param,
});
});
});

beforeEach(() => (callCount = 0));

const testData = [
{
name: 'POST to empty path',
Expand Down Expand Up @@ -63,15 +78,7 @@ describe('HTTP Function', () => {

testData.forEach(test => {
it(test.name, async () => {
let callCount = 0;
const server = getServer((req: Request, res: Response) => {
++callCount;
res.send({
result: req.body.text,
query: req.query.param,
});
}, 'http');
const st = supertest(server);
const st = supertest(getTestServer('testHttpFunction'));
await (test.httpVerb === 'GET'
? st.get(test.path)
: st.post(test.path).send({text: 'hello'})
Expand Down

0 comments on commit 9c25913

Please sign in to comment.