Writing and testing Azure Functions with TypeScript
By Martijn Storck
Azure Functions is a serverless compute solution that integrates with a wide selection of (Azure) cloud services to allow developers to build isolated blocks of event-driven code in a cloud-native fashion. This post demonstrates how an Azure Function written in TypeScript can interact with a database without any actual database logic in the function itself, and how to write unit tests for these functions.
The complete example can be found in the GitHub repository. The commit history follows the sections below, so you can follow along.
Install Azure Functions Core Tools
The Azure Functions Core Tools enable local development of functions using a CLI inside or outside an editor. Personally, I use JetBrains Webstorm for TypeScript development, but unfortunately the Azure Functions support from Rider and IntelliJ has not yet carried over so CLI it is!
The tools are available for Windows, macOS and Linux. Installation instructions can be found over at Microsoft.com. For macOS and Homebrew, installation is as follows:
$ brew tap azure/functions
$ brew install azure-functions-core-tools@4
The result is a func
binary ready to use:
Create a new Function App with a HttpTrigger function
The command func init
generates a new app in the desired runtime and language, complete with
an initialised git repository:
$ mkdir azure-functions-test-example
$ cd azure-functions-test-example
$ func init --worker-runtime node --language typescript
$ npm install
The CLI provides extensive online help, and the parameters in these examples are optional. Running
func init
or func new
without arguments takes you through the available options in a wizard. To
keep the example concise, let’s create an example HTTP trigger function by specifying the template and
name on the command line:
$ func new --template HttpTrigger --name Greet
The generated code is found in Greet/index.ts
and looks as follows:
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<void> {
context.log("HTTP trigger function processed a request.");
const name = req.query.name || (req.body && req.body.name);
const responseMessage = name
? "Hello, " + name + ". This HTTP triggered function executed successfully."
: "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.";
context.res = {
body: responseMessage,
};
};
To start the local functions host, issue the npm start
command.
After a short while the host presents the local URL for the Greet
function:
$ npm start
[…]
Azure Functions Core Tools
Core Tools Version: 4.0.4829 Commit hash: N/A (64-bit)
Function Runtime Version: 4.11.2.19273
Functions:
Greet: [GET,POST] http://localhost:7071/api/Greet
Verify that it works by calling the function in a new terminal:
$ curl "http://localhost:7071/api/Greet?name=Martijn"
Hello, Martijn. This HTTP triggered function executed successfully.
Unit testing (pure) serverless functions
There is very little code in the Function App. The Greet
function listed above is the only actual code, so that is
all we need to test.
Communication with the outside world is primarily orchestrated through triggers and bindings. Triggers define how a function is invoked (e.g. an incoming HTTP request, or a timer event) and carry data associated with the event. Bindings are a way of defining a connection to another resource (e.g. a database, or a storage blob) without hard coding access credentials. The function receives incoming data through parameters and sends data to output bindings using its return value.
The Azure Functions runtime connects triggers and bindings to function parameters according
to the specification in the accompanying function.json
file. The function interacts with the runtime through
a shared Context object and additional trigger and binding-specific arguments. The type looks as follows:
export type AzureFunction = (context: Context, ...args: any[]) => Promise<any> | void;
The Greet
function has an HTTP trigger and output binding, meaning it gets a request from the client and
sends a response. To test our Greet
function, all the test harness needs to do is send a valid Context
object and a HttpRequest
object, as that is what the runtime sends to our function. There is no
global state or dependency injection to consider. The function
writes the response for the HTTP output binding to context.res
, which is what we will be testing for.
Install Jest
To write the unit tests, we use the Jest testing framework. Start by adding jest to the project and generating a configuration to enable the TypeScript preset:
$ npm install --save-dev jest ts-jest
$ npx ts-jest config:init
Note that I chose to not install @types/jest
, which are the excellent but unofficial
DefinitelyTyped typings for Jest. Instead, we will import the built-in types from @jest/globals
. I
would recommend this for all new deployments, since that way the types will always be in sync with the
used Jest version.
To allow running tests using npm test
, update the scripts definition in package.json
as follows:
{
// …
"scripts": {
// …
"test": "jest"
},
// …
}
Write the first test
A common convention is to put test files in the function folder, next to the source files. So to test
Greet/index.ts
we will create a Greet/Index.test.ts
with the following contents:
import { describe, expect, test, beforeEach } from "@jest/globals";
import { Context, HttpRequest } from "@azure/functions";
import httpTrigger from "./index";
describe("Greet", () => {
let context: Context;
let request: HttpRequest;
beforeEach(() => {
// Really crude and unsafe implementations that will be replaced soon
context = { log: () => {} } as unknown as Context;
request = { query: {} } as unknown as HttpRequest;
});
test("greets the user by the supplied name", async () => {
request.query = { name: "Azure fan" };
await httpTrigger(context, request);
expect(context.res.body).toMatch(/^Hello, Azure fan/);
});
test("provides instructions if no name is given", async () => {
await httpTrigger(context, request);
expect(context.res.body).toMatch(/^This HTTP triggered function executed/);
});
});
The context and request variables for the function under test are initialized in the beforeEach
function.
For now, we apply some classic hacks to trick the TypeScript compiler into accepting our minimal objects
that only implement the context.log
and reequest.query
properties required by the Greet
function.
These will be replaced with a proper implementation momentarily.
Both tests call the httpTrigger
function from index.js
and check the response. Input in the form
of a query string variable is set in the request.query
property.
These tests should pass without issue:
$ npm test
PASS Greet/index.test.ts
Greet
✓ greets the user by the supplied name (2 ms)
✓ provides instructions if no name is given (1 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Building valid Context and Request objects using a factory
The context type and various request types in the @azure/functions
package contain many fields that
can be used by functions. Let’s create a helper that provides objects with the correct shape. Create a
new file in the root of your project called testHelper.ts
with the following contents:
import { Context, Form, HttpRequest, Logger } from "@azure/functions";
export const buildContext = (): Context => ({
bindingData: undefined,
bindingDefinitions: [],
bindings: {},
executionContext: undefined,
invocationId: "",
log: createLogger(),
traceContext: undefined,
done: () => {},
});
export const buildHttpRequest = (): HttpRequest => ({
headers: undefined,
method: undefined,
params: undefined,
parseFormBody(): Form { return undefined; },
query: {},
url: "",
user: undefined,
});
const createLogger = (): Logger => {
const logger = () => {};
logger.error = () => {};
logger.info = () => {};
logger.verbose = () => {};
logger.warn = () => {};
return logger;
};
This file contains functions to create valid stubs that should work as a baseline for most functions. Adjust the test file as follows:
import { buildContext, buildHttpRequest } from "../testHelper";
/// …
describe("Greet", () => {
let context: Context;
let request: HttpRequest;
beforeEach(() => {
context = buildContext();
request = buildHttpRequest();
});
/// …
});
Excellent! The tests run as before, but the code looks nicer and allow us to move forward.
Adding a database into the mix
To test a real-world example, let’s build a function that has an HTTP Trigger, but adds input and output
bindings to CosmosDB. The function will accept an id
parameter in the query string, and we will
configure the runtime to fetches a document with that id from a CosmosDB container. The function will
return the document to the HTTP output binding, and additionally a new item will be created in CosmosDB
to log the access of the document. If the document cannot be found, the function will return a 404 response.
Schematically, this looks as follows:
Quickstart with CosmosDB (optional)
The functions can be written and tested without an actual connection to a CosmosDB instance. But if you want to see the code in action, you will need a database to connect to. Follow the Quickstart guide to create a free CosmosDB instance through the Azure portal. This tutorial assumes that you follow all the steps in the Quickstart tutorial, including creating TodoList container with an id 1 document and the correct partition key (which we’ll hardcode in the binding to reduce complexity).
After the database is created, navigate to your Cosmos DB account in the portal, and head to
Settings > Keys. Make note of the Primary Connection String. Open local.settings.json
and add a new
line to store the connection string in CosmosDbConnectionString
.
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "node",
"AzureWebJobsStorage": "",
"CosmosDbConnectionString": "<your connection string here>"
}
}
The binding of CosmosDB
Let’s create a new function to implement the specified behaviour:
$ func new --template HttpTrigger --name ToDoItem
Add the input and output bindings to ToDoItem/function.json
, but don’t remove the existing HTTP out
binding as we still want to return data to the client:
{
"bindings": [
// …
{
"type": "cosmosDB",
"direction": "in",
"name": "toDoItem",
"databaseName": "ToDoList",
"collectionName": "Items",
"createIfNotExists": false,
"connectionStringSetting": "CosmosDbConnectionString",
"Id": "{Query.id}",
"PartitionKey": "personal"
},
{
"type": "cosmosDB",
"direction": "out",
"name": "outputDocument",
"databaseName": "ToDoList",
"collectionName": "Logs",
"createIfNotExists": true,
"connectionStringSetting": "CosmosDbConnectionString"
}
]
}
These bindings define the connection with the database and instruct the Functions runtime on how to
perform the heavy lifting for us. The runtime will fetch the document with the id specified in the query
string and store that in the toDoItem variable. Additionally, the object that is assigned to the
outputDocument
binding on the context
wil be written to the Logs
container by the runtime.
Our function does not know about the database and can operate in a purely functional manner. The
implementation looks as follows:
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest,
toDoItem: any // the CosmosDB document fetched for us by the runtime
): Promise<void> {
if (toDoItem === undefined) {
context.res = { status: 404 };
return;
}
context.bindings.outputDocument = {
// our new log document
id: context.invocationId,
requestedId: req.query.id,
timestamp: new Date(),
};
context.res = { body: toDoItem }; // the response to the HTTP client
};
export default httpTrigger;
Test your function using curl:
# Request a nonexistent document
$ curl -i "http://localhost:7071/api/ToDoItem?id=2"
HTTP/1.1 404 Not Found
# Request an existing document
$ curl "http://localhost:7071/api/ToDoItem?id=1"
{
"id": "1",
…
"category": "personal",
"name": "groceries",
"description": "Pick up apples and strawberries.",
"isComplete": false
}
and look for the created log document using the Data Explorer in the Azure portal:
It’s not always possible to avoid side effects in Azure Functions. For instance your function might need to consult an external API and that is completely valid. But in this example, the bindings provided by the runtime allow us to interact with the database in a purely functional manner. This will also help with testing.
Testing our ToDoItem function
The boilerplate for this test is the same as for the previous function. The full source can be found in the git repository.
The first two test cases describe the behaviour when the item for the requested id cannot be found; it returns a 404 and no document to be written to the Logs collection:
test("returns 404 if item is not found", async () => {
await httpTrigger(context, request);
expect(context.res.status).toEqual(404);
});
test("does not return a log document if item is not found", async () => {
await httpTrigger(context, request);
expect(context.bindings.outputDocument).toBeUndefined();
});
For the cases where the document does exist, we send a mock document object as third parameter to our function, just as the Functions runtime would do:
test("returns the document from the input binding", async () => {
await httpTrigger(context, request, { id: "1" });
expect(context.res.body).toEqual({ id: "1" });
});
test("returns a log document to the outputDocument binding", async () => {
request.query = { id: "1" };
await httpTrigger(context, request, { id: "1" });
expect(context.bindings.outputDocument).toHaveProperty("id");
expect(context.bindings.outputDocument).toHaveProperty("requestedId", "1");
expect(context.bindings.outputDocument).toHaveProperty("timestamp");
});
Why this is great
This is a great example of the power of serverless functions and how they simplify development and testing. The actual function is just that: a TypeScript function. The test code is a bare-bones jest example that does not know about HTTP or CosmosDB, but simulates the contract between the runtime and the functions.
When the function is deployed in production, the output binding could be switched out for another database
(document or SQL) altogether and the code would work without alterations. Amending the code to respond
to an Azure Service Bus message instead of an HTTP request would require but minor alterations. All that
our Function App contains is the logic and test code. There are no runtime dependencies in package.json
and web servers or database drivers to update.
If you want to learn more about Azure Functions, I recommend checking out the appropriate AZ-204 training path and the official documentation.