Skip to content

Commit ddc55d6

Browse files
committed
Improve test coverage.
1 parent 79f2b73 commit ddc55d6

File tree

3 files changed

+265
-0
lines changed

3 files changed

+265
-0
lines changed

tests/test_aio.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pathlib
16+
import re
17+
import sys
18+
import tempfile
19+
20+
from unittest.mock import AsyncMock, Mock, call
21+
22+
import pytest
23+
24+
from functions_framework import exceptions
25+
from functions_framework.aio import (
26+
LazyASGIApp,
27+
_cloudevent_func_wrapper,
28+
_http_func_wrapper,
29+
create_asgi_app,
30+
)
31+
32+
TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions"
33+
34+
35+
def test_import_error_without_starlette(monkeypatch):
36+
import builtins
37+
38+
original_import = builtins.__import__
39+
40+
def mock_import(name, *args, **kwargs):
41+
if name.startswith("starlette"):
42+
raise ImportError(f"No module named '{name}'")
43+
return original_import(name, *args, **kwargs)
44+
45+
monkeypatch.setattr(builtins, "__import__", mock_import)
46+
47+
# Remove the module from sys.modules to force re-import
48+
if "functions_framework.aio" in sys.modules:
49+
del sys.modules["functions_framework.aio"]
50+
51+
with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo:
52+
import functions_framework.aio
53+
54+
assert "Starlette is not installed" in str(excinfo.value)
55+
assert "pip install functions-framework[async]" in str(excinfo.value)
56+
57+
58+
def test_invalid_function_definition_missing_function_file():
59+
source = TEST_FUNCTIONS_DIR / "missing_function_file" / "main.py"
60+
target = "function"
61+
62+
with pytest.raises(exceptions.MissingSourceException) as excinfo:
63+
create_asgi_app(target, source)
64+
65+
assert re.match(
66+
r"File .* that is expected to define function doesn't exist", str(
67+
excinfo.value)
68+
)
69+
70+
71+
def test_asgi_typed_signature_not_supported():
72+
source = TEST_FUNCTIONS_DIR / "typed_events" / "typed_event.py"
73+
target = "function_typed"
74+
75+
with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo:
76+
create_asgi_app(target, source, "typed")
77+
78+
assert "ASGI server does not support typed events (signature type: 'typed')" in str(
79+
excinfo.value
80+
)
81+
82+
83+
def test_asgi_background_event_not_supported():
84+
source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py"
85+
target = "function"
86+
87+
with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo:
88+
create_asgi_app(target, source, "event")
89+
90+
assert (
91+
"ASGI server does not support legacy background events (signature type: 'event')"
92+
in str(excinfo.value)
93+
)
94+
assert "Use 'cloudevent' signature type instead" in str(excinfo.value)
95+
96+
97+
@pytest.mark.asyncio
98+
async def test_lazy_asgi_app(monkeypatch):
99+
actual_app = AsyncMock()
100+
create_asgi_app_mock = Mock(return_value=actual_app)
101+
monkeypatch.setattr(
102+
"functions_framework.aio.create_asgi_app", create_asgi_app_mock)
103+
104+
# Test that it's lazy
105+
target, source, signature_type = "func", "source.py", "http"
106+
lazy_app = LazyASGIApp(target, source, signature_type)
107+
108+
assert lazy_app.app is None
109+
assert lazy_app._app_initialized is False
110+
111+
# Mock ASGI call parameters
112+
scope = {"type": "http", "method": "GET", "path": "/"}
113+
receive = AsyncMock()
114+
send = AsyncMock()
115+
116+
# Test that it's initialized when called
117+
await lazy_app(scope, receive, send)
118+
119+
assert lazy_app.app is actual_app
120+
assert lazy_app._app_initialized is True
121+
assert create_asgi_app_mock.call_count == 1
122+
assert create_asgi_app_mock.call_args == call(
123+
target, source, signature_type)
124+
125+
# Verify the app was called
126+
actual_app.assert_called_once_with(scope, receive, send)
127+
128+
# Test that subsequent calls use the same app
129+
create_asgi_app_mock.reset_mock()
130+
actual_app.reset_mock()
131+
132+
await lazy_app(scope, receive, send)
133+
134+
assert create_asgi_app_mock.call_count == 0 # Should not create app again
135+
actual_app.assert_called_once_with(
136+
scope, receive, send) # Should be called again
137+
138+
139+
@pytest.mark.asyncio
140+
async def test_http_func_wrapper_json_response():
141+
async def http_func(request):
142+
return {"message": "hello", "count": 42}
143+
144+
wrapper = _http_func_wrapper(http_func, is_async=True)
145+
146+
request = Mock()
147+
response = await wrapper(request)
148+
149+
assert response.__class__.__name__ == "JSONResponse"
150+
assert b'"message":"hello"' in response.body
151+
assert b'"count":42' in response.body
152+
153+
154+
@pytest.mark.asyncio
155+
async def test_http_func_wrapper_sync_function():
156+
def sync_http_func(request):
157+
return "sync response"
158+
159+
wrapper = _http_func_wrapper(sync_http_func, is_async=False)
160+
161+
request = Mock()
162+
response = await wrapper(request)
163+
164+
assert response.__class__.__name__ == "Response"
165+
assert response.body == b"sync response"
166+
167+
168+
@pytest.mark.asyncio
169+
async def test_cloudevent_func_wrapper_sync_function():
170+
called_with_event = None
171+
172+
def sync_cloud_event(event):
173+
nonlocal called_with_event
174+
called_with_event = event
175+
176+
wrapper = _cloudevent_func_wrapper(sync_cloud_event, is_async=False)
177+
178+
request = Mock()
179+
request.body = AsyncMock(
180+
return_value=b'{"specversion": "1.0", "type": "test.event", "source": "test-source", "id": "123", "data": {"test": "data"}}'
181+
)
182+
request.headers = {"content-type": "application/cloudevents+json"}
183+
184+
response = await wrapper(request)
185+
186+
assert response.body == b"OK"
187+
assert response.status_code == 200
188+
189+
assert called_with_event is not None
190+
assert called_with_event["type"] == "test.event"
191+
assert called_with_event["source"] == "test-source"

tests/test_decorator_functions.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,45 @@ def test_http_decorator(http_decorator_client):
7575
resp = http_decorator_client.post("/my_path", json={"mode": "path"})
7676
assert resp.status_code == 200
7777
assert resp.text == "/my_path"
78+
79+
80+
def test_aio_sync_cloud_event_decorator(cloud_event_1_0):
81+
"""Test aio decorator with sync cloud event function."""
82+
source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py"
83+
target = "function_cloud_event_sync"
84+
85+
app = create_asgi_app(target, source)
86+
client = StarletteTestClient(app)
87+
88+
headers, data = ce_conversion.to_structured(cloud_event_1_0)
89+
resp = client.post("/", headers=headers, data=data)
90+
assert resp.status_code == 200
91+
assert resp.text == "OK"
92+
93+
94+
def test_aio_sync_http_decorator():
95+
source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py"
96+
target = "function_http_sync"
97+
98+
app = create_asgi_app(target, source)
99+
client = StarletteTestClient(app)
100+
101+
resp = client.post("/my_path?mode=path")
102+
assert resp.status_code == 200
103+
assert resp.text == "/my_path"
104+
105+
resp = client.post("/other_path")
106+
assert resp.status_code == 200
107+
assert resp.text == "sync response"
108+
109+
110+
def test_aio_http_dict_response():
111+
source = TEST_FUNCTIONS_DIR / "decorators" / "async_decorator.py"
112+
target = "function_http_dict_response"
113+
114+
app = create_asgi_app(target, source)
115+
client = StarletteTestClient(app)
116+
117+
resp = client.post("/")
118+
assert resp.status_code == 200
119+
assert resp.json() == {"message": "hello", "count": 42, "success": True}

tests/test_functions/decorators/async_decorator.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,35 @@ async def function_http(request):
6464
return request.url.path
6565
else:
6666
raise HTTPException(400)
67+
68+
69+
@functions_framework.aio.cloud_event
70+
def function_cloud_event_sync(cloud_event):
71+
"""Test sync CloudEvent function with aio decorator."""
72+
valid_event = (
73+
cloud_event["id"] == "my-id"
74+
and cloud_event.data == {"name": "john"}
75+
and cloud_event["source"] == "from-galaxy-far-far-away"
76+
and cloud_event["type"] == "cloud_event.greet.you"
77+
and cloud_event["time"] == "2020-08-16T13:58:54.471765"
78+
)
79+
80+
if not valid_event:
81+
raise HTTPException(500)
82+
83+
84+
@functions_framework.aio.http
85+
def function_http_sync(request):
86+
"""Test sync HTTP function with aio decorator."""
87+
# Use query params since they're accessible synchronously
88+
mode = request.query_params.get("mode")
89+
if mode == "path":
90+
return request.url.path
91+
else:
92+
return "sync response"
93+
94+
95+
@functions_framework.aio.http
96+
def function_http_dict_response(request):
97+
"""Test sync HTTP function returning dict with aio decorator."""
98+
return {"message": "hello", "count": 42, "success": True}

0 commit comments

Comments
 (0)