Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add testing helpers and instructions
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
1 parent
71eb43f
commit 499ca87
Showing
6 changed files
with
398 additions
and
101 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
|
||
}); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
// 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 {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, | ||
}; | ||
}; |
Oops, something went wrong.