Skip to content

Add example of using the Starlette test client with tox #2026

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions examples/testclient/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
===================
Test Client Example
===================

This example demonstrates test and validation features for a
simple Connexion app:

* Validate generated JSON responses against the specification
* Catch and report exceptions raised while processing a request
* Use tox and the Starlette test client to test the app automatically.

Preparing
---------

Create a new virtual environment and install the required libraries
with these commands::

$ python -m venv my-venv
$ source my-venv/bin/activate
$ pip install 'connexion[flask,swagger-ui,uvicorn]>=3.1.0' tox

Testing
-------

Run automated tests on the app with this command::

$ tox

Running
-------

Launch the Connexion server directly::

$ python app.py

or using uvicorn (or another async server)::

$ uvicorn --factory app:create_app --port 8080

Now open your browser and view the Swagger UI for these specification files:

* http://localhost:8080/openapi/ui/ for the OpenAPI 3 spec
* http://localhost:8080/swagger/ui/ for the Swagger 2 spec

Demonstrating
-------------

In the Swagger UI, click the "Try it out" button. Send a request with a name
and the default message body. The app responds with a 200 status code and a
greeting message "Hello <name>". Next, demonstrate the app's validation features
by sending a request with one of the following values in the request body's
``message`` parameter:

* Message ``crash``: the app raises an exception, which is caught and reported by
the custom exception handler. The response is an RFC 7807 "problem" response
with ``type``, ``title``, ``detail`` and ``status`` fields.
* Message ``invalid``: the app generates an invalid response, which is detected
and reported by Connexion. The response is an RFC 7807 "problem" response with
the failed-validation message.
64 changes: 64 additions & 0 deletions examples/testclient/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import logging
from pathlib import Path

from connexion import FlaskApp
from connexion.lifecycle import ConnexionRequest, ConnexionResponse
from connexion.problem import problem

# reuse the configured logger for simplicity
logger = logging.getLogger("uvicorn.error")


def handle_error(request: ConnexionRequest, ex: Exception) -> ConnexionResponse:
"""
Report an error that happened while processing a request.
See: https://connexion.readthedocs.io/en/latest/exceptions.html

:param request: Request that failed
:parm ex: Exception that was raised
:return: ConnexionResponse with RFC7087 problem details
"""
# log the request URL, exception and stack trace
logger.exception(
"Connexion caught exception on request to %s", request.url, exc_info=ex
)
return problem(title="Error", status=500, detail=repr(ex))


def create_app() -> FlaskApp:
"""
Create the connexion.FlaskApp, which wraps a Flask app.

:return Newly created connexion.FlaskApp
"""
app = FlaskApp(__name__, specification_dir="spec/")
# hook the functions to the OpenAPI spec
title = {"title": "Hello World Plus Example"}
app.add_api("openapi.yaml", arguments=title, validate_responses=True)
app.add_api("swagger.yaml", arguments=title, validate_responses=True)
# hook a function that is invoked on any exception
app.add_error_handler(Exception, handle_error)
# return the fully initialized connexion.FlaskApp
return app


def post_greeting(name: str, body: dict) -> tuple:
logger.info(
"%s: name len %d, body items %d", post_greeting.__name__, len(name), len(body)
)
# the body is optional
message = body.get("message", None)
if "crash" == message:
msg = f"Found message {message}, raise ValueError"
logger.info("%s", msg)
raise ValueError(msg)
if "invalid" == message:
logger.info("Found message %s, return invalid response", message)
return {"bogus": "response"}
return {"greeting": f"Hello {name}"}, 200


# define app so loader can find it
conn_app = create_app()
if __name__ == "__main__":
conn_app.run(f"{Path(__file__).stem}:conn_app", port=8080)
1 change: 1 addition & 0 deletions examples/testclient/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
connexion[flask,swagger-ui,uvicorn]
48 changes: 48 additions & 0 deletions examples/testclient/spec/openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
openapi: "3.0.0"

info:
title: Hello World
version: "1.0"

servers:
- url: /openapi

paths:
/greeting/{name}:
post:
summary: Generate greeting
description: Generates a greeting message.
operationId: app.post_greeting
parameters:
- name: name
in: path
description: Name of the person to greet.
required: true
schema:
type: string
example: "dave"
requestBody:
description: >
Optional body with a message.
Send message "crash" or "invalid" to simulate an error.
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: "hi"
responses:
'200':
description: greeting response
content:
application/json:
schema:
type: object
properties:
greeting:
type: string
example: "Hello John"
required:
- greeting
44 changes: 44 additions & 0 deletions examples/testclient/spec/swagger.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
swagger: "2.0"

info:
title: "{{title}}"
version: "1.0"

basePath: /swagger

paths:
/greeting/{name}:
post:
summary: Generate greeting
operationId: app.post_greeting
parameters:
- name: name
in: path
description: Name of the person to greet.
required: true
type: string
- name: body
in: body
description: >
Optional body with a message.
Send message "crash" or "invalid" to simulate an error.
schema:
type: object
properties:
message:
type: string
example: "hi"
produces:
- application/json
responses:
'200':
description: greeting response
schema:
type: object
properties:
greeting:
type: string
required:
- greeting
example:
greeting: "Hello John"
2 changes: 2 additions & 0 deletions examples/testclient/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# empty __init__.py so that pytest can add correct path to coverage report
# https://github.com/pytest-dev/pytest-cov/issues/98#issuecomment-451344057
17 changes: 17 additions & 0 deletions examples/testclient/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
fixtures available for injection to tests by pytest
"""
import pytest
from app import conn_app
from starlette.testclient import TestClient


@pytest.fixture
def client():
"""
Create a Connexion test_client from the Connexion app.

https://connexion.readthedocs.io/en/stable/testing.html
"""
client: TestClient = conn_app.test_client()
yield client
35 changes: 35 additions & 0 deletions examples/testclient/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from httpx import Response
from starlette.testclient import TestClient

detail = "detail"
greeting = "greeting"
message = "message"
prefixes = ["openapi", "swagger"]


def test_greeting_dave(client: TestClient):
name = "dave"
for prefix in prefixes:
res: Response = client.post(
f"/{prefix}/{greeting}/{name}", json={message: "hi"}
)
assert res.status_code == 200
assert name in res.json()[greeting]


def test_greeting_crash(client: TestClient):
crash = "crash"
for prefix in prefixes:
res: Response = client.post(f"/{prefix}/{greeting}/name", json={message: crash})
assert res.status_code == 500
assert crash in res.json()[detail]


def test_greeting_invalid(client: TestClient):
for prefix in prefixes:
# a body is required in the POST
res: Response = client.post(
f"/{prefix}/{greeting}/name", json={message: "invalid"}
)
assert res.status_code == 500
assert "Response body does not conform" in res.json()[detail]
17 changes: 17 additions & 0 deletions examples/testclient/tox.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[tox]
envlist = code
minversion = 2.0

[pytest]
testpaths = tests

[testenv:code]
basepython = python3
deps=
pytest
pytest-mock
-r requirements.txt
commands =
# posargs allows running just a single test like this:
# tox -- tests/test_foo.py::test_bar
pytest {posargs}