From 9c25913a705a67966ba01b5861729239f57b1ef3 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Wed, 1 Dec 2021 18:33:11 +0000 Subject: [PATCH] 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. --- docs/README.md | 1 + docs/testing-functions.md | 201 ++++++++++++++++++++++++++++++++ package.json | 4 + src/testing.ts | 55 +++++++++ test/integration/cloud_event.ts | 22 ++-- test/integration/http.ts | 29 +++-- 6 files changed, 294 insertions(+), 18 deletions(-) create mode 100644 docs/testing-functions.md create mode 100644 src/testing.ts diff --git a/docs/README.md b/docs/README.md index 196667a2..45815a42 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/testing-functions.md b/docs/testing-functions.md new file mode 100644 index 00000000..f1d7ae3f --- /dev/null +++ b/docs/testing-functions.md @@ -0,0 +1,201 @@ + + +# 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); + }); +}); +``` \ No newline at end of file diff --git a/package.json b/package.json index 6b8c63ab..d894a696 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/testing.ts b/src/testing.ts new file mode 100644 index 00000000..4357e68f --- /dev/null +++ b/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 + ); +}; diff --git a/test/integration/cloud_event.ts b/test/integration/cloud_event.ts index 21e18711..7a7a6da5 100644 --- a/test/integration/cloud_event.ts +++ b/test/integration/cloud_event.ts @@ -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 @@ -31,6 +31,7 @@ const TEST_CLOUD_EVENT = { some: 'payload', }, }; + const TEST_EXTENSIONS = { traceparent: '00-65088630f09e0a5359677a7429456db7-97f23477fb2bf5ec-01', }; @@ -38,6 +39,13 @@ const TEST_EXTENSIONS = { 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. @@ -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) diff --git a/test/integration/http.ts b/test/integration/http.ts index 65126a3c..553946c0 100644 --- a/test/integration/http.ts +++ b/test/integration/http.ts @@ -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', @@ -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'})