Testing HTTP Functions

There are two distinct types of Cloud Functions. CloudEvent functions and background functions are both event-driven functions that are triggered by internal Cloud Platform events, while HTTP functions are triggered by HTTP requests.

A function's test structure depends on which Google Cloud Platform resources that function uses. In turn, a function's resource use depends on how that function is triggered.

This document describes how to test HTTP Cloud Functions. See Testing Event-Driven Functions for information on how to test event-driven functions.

HTTP-triggered functions

Like most Cloud Functions unit tests, HTTP-triggered function unit tests have a specific structure. However, system and integration tests are similar in structure—which is usually not the case for tests of other function types.

The following is an example HTTP function:

Node.js

const escapeHtml = require('escape-html');

/**
 * HTTP Cloud Function.
 *
 * @param {Object} req Cloud Function request context.
 *                     More info: https://expressjs.com/en/api.html#req
 * @param {Object} res Cloud Function response context.
 *                     More info: https://expressjs.com/en/api.html#res
 */
exports.helloHttp = (req, res) => {
  res.send(`Hello ${escapeHtml(req.query.name || req.body.name || 'World')}!`);
};

Python

from flask import escape

def hello_http(request):
    """HTTP Cloud Function.
    Args:
        request (flask.Request): The request object.
        <https://flask.palletsprojects.com/en/1.1.x/api/#incoming-request-data>
    Returns:
        The response text, or any set of values that can be turned into a
        Response object using `make_response`
        <https://flask.palletsprojects.com/en/1.1.x/api/#flask.make_response>.
    """
    request_json = request.get_json(silent=True)
    request_args = request.args

    if request_json and 'name' in request_json:
        name = request_json['name']
    elif request_args and 'name' in request_args:
        name = request_args['name']
    else:
        name = 'World'
    return 'Hello {}!'.format(escape(name))

Go


// Package helloworld provides a set of Cloud Functions samples.
package helloworld

import (
	"encoding/json"
	"fmt"
	"html"
	"net/http"
)

// HelloHTTP is an HTTP Cloud Function with a request parameter.
func HelloHTTP(w http.ResponseWriter, r *http.Request) {
	var d struct {
		Name string `json:"name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&d); err != nil {
		fmt.Fprint(w, "Hello, World!")
		return
	}
	if d.Name == "" {
		fmt.Fprint(w, "Hello, World!")
		return
	}
	fmt.Fprintf(w, "Hello, %s!", html.EscapeString(d.Name))
}

Java


import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.logging.Logger;

public class HelloHttp implements HttpFunction {
  private static final Logger logger = Logger.getLogger(HelloHttp.class.getName());

  private static final Gson gson = new Gson();

  @Override
  public void service(HttpRequest request, HttpResponse response)
      throws IOException {
    // Check URL parameters for "name" field
    // "world" is the default value
    String name = request.getFirstQueryParameter("name").orElse("world");

    // Parse JSON request and check for "name" field
    try {
      JsonElement requestParsed = gson.fromJson(request.getReader(), JsonElement.class);
      JsonObject requestJson = null;

      if (requestParsed != null && requestParsed.isJsonObject()) {
        requestJson = requestParsed.getAsJsonObject();
      }

      if (requestJson != null && requestJson.has("name")) {
        name = requestJson.get("name").getAsString();
      }
    } catch (JsonParseException e) {
      logger.severe("Error parsing JSON: " + e.getMessage());
    }

    var writer = new PrintWriter(response.getWriter());
    writer.printf("Hello %s!", name);
  }
}

C#

using Google.Cloud.Functions.Framework;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;

namespace HelloHttp
{
    public class Function : IHttpFunction
    {
        private readonly ILogger _logger;

        public Function(ILogger<Function> logger) =>
            _logger = logger;

        public async Task HandleAsync(HttpContext context)
        {
            HttpRequest request = context.Request;
            // Check URL parameters for "name" field
            // "world" is the default value
            string name = ((string) request.Query["name"]) ?? "world";

            // If there's a body, parse it as JSON and check for "name" field.
            using TextReader reader = new StreamReader(request.Body);
            string text = await reader.ReadToEndAsync();
            if (text.Length > 0)
            {
                try
                {
                    JsonElement json = JsonSerializer.Deserialize<JsonElement>(text);
                    if (json.TryGetProperty("name", out JsonElement nameElement) &&
                        nameElement.ValueKind == JsonValueKind.String)
                    {
                        name = nameElement.GetString();
                    }
                }
                catch (JsonException parseException)
                {
                    _logger.LogError(parseException, "Error parsing JSON request");
                }
            }

            await context.Response.WriteAsync($"Hello {name}!");
        }
    }
}

Ruby

require "functions_framework"
require "cgi"
require "json"

FunctionsFramework.http "hello_http" do |request|
  # The request parameter is a Rack::Request object.
  # See https://www.rubydoc.info/gems/rack/Rack/Request
  name = request.params["name"] ||
         (JSON.parse(request.body.read)["name"] rescue nil) ||
         "World"
  # Return the response body as a string.
  # You can also return a Rack::Response object, a Rack response array, or
  # a hash which will be JSON-encoded into a response.
  "Hello #{CGI.escape_html name}!"
end

Unit tests

These tests act as unit tests for the HTTP-triggered function above.

Node.js

Express is mocked using Sinon.
const assert = require('assert');
const sinon = require('sinon');
const uuid = require('uuid');

const {helloHttp} = require('..');

it('helloHttp: should print a name', () => {
  // Mock ExpressJS 'req' and 'res' parameters
  const name = uuid.v4();
  const req = {
    query: {},
    body: {
      name: name,
    },
  };
  const res = {send: sinon.stub()};

  // Call tested function
  helloHttp(req, res);

  // Verify behavior of tested function
  assert.ok(res.send.calledOnce);
  assert.deepStrictEqual(res.send.firstCall.args, [`Hello ${name}!`]);
});

Python

Flask is mocked using unittest.
from unittest.mock import Mock

import main


def test_print_name():
    name = 'test'
    data = {'name': name}
    req = Mock(get_json=Mock(return_value=data), args=data)

    # Call tested function
    assert main.hello_http(req) == 'Hello {}!'.format(name)


def test_print_hello_world():
    data = {}
    req = Mock(get_json=Mock(return_value=data), args=data)

    # Call tested function
    assert main.hello_http(req) == 'Hello World!'

Go


package helloworld

import (
	"net/http/httptest"
	"strings"
	"testing"
)

func TestHelloHTTP(t *testing.T) {
	tests := []struct {
		body string
		want string
	}{
		{body: `{"name": ""}`, want: "Hello, World!"},
		{body: `{"name": "Gopher"}`, want: "Hello, Gopher!"},
	}

	for _, test := range tests {
		req := httptest.NewRequest("GET", "/", strings.NewReader(test.body))
		req.Header.Add("Content-Type", "application/json")

		rr := httptest.NewRecorder()
		HelloHTTP(rr, req)

		if got := rr.Body.String(); got != test.want {
			t.Errorf("HelloHTTP(%q) = %q, want %q", test.body, got, test.want)
		}
	}
}

Java


import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.when;

import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.gson.Gson;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.Map;
import java.util.Optional;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

@RunWith(JUnit4.class)
public class HelloHttpTest {
  @Mock private HttpRequest request;
  @Mock private HttpResponse response;

  private BufferedWriter writerOut;
  private StringWriter responseOut;
  private static final Gson gson = new Gson();

  @Before
  public void beforeTest() throws IOException {
    MockitoAnnotations.initMocks(this);

    // use an empty string as the default request content
    BufferedReader reader = new BufferedReader(new StringReader(""));
    when(request.getReader()).thenReturn(reader);

    responseOut = new StringWriter();
    writerOut = new BufferedWriter(responseOut);
    when(response.getWriter()).thenReturn(writerOut);
  }

  @Test
  public void helloHttp_noParamsGet() throws IOException {
    new HelloHttp().service(request, response);

    writerOut.flush();
    assertThat(responseOut.toString()).isEqualTo("Hello world!");
  }
}

C#

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Xunit;

namespace HelloHttp.Tests
{
    public class FunctionUnitTest
    {
        [Fact]
        public async Task GetRequest_NoParameters()
        {
            DefaultHttpContext context = new DefaultHttpContext();
            string text = await ExecuteRequest(context);
            Assert.Equal("Hello world!", text);
        }

        [Fact]
        public async Task GetRequest_UrlParameters()
        {
            DefaultHttpContext context = new DefaultHttpContext
            {
                Request = { QueryString = new QueryString("?name=Cho") }
            };
            string text = await ExecuteRequest(context);
            Assert.Equal("Hello Cho!", text);
        }

        [Fact]
        public async Task PostRequest_BodyParameters()
        {
            string json = "{\"name\":\"Julie\"}";
            DefaultHttpContext context = new DefaultHttpContext
            {
                Request =
                {
                    Method = HttpMethods.Post,
                    Body = new MemoryStream(Encoding.UTF8.GetBytes(json))
                }
            };
            string text = await ExecuteRequest(context);
            Assert.Equal("Hello Julie!", text);
        }

        /// <summary>
        /// Executes the given request in the function, validates that the response
        /// status code is 200, and returns the text of the response body.
        /// </summary>
        private static async Task<string> ExecuteRequest(HttpContext context)
        {
            MemoryStream responseStream = new MemoryStream();
            context.Response.Body = responseStream;

            Function function = new Function(new NullLogger<Function>());
            await function.HandleAsync(context);
            Assert.Equal(200, context.Response.StatusCode);
            context.Response.BodyWriter.Complete();
            context.Response.Body.Position = 0;

            TextReader reader = new StreamReader(responseStream);
            return reader.ReadToEnd();
        }
    }
}

Ruby

require "minitest/autorun"
require "functions_framework/testing"

describe "functions_helloworld_http" do
  include FunctionsFramework::Testing

  it "prints a name" do
    load_temporary "helloworld/http/app.rb" do
      request = make_post_request "http://example.com:8080/", '{"name": "Ruby"}'
      response = call_http "hello_http", request
      assert_equal 200, response.status
      assert_equal "Hello Ruby!", response.body.join
    end
  end

  it "prints hello world" do
    load_temporary "helloworld/http/app.rb" do
      request = make_post_request "http://example.com:8080/", ""
      response = call_http "hello_http", request
      assert_equal 200, response.status
      assert_equal "Hello World!", response.body.join
    end
  end
end

Use the following command to run the unit tests:

Node.js

mocha test/sample.unit.http.test.js --exit

Python

pytest sample_http_test.py

Go

go test -v ./hello_http_test.go

Java

Maven uses mvn test to run unit tests:

mvn test

C#

dotnet test

Ruby

bundle exec ruby functions/test/helloworld/http_test.rb

Integration tests

These tests act as integration tests for the function above:

Node.js

const assert = require('assert');
const execPromise = require('child-process-promise').exec;
const path = require('path');
const requestRetry = require('requestretry');
const uuid = require('uuid');

const PORT = process.env.PORT || 8080;
const BASE_URL = `http://localhost:${PORT}`;
const cwd = path.join(__dirname, '..');

  let ffProc;

  // Run the functions-framework instance to host functions locally
  before(() => {
    // exec's 'timeout' param won't kill children of "shim" /bin/sh process
    // Workaround: include "& sleep <TIMEOUT>; kill $!" in executed command
    ffProc = execPromise(
      `functions-framework --target=helloHttp --signature-type=http --port ${PORT} & sleep 2; kill $!`,
      {shell: true, cwd}
    );
  });

  after(async () => {
    // Wait for the functions framework to stop
    await ffProc;
  });

  it('helloHttp: should print a name', async () => {
    const name = uuid.v4();

    const response = await requestRetry({
      url: `${BASE_URL}/helloHttp`,
      method: 'POST',
      body: {name},
      retryDelay: 200,
      json: true,
    });

    assert.strictEqual(response.statusCode, 200);
    assert.strictEqual(response.body, `Hello ${name}!`);
  });

Python

import os
import subprocess
import uuid

import requests
from requests.packages.urllib3.util.retry import Retry


def test_args():
    name = str(uuid.uuid4())
    port = os.getenv('PORT', 8080)  # Each functions framework instance needs a unique port

    process = subprocess.Popen(
      [
        'functions-framework',
        '--target', 'hello_http',
        '--port', str(port)
      ],
      cwd=os.path.dirname(__file__),
      stdout=subprocess.PIPE
    )

    # Send HTTP request simulating Pub/Sub message
    # (GCF translates Pub/Sub messages to HTTP requests internally)
    BASE_URL = f'http://localhost:{port}'

    retry_policy = Retry(total=6, backoff_factor=1)
    retry_adapter = requests.adapters.HTTPAdapter(
      max_retries=retry_policy)

    session = requests.Session()
    session.mount(BASE_URL, retry_adapter)

    name = str(uuid.uuid4())
    res = requests.post(
      BASE_URL,
      json={'name': name}
    )
    assert res.text == 'Hello {}!'.format(name)

    # Stop the functions framework process
    process.kill()
    process.wait()

Java


import static com.google.common.truth.Truth.assertThat;

import io.github.resilience4j.core.IntervalFunction;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

@RunWith(JUnit4.class)
public class ExampleIT {
  // Root URL pointing to the locally hosted function
  // The Functions Framework Maven plugin lets us run a function locally
  private static final String BASE_URL = "http://localhost:8080";

  private static Process emulatorProcess = null;
  private static HttpClient client = HttpClient.newHttpClient();

  @BeforeClass
  public static void setUp() throws IOException {
    // Get the sample's base directory (the one containing a pom.xml file)
    String baseDir = System.getProperty("basedir");

    // Emulate the function locally by running the Functions Framework Maven plugin
    emulatorProcess = new ProcessBuilder()
        .command("mvn", "function:run")
        .directory(new File(baseDir))
        .start();
  }

  @AfterClass
  public static void tearDown() throws IOException {
    // Terminate the running Functions Framework Maven plugin process
    emulatorProcess.destroy();
  }

  @Test
  public void helloHttp_shouldRunWithFunctionsFramework() throws Throwable {
    String functionUrl = BASE_URL + "/helloHttp";

    HttpRequest getRequest = HttpRequest.newBuilder().uri(URI.create(functionUrl)).GET().build();

    // The Functions Framework Maven plugin process takes time to start up
    // Use resilience4j to retry the test HTTP request until the plugin responds
    RetryRegistry registry = RetryRegistry.of(RetryConfig.custom()
        .maxAttempts(8)
        .intervalFunction(IntervalFunction.ofExponentialBackoff(200, 2))
        .retryExceptions(IOException.class)
        .build());
    Retry retry = registry.retry("my");

    // Perform the request-retry process
    String body = Retry.decorateCheckedSupplier(retry, () -> client.send(
        getRequest,
        HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)).body()
    ).apply();

    // Verify the function returned the right results
    assertThat(body).isEqualTo("Hello world!");
  }
}

To run integration tests for HTTP functions, use the following command. You can configure the integration test samples shown above by changing the values used in the export commands shown below.

Node.js

export PORT=8080
mocha test/sample.integration.http.test.js --exit

Python

export PORT=8080
pytest sample_http_test_integration.py

Java

The Java integration test example always runs at localhost:8080, and does not use environment variables. Maven uses mvn verify to run integration tests.

mvn verify -Dit.test=ExampleIT

System tests

These tests act as system tests for the function above:

Node.js

const assert = require('assert');
const Supertest = require('supertest');
const supertest = Supertest(process.env.BASE_URL);
const childProcess = require('child_process');

describe('system tests', () => {
  it('helloHttp: should print a name', async () => {
    await supertest
      .post('/helloHttp')
      .send({name: 'John'})
      .expect(200)
      .expect(response => {
        assert.strictEqual(response.text, 'Hello John!');
      });
  });

Python

import os
import uuid

import requests


def test_no_args():
    BASE_URL = os.getenv('BASE_URL')
    assert BASE_URL is not None

    res = requests.get('{}/hello_http'.format(BASE_URL))
    assert res.text == 'Hello, World!'


def test_args():
    BASE_URL = os.getenv('BASE_URL')
    assert BASE_URL is not None

    name = str(uuid.uuid4())
    res = requests.post(
      '{}/hello_http'.format(BASE_URL),
      json={'name': name}
    )
    assert res.text == 'Hello, {}!'.format(name)

Go


package helloworld

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"strings"
	"testing"
	"time"
)

const RuntimeVersion = "go113"

func TestMain(m *testing.M) {
	// Only run end-to-end tests when configured to do so.
	if os.Getenv("GOLANG_SAMPLES_E2E_TEST") == "" {
		log.Println("Skipping end-to-end tests: GOLANG_SAMPLES_E2E_TEST not set")
		os.Exit(m.Run())
	}

	retn, err := setupAndRun(m)
	if err != nil {
		log.Fatal(err)
	}
	os.Exit(retn)
}

func setupAndRun(m *testing.M) (int, error) {
	entryPoint := "HelloHTTP"
	name := entryPoint + "-" + time.Now().Format("20060102-150405")

	// Setup function for tests.
	cmd := exec.Command("gcloud", "functions", "deploy", name,
		"--entry-point="+entryPoint,
		"--runtime="+RuntimeVersion,
		"--allow-unauthenticated",
		"--trigger-http",
	)
	log.Printf("Running: %s %s", cmd.Path, strings.Join(cmd.Args, " "))
	if _, err := cmd.Output(); err != nil {
		log.Println(string(err.(*exec.ExitError).Stderr))
		return 1, fmt.Errorf("Setup: Deploy function: %w", err)
	}

	// Tear down the deployed function.
	defer func() {
		cmd = exec.Command("gcloud", "functions", "delete", name)
		log.Printf("Running: %s %s", cmd.Path, strings.Join(cmd.Args, " "))
		if _, err := cmd.Output(); err != nil {
			log.Println(string(err.(*exec.ExitError).Stderr))
			log.Printf("Teardown: Delete function: %v", err)
		}
	}()

	// Retrieve URL for tests.
	cmd = exec.Command("gcloud", "functions", "describe", name, "--format=value(httpsTrigger.url)")
	log.Printf("Running: %s %s", cmd.Path, strings.Join(cmd.Args, " "))
	out, err := cmd.Output()
	if err != nil {
		log.Println(string(err.(*exec.ExitError).Stderr))
		return 1, fmt.Errorf("Setup: Get function URL: %w", err)
	}
	if err := os.Setenv("BASE_URL", strings.TrimSpace(string(out))); err != nil {
		return 1, fmt.Errorf("Setup: os.Setenv: %w", err)
	}

	// Run the tests.
	return m.Run(), nil
}

func TestHelloHTTPSystem(t *testing.T) {
	client := http.Client{
		Timeout: 10 * time.Second,
	}
	urlString := os.Getenv("BASE_URL")
	testURL, err := url.Parse(urlString)
	if err != nil {
		t.Fatalf("url.Parse(%q): %v", urlString, err)
	}

	tests := []struct {
		body string
		want string
	}{
		{body: `{"name": ""}`, want: "Hello, World!"},
		{body: `{"name": "Gopher"}`, want: "Hello, Gopher!"},
	}

	for _, test := range tests {
		req := &http.Request{
			Method: http.MethodPost,
			Body:   ioutil.NopCloser(strings.NewReader(test.body)),
			URL:    testURL,
		}
		resp, err := client.Do(req)
		if err != nil {
			t.Fatalf("HelloHTTP http.Get: %v", err)
		}
		body, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			t.Fatalf("HelloHTTP ioutil.ReadAll: %v", err)
		}
		if got := string(body); got != test.want {
			t.Errorf("HelloHTTP(%q) = %q, want %q", test.body, got, test.want)
		}
	}
}

Java


import static com.google.common.truth.Truth.assertThat;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

@RunWith(JUnit4.class)
public class ExampleSystemIT {
  // Root URL pointing to your Cloud Functions deployment
  // TODO<developer>: set this value, as an environment variable or within your test code
  private static final String BASE_URL = System.getenv("FUNCTIONS_BASE_URL");

  private static HttpClient client = HttpClient.newHttpClient();

  @Test
  public void helloHttp_shouldRunWithFunctionsFramework() throws IOException, InterruptedException {
    String functionUrl = BASE_URL + "/HelloHttp";

    java.net.http.HttpRequest getRequest =
        java.net.http.HttpRequest.newBuilder().uri(URI.create(functionUrl)).GET().build();

    HttpResponse response = client.send(getRequest, HttpResponse.BodyHandlers.ofString());
    assertThat(response.body().toString()).isEqualTo("Hello world!");
  }
}

To run system tests for HTTP functions, deploy your functions with the following command:

Node.js

gcloud functions deploy helloHttp \
--runtime nodejs10 
You can use the following values for the --runtime flag to specify your preferred Node.js version:
  • nodejs10
  • nodejs12
  • nodejs14 (public preview)

Python

gcloud functions deploy hello_http \
--runtime python38 
You can use the following values for the --runtime flag to specify your preferred Python version:
  • python37
  • python38
  • python39 (public preview)

Go

gcloud functions deploy HelloHTTP \
--runtime go113 
You can use the following values for the --runtime flag to specify your preferred Go version:
  • go111 (Deprecated)
  • go113

Java

gcloud functions deploy java-hello-http \
--entry-point functions.HelloHttp \
--runtime java11 \
--memory 512MB 

Use the following commands to test your deployed HTTP function. Note that the primary difference between system tests and integration tests for HTTP Cloud Functions is the URL where the function can be reached:

Node.js

export BASE_URL=https://YOUR_GCF_REGION-YOUR_GCP_PROJECT_ID.cloudfunctions.net/
mocha test/sample.system.http.test.js --exit

Python

export BASE_URL=https://YOUR_GCF_REGION-YOUR_GCP_PROJECT_ID.cloudfunctions.net/
pytest sample_http_test_system.py

Go

export BASE_URL=https://YOUR_GCF_REGION-YOUR_GCP_PROJECT_ID.cloudfunctions.net/
go test -v ./hello_http_system_test.go

Java

Maven uses mvn verify to run system tests.

mvn verify -Dit.test=ExampleSystemIT

where:

  • YOUR_GCF_REGION is your Cloud Functions region.
  • YOUR_GCP_PROJECT_ID is your Cloud project ID.