Skip to content

Commit 9c25913

Browse files
feat: add testing helpers and instructions (#392)
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.
1 parent e716d9b commit 9c25913

File tree

6 files changed

+294
-18
lines changed

6 files changed

+294
-18
lines changed

docs/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
This directory contains advanced docs around the Functions Framework.
44

55
- [Testing events and Pub/Sub](events.md)
6+
- [Testing Functions](testing-functions.md)
67
- [Debugging Functions](debugging.md)
78
- [Running and Deploying Docker Containers](docker.md)
89
- [Writing a Function in Typescript](typescript.md)

docs/testing-functions.md

+201
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<!--
2+
# @title Testing Functions
3+
-->
4+
5+
# Testing Functions
6+
7+
This guide covers writing unit tests for functions using the Functions Framework
8+
for Node.js.
9+
10+
## Overview of function testing
11+
12+
One of the benefits of the functions-as-a-service paradigm is that functions are
13+
easy to test. In many cases, you can simply call a function with input, and test
14+
the output. You do not need to set up (or mock) an actual server.
15+
16+
The Functions Framework provides utility methods that streamline the process of
17+
setting up functions and the environment for testing, constructing input
18+
parameters, and interpreting results. These are available in the
19+
`@google-cloud/functions-framework/testing` module.
20+
21+
## Loading functions for testing
22+
23+
The easiest way to get started unit testing Node.js Cloud Functions is to explicitly
24+
export the functions you wish to unit test.
25+
26+
```js
27+
// hello_tests.js
28+
import * as functions from '@google-cloud/functions-framework';
29+
30+
// declare a cloud function and export it so that it can be
31+
// imported in unit tests
32+
export const HelloTests = (req, res) => {
33+
res.send('Hello, World!');
34+
};
35+
36+
// register the HelloTests with the Functions Framework
37+
functions.http('HelloTests', HelloTests);
38+
```
39+
40+
This is a perfectly acceptable approach that allows you to keep your application
41+
code decoupled from the Functions Framework, but it also has some drawbacks.
42+
You won't automatically benefit from the implicit type hints and autocompletion
43+
that are available when you pass a callback to `functions.http` directly:
44+
45+
```js
46+
// hello_tests.js
47+
import * as functions from '@google-cloud/functions-framework';
48+
49+
// register the HelloTests with the Functions Framework
50+
functions.http('HelloTests', (req, res) => {
51+
// req and res are strongly typed here
52+
});
53+
```
54+
55+
The testing module provides a `getFunction` helper method that can be used to
56+
access a function that was registered with the Functions Framework. To use it in
57+
your unit test you must first load the module that registers the function you wish
58+
to test.
59+
60+
```js
61+
import {getFunction} from "@google-cloud/functions-framework/testing";
62+
63+
describe("HelloTests", () => {
64+
before(async () => {
65+
// load the module that defines HelloTests
66+
await import("./hello_tests.js");
67+
});
68+
69+
it("is testable", () => {
70+
// get the function using the name it was registered with
71+
const HelloTest = getFunction("HelloTests");
72+
// ...
73+
});
74+
});
75+
```
76+
77+
## Testing HTTP functions
78+
79+
Testing an HTTP function is generally as simple as generating a request, calling
80+
the function, and asserting against the response.
81+
82+
HTTP functions are passed an [express.Request](https://expressjs.com/en/api.html#req)
83+
and an [express.Response](https://expressjs.com/en/api.html#res) as arguments. You can
84+
create simple stubs to use in unit tests.
85+
86+
```js
87+
import assert from "assert";
88+
import {getFunction} from "@google-cloud/functions-framework/testing";
89+
90+
describe("HelloTests", () => {
91+
before(async () => {
92+
// load the module that defines HelloTests
93+
await import("./hello_tests.js");
94+
});
95+
96+
it("is testable", () => {
97+
// get the function using the name it was registered with
98+
const HelloTest = getFunction("HelloTests");
99+
100+
// a Request stub with a simple JSON payload
101+
const req = {
102+
body: { foo: "bar" },
103+
};
104+
// a Response stub that captures the sent response
105+
let result;
106+
const res = {
107+
send: (x) => {
108+
result = x;
109+
},
110+
};
111+
112+
// invoke the function
113+
HelloTest(req, res);
114+
115+
// assert the response matches the expected value
116+
assert.equal(result, "Hello, World!");
117+
});
118+
});
119+
```
120+
121+
## Testing CloudEvent functions
122+
123+
Testing a CloudEvent function works similarly. The
124+
[JavaScript SDK for CloudEvents](https://github.com/cloudevents/sdk-javascript) provides
125+
APIs to create stub CloudEvent objects for use in tests.
126+
127+
Unlike HTTP functions, event functions do not accept a response argument. Instead, you
128+
will need to test side effects. A common approach is to replace your function's
129+
dependencies with mock objects that can be used to verify its behavior. The
130+
[sinonjs](https://sinonjs.org/) is a standalone library for creating mocks that work with
131+
any Javascript testing framework:
132+
133+
```js
134+
import assert from "assert";
135+
import sinon from "sinon";
136+
import {CloudEvent} from "cloudevents";
137+
import {getFunction} from "@google-cloud/functions-framework/testing";
138+
139+
import {MyDependency} from "./my_dependency.js";
140+
141+
describe("HelloCloudEvent", () => {
142+
before(async () => {
143+
// load the module that defines HelloCloudEvent
144+
await import("./hello_cloud_event.js");
145+
});
146+
147+
const sandbox = sinon.createSandbox();
148+
149+
beforeEach(() => {
150+
sandbox.spy(MyDependency);
151+
});
152+
153+
afterEach(() => {
154+
sandbox.restore();
155+
});
156+
157+
it("uses MyDependency", () => {
158+
const HelloCloudEvent = getFunction("HelloCloudEvent");
159+
HelloCloudEvent(new CloudEvent({
160+
type: 'com.google.cloud.functions.test',
161+
source: 'https://github.com/GoogleCloudPlatform/functions-framework-nodejs',
162+
}));
163+
// assert that the cloud function invoked `MyDependency.someMethod()`
164+
assert(MyDependency.someMethod.calledOnce);
165+
});
166+
});
167+
```
168+
169+
## Integration testing with SuperTest
170+
171+
The `testing` module also includes utilities that help you write high-level, integration
172+
tests to verify the behavior of the Functions Framework HTTP server that invokes your function
173+
to respond to requests. The [SuperTest](https://github.com/visionmedia/supertest) library
174+
provides a developer friendly API for writing HTTP integration tests in javascript. The
175+
`testing` module includes a `getTestServer` helper to help you test your functions using
176+
SuperTest.
177+
178+
```js
179+
import supertest from 'supertest';
180+
import {getTestServer} from '@google-cloud/functions-framework/testing';
181+
182+
describe("HelloTests", function () {
183+
before(async () => {
184+
// load the module that defines HelloTests
185+
await import("./hello_tests.js");
186+
});
187+
188+
it("uses works with SuperTest", async () => {
189+
// call getTestServer with the name of function you wish to test
190+
const server = getTestServer("HelloTests");
191+
192+
// invoke HelloTests with SuperTest
193+
await supertest(server)
194+
.post("/")
195+
.send({ some: "payload" })
196+
.set("Content-Type", "application/json")
197+
.expect("Hello, World!")
198+
.expect(200);
199+
});
200+
});
201+
```

package.json

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
"repository": "GoogleCloudPlatform/functions-framework-nodejs",
99
"main": "build/src/index.js",
1010
"types": "build/src/index.d.ts",
11+
"exports": {
12+
".": "./build/src/index.js",
13+
"./testing": "./build/src/testing.js"
14+
},
1115
"dependencies": {
1216
"body-parser": "^1.18.3",
1317
"express": "^4.16.4",

src/testing.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2021 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// This module provides a set of utility functions that are useful for unit testing Cloud Functions.
16+
import {Server} from 'http';
17+
18+
import {HandlerFunction} from '.';
19+
import {getRegisteredFunction} from './function_registry';
20+
import {getServer} from './server';
21+
22+
/**
23+
* Testing utility for retrieving a function registered with the Functions Framework
24+
* @param functionName the name of the function to get
25+
* @returns a function that was registered with the Functions Framework
26+
*
27+
* @beta
28+
*/
29+
export const getFunction = (
30+
functionName: string
31+
): HandlerFunction | undefined => {
32+
return getRegisteredFunction(functionName)?.userFunction;
33+
};
34+
35+
/**
36+
* Create an Express server that is configured to invoke a function that was
37+
* registered with the Functions Framework. This is a useful utility for testing functions
38+
* using [supertest](https://www.npmjs.com/package/supertest).
39+
* @param functionName the name of the function to wrap in the test server
40+
* @returns a function that was registered with the Functions Framework
41+
*
42+
* @beta
43+
*/
44+
export const getTestServer = (functionName: string): Server => {
45+
const registeredFunction = getRegisteredFunction(functionName);
46+
if (!registeredFunction) {
47+
throw new Error(
48+
`The provided function "${functionName}" was not registered. Did you forget to require the module that defined it?`
49+
);
50+
}
51+
return getServer(
52+
registeredFunction.userFunction,
53+
registeredFunction.signatureType
54+
);
55+
};

test/integration/cloud_event.ts

+15-7
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
// limitations under the License.
1414

1515
import * as assert from 'assert';
16-
import * as functions from '../../src/functions';
16+
import * as functions from '../../src/index';
1717
import * as sinon from 'sinon';
18-
import {getServer} from '../../src/server';
18+
import {getTestServer} from '../../src/testing';
1919
import * as supertest from 'supertest';
2020

2121
// A structured CloudEvent
@@ -31,13 +31,21 @@ const TEST_CLOUD_EVENT = {
3131
some: 'payload',
3232
},
3333
};
34+
3435
const TEST_EXTENSIONS = {
3536
traceparent: '00-65088630f09e0a5359677a7429456db7-97f23477fb2bf5ec-01',
3637
};
3738

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

42+
let receivedCloudEvent: functions.CloudEventsContext | null;
43+
before(() => {
44+
functions.cloudEvent('testCloudEventFunction', ce => {
45+
receivedCloudEvent = ce;
46+
});
47+
});
48+
4149
beforeEach(() => {
4250
clock = sinon.useFakeTimers();
4351
// Prevent log spew from the PubSub emulator request.
@@ -256,15 +264,15 @@ describe('CloudEvent Function', () => {
256264
body: {
257265
...TEST_CLOUD_EVENT.data,
258266
},
259-
expectedCloudEvent: {...TEST_CLOUD_EVENT, ...TEST_EXTENSIONS},
267+
expectedCloudEvent: {
268+
...TEST_CLOUD_EVENT,
269+
...TEST_EXTENSIONS,
270+
},
260271
},
261272
];
262273
testData.forEach(test => {
263274
it(`${test.name}`, async () => {
264-
let receivedCloudEvent: functions.CloudEventsContext | null = null;
265-
const server = getServer((cloudEvent: functions.CloudEventsContext) => {
266-
receivedCloudEvent = cloudEvent as functions.CloudEventsContext;
267-
}, 'cloudevent');
275+
const server = getTestServer('testCloudEventFunction');
268276
await supertest(server)
269277
.post('/')
270278
.set(test.headers)

test/integration/http.ts

+18-11
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,26 @@
1313
// limitations under the License.
1414

1515
import * as assert from 'assert';
16-
import {getServer} from '../../src/server';
1716
import * as supertest from 'supertest';
18-
import {Request, Response} from '../../src/functions';
17+
18+
import * as functions from '../../src/index';
19+
import {getTestServer} from '../../src/testing';
1920

2021
describe('HTTP Function', () => {
22+
let callCount = 0;
23+
24+
before(() => {
25+
functions.http('testHttpFunction', (req, res) => {
26+
++callCount;
27+
res.send({
28+
result: req.body.text,
29+
query: req.query.param,
30+
});
31+
});
32+
});
33+
34+
beforeEach(() => (callCount = 0));
35+
2136
const testData = [
2237
{
2338
name: 'POST to empty path',
@@ -63,15 +78,7 @@ describe('HTTP Function', () => {
6378

6479
testData.forEach(test => {
6580
it(test.name, async () => {
66-
let callCount = 0;
67-
const server = getServer((req: Request, res: Response) => {
68-
++callCount;
69-
res.send({
70-
result: req.body.text,
71-
query: req.query.param,
72-
});
73-
}, 'http');
74-
const st = supertest(server);
81+
const st = supertest(getTestServer('testHttpFunction'));
7582
await (test.httpVerb === 'GET'
7683
? st.get(test.path)
7784
: st.post(test.path).send({text: 'hello'})

0 commit comments

Comments
 (0)