Cloud Functions には 2 つの異なるタイプがあります。CloudEvent 関数とバックグラウンド関数は、内部 Cloud Platform イベントによってトリガーされるイベント ドリブン関数である一方、HTTP 関数は HTTP リクエストによってトリガーされます。
関数のテスト構造は、その関数で使用される Google Cloud Platform リソースによって決まります。また、関数がどのようにリソースを使用するかは、その関数のトリガー方法によって異なります。
このドキュメントでは、HTTP Cloud Functions のテスト方法について説明します。イベント ドリブン関数のテスト方法については、イベント ドリブン関数のテストをご覧ください。
HTTP でトリガーされる関数
大部分の Cloud Functions 単体テストの場合と同様に、HTTP トリガー関数単体テストには固有の構造があります。システムテストと統合テストは構造が似ていますが、多くの場合、他の関数タイプのテストには当てはまりません。
HTTP 関数の例を次に示します。
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
単体テスト
次のテストは、上記の HTTP トリガー関数の単体テストとして機能します。
Node.js
Express は、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 は、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
この単体テストを実行するには、以下のコマンドを使用します。
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 はmvn test
を使用して単体テストを実行します。
mvn test
C#
dotnet test
Ruby
bundle exec ruby functions/test/helloworld/http_test.rb
統合テスト
次のテストは、上記の関数の統合テストとして機能します。
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!");
}
}
HTTP 関数の統合テストを実行するには、次のコマンドを使用します。上記の統合テストサンプルは、以下に示す export
コマンドで使用される値を変更することで構成できます。
Node.js
export PORT=8080 mocha test/sample.integration.http.test.js --exit
Python
export PORT=8080 pytest sample_http_test_integration.py
Java
Java 統合テストサンプルは常にlocalhost:8080
で実行され、環境変数は使用されません。Maven は、mvn verify
を使用して統合テストを実行します。
mvn verify -Dit.test=ExampleIT
システムテスト
次のテストは、上記の関数のシステムテストとして機能します。
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 (
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
)
func TestHelloHTTPSystem(t *testing.T) {
client := http.Client{
Timeout: 10 * time.Second,
}
urlString := os.Getenv("BASE_URL") + "/HelloHTTP"
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!");
}
}
HTTP 関数のシステムテストを実行するには、次のコマンドで関数をデプロイします。
Node.js
gcloud functions deploy helloHttp \ --runtime nodejs10優先する Node.js のバージョンを指定するには、
--runtime
フラグに次の値を使用します。
nodejs10
nodejs12
nodejs14
(公開プレビュー)
Python
gcloud functions deploy hello_http \ --runtime python38優先する Python バージョンを指定するには、
--runtime
フラグに次の値を使用します。
python37
python38
python39
(公開プレビュー)
Go
gcloud functions deploy HelloHTTP \ --runtime go113優先する Go バージョンを指定するには、
--runtime
フラグに次の値を使用します。
go111
go113
Java
gcloud functions deploy java-hello-http \ --entry-point functions.HelloHttp \ --runtime java11 \ --memory 512MB
デプロイされた HTTP 関数を、以下のコマンドを使用してテストします。HTTP Cloud Functions のシステムテストと統合テストとの主な違いは、関数へのアクセスを可能にする URL です。
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 は、mvn verify
を使用してシステムテストを実行します。
mvn verify -Dit.test=ExampleSystemIT
ここで
YOUR_GCF_REGION
は Cloud Functions のリージョンです。YOUR_GCP_PROJECT_ID
は Cloud プロジェクト ID です。