Skip to content

Commit bddffb2

Browse files
authored
Merge pull request #64 from chr1st1ank/adding-tests
Adding a test suite for flask
2 parents f89fc29 + ce55153 commit bddffb2

File tree

6 files changed

+250
-2
lines changed

6 files changed

+250
-2
lines changed

.github/workflows/code_quality.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
2+
name: Code Quality
3+
on:
4+
push:
5+
branches: [ master ]
6+
pull_request:
7+
branches: [ master ]
8+
9+
jobs:
10+
flake8:
11+
name: Flake8
12+
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- uses: actions/checkout@v2
17+
- name: Set up Python 3.9
18+
uses: actions/setup-python@v2
19+
with:
20+
python-version: 3.9
21+
- name: Install dependencies
22+
run: |
23+
python -m pip install --upgrade pip
24+
pip install -r test-requirements.txt
25+
- name: Flake8
26+
run: |
27+
# stop the build if there are Python syntax errors or undefined names
28+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
29+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
30+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
31+
32+
tests:
33+
name: Tests
34+
35+
runs-on: ubuntu-latest
36+
37+
strategy:
38+
max-parallel: 4
39+
matrix:
40+
python-version: ["3.7", "3.8", "3.9"]
41+
42+
steps:
43+
- name: Set up Python ${{ matrix.python-version }}
44+
uses: actions/setup-python@v2
45+
with:
46+
python-version: ${{ matrix.python-version }}
47+
- name: "Git checkout"
48+
uses: actions/checkout@v2
49+
- name: Install dependencies
50+
run: |
51+
python -m pip install --upgrade pip
52+
pip install -r test-requirements.txt
53+
- name: Run tests
54+
run: |
55+
pytest

test-requirements.txt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
wheel
2-
flask==1.0
2+
flask
33
connexion[swagger-ui]
44
quart
5-
sanic
5+
sanic
6+
flake8
7+
pytest
8+
-e .

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Global fixtures and settings for the pytest test suite"""
2+
import sys
3+
import os
4+
5+
# Add test helper modules to search path with out making "tests" a Python package
6+
sys.path.append(os.path.join(os.path.dirname(__file__), "helpers"))

tests/helpers/constants.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Constants shared by multiple tests"""
2+
3+
STANDARD_MSG_ATTRIBUTES = {
4+
"written_at",
5+
"written_ts",
6+
"msg",
7+
"type",
8+
"logger",
9+
"thread",
10+
"level",
11+
"module",
12+
"line_no",
13+
"correlation_id",
14+
}

tests/helpers/handler.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import logging
2+
from typing import List
3+
4+
5+
class FormattedMessageCollectorHandler(logging.Handler):
6+
"""A logging handler that stores formatted log records in its "messages" attribute."""
7+
8+
def __init__(self, level=logging.NOTSET) -> None:
9+
"""Create a new log handler."""
10+
super().__init__(level=level)
11+
self.level = level
12+
self.messages: List[str] = []
13+
14+
def emit(self, record: logging.LogRecord) -> None:
15+
"""Keep the log records in a list in addition to the log text."""
16+
self.messages.append(self.format(record))
17+
18+
def reset(self) -> None:
19+
"""Empty the list of messages"""
20+
self.messages = []

tests/test_flask.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Test suite for the flask backend"""
2+
import json
3+
import logging
4+
import pathlib
5+
import re
6+
import sys
7+
8+
import flask
9+
import pytest
10+
11+
from helpers import constants
12+
from helpers.handler import FormattedMessageCollectorHandler
13+
14+
LOGGER_NAME = "flask-test"
15+
16+
17+
@pytest.fixture
18+
def client_and_log_handler():
19+
import json_logging
20+
21+
# Init app
22+
app = flask.Flask(__name__)
23+
24+
# Init std logging
25+
logger = logging.getLogger(LOGGER_NAME)
26+
logger.setLevel(logging.DEBUG)
27+
handler = FormattedMessageCollectorHandler()
28+
logger.addHandler(handler)
29+
30+
# Add json_logging
31+
json_logging.init_flask(enable_json=True)
32+
json_logging.init_request_instrument(app)
33+
34+
# Prepare test endpoints
35+
@app.route("/log/levels/debug")
36+
def log_debug():
37+
logger.debug("debug message")
38+
return {}
39+
40+
@app.route("/log/levels/info")
41+
def log_info():
42+
logger.info("info message")
43+
return {}
44+
45+
@app.route("/log/levels/error")
46+
def log_error():
47+
logger.error("error message")
48+
return {}
49+
50+
@app.route("/log/extra_property")
51+
def extra_property():
52+
logger.info(
53+
"test log statement with extra props",
54+
extra={"props": {"extra_property": "extra_value"}},
55+
)
56+
return {}
57+
58+
@app.route("/log/exception")
59+
def log_exception():
60+
try:
61+
raise RuntimeError()
62+
except BaseException as e:
63+
logger.exception("Error occurred", exc_info=e)
64+
return {}
65+
66+
with app.test_client() as test_client:
67+
yield test_client, handler
68+
69+
# Tear down test environment
70+
logger.removeHandler(handler)
71+
del sys.modules["json_logging"] # "de-import" because json_logging maintains global state
72+
73+
74+
@pytest.mark.parametrize("level", ["debug", "info", "error"])
75+
def test_record_format_per_log_level(client_and_log_handler, level):
76+
"""Test if log messages are formatted correctly for all log levels"""
77+
api_client, handler = client_and_log_handler
78+
79+
response = api_client.get("/log/levels/" + level)
80+
81+
assert response.status_code == 200
82+
assert len(handler.messages) == 1
83+
msg = json.loads(handler.messages[0])
84+
assert set(msg.keys()) == constants.STANDARD_MSG_ATTRIBUTES
85+
assert msg["module"] == __name__
86+
assert msg["level"] == level.upper()
87+
assert msg["logger"] == LOGGER_NAME
88+
assert msg["type"] == "log"
89+
assert msg["msg"] == level + " message"
90+
assert re.match(
91+
r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+.*)?$", msg["written_at"]
92+
), "The field 'written_at' does not contain an iso timestamp"
93+
94+
95+
def test_correlation_id_given(client_and_log_handler):
96+
"""Test if a given correlation ID is added to the logs"""
97+
api_client, handler = client_and_log_handler
98+
99+
response = api_client.get("/log/levels/debug", headers={"X-Correlation-Id": "abc-def"})
100+
101+
assert response.status_code == 200
102+
assert len(handler.messages) == 1
103+
msg = json.loads(handler.messages[0])
104+
assert set(msg.keys()) == constants.STANDARD_MSG_ATTRIBUTES
105+
assert msg["correlation_id"] == "abc-def"
106+
107+
108+
def test_correlation_id_generated(client_and_log_handler):
109+
"""Test if a missing correlation ID is replaced by an autogenerated UUID"""
110+
api_client, handler = client_and_log_handler
111+
112+
response = api_client.get("/log/levels/debug")
113+
114+
assert response.status_code == 200
115+
assert len(handler.messages) == 1
116+
msg = json.loads(handler.messages[0])
117+
assert set(msg.keys()) == constants.STANDARD_MSG_ATTRIBUTES
118+
assert re.match(
119+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$",
120+
msg["correlation_id"],
121+
), "autogenerated UUID doesn't have expected format"
122+
123+
124+
def test_extra_property(client_and_log_handler):
125+
"""Test adding an extra property to a log message"""
126+
api_client, handler = client_and_log_handler
127+
128+
response = api_client.get("/log/extra_property")
129+
130+
assert response.status_code == 200
131+
assert len(handler.messages) == 1
132+
msg = json.loads(handler.messages[0])
133+
assert set(msg.keys()) == constants.STANDARD_MSG_ATTRIBUTES.union({"extra_property"})
134+
assert msg["extra_property"] == "extra_value"
135+
136+
137+
def test_exception_logged_with_stack_trace(client_and_log_handler):
138+
"""Test if the details of a stack trace are logged"""
139+
api_client, handler = client_and_log_handler
140+
141+
response = api_client.get("/log/exception")
142+
143+
assert response.status_code == 200
144+
assert len(handler.messages) == 1
145+
msg = json.loads(handler.messages[0])
146+
assert set(msg.keys()) == constants.STANDARD_MSG_ATTRIBUTES.union({"exc_info", "filename"})
147+
assert msg["filename"] == pathlib.Path(__file__).name, "File name for exception not logged"
148+
assert "Traceback (most recent call last):" in msg["exc_info"], "Not a stack trace"
149+
assert "RuntimeError" in msg["exc_info"], "Exception type not logged"
150+
assert len(msg["exc_info"].split("\n")) > 2, "Stacktrace doesn't have multiple lines"

0 commit comments

Comments
 (0)