Skip to content

Commit

Permalink
refactor health endpoint tests
Browse files Browse the repository at this point in the history
* move http endpoint test as smoke test to the acceptance tests
* use asgiref testing to test only the asgi app in unittests
  • Loading branch information
ekneg54 committed Nov 12, 2024
1 parent 34b65ec commit 52a8a33
Show file tree
Hide file tree
Showing 3 changed files with 57 additions and 51 deletions.
6 changes: 5 additions & 1 deletion logprep/metrics/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def __init__(self, configuration: MetricsConfig):
self.server = None
self.healthcheck_functions = None
self._multiprocessing_prepared = False
self.app = None

def prepare_multiprocessing(self):
"""
Expand Down Expand Up @@ -99,10 +100,12 @@ def run(self, daemon=True):

def init_server(self, daemon=True) -> None:
"""Initializes the server"""
if not self.app:
self.app = make_patched_asgi_app(self.healthcheck_functions)
port = self.configuration.port
self.server = http.ThreadingHTTPServer(
self.configuration.uvicorn_config | {"port": port, "host": "0.0.0.0"},
make_patched_asgi_app(self.healthcheck_functions),
self.app,
daemon=daemon,
logger_name="Exporter",
)
Expand All @@ -116,6 +119,7 @@ def restart(self):
def update_healthchecks(self, healthcheck_functions: Iterable[Callable], daemon=True) -> None:
"""Updates the healthcheck functions"""
self.healthcheck_functions = healthcheck_functions
self.app = make_patched_asgi_app(self.healthcheck_functions)
if self.server and self.server.thread and self.server.thread.is_alive():
self.server.shut_down()
self.init_server(daemon=daemon)
Expand Down
7 changes: 6 additions & 1 deletion tests/acceptance/test_full_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def test_start_of_logprep_from_http_with_templated_url_and_config():
output = proc.stdout.readline().decode("utf8")


def test_logprep_exposes_prometheus_metrics(tmp_path):
def test_logprep_exposes_prometheus_metrics_and_healthchecks(tmp_path):
temp_dir = tempfile.gettempdir()
input_file_path = Path(os.path.join(temp_dir, "input.txt"))
input_file_path.touch()
Expand Down Expand Up @@ -246,4 +246,9 @@ def test_logprep_exposes_prometheus_metrics(tmp_path):
len(re.findall(both_calculators, metrics)) == 4
), "More or less than 4 rules were found for both calculator"

# check health endpoint
response = requests.get("http://127.0.0.1:8003/health", timeout=7)
response.raise_for_status()
assert "OK" == response.text

proc.kill()
95 changes: 46 additions & 49 deletions tests/unit/metrics/test_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from unittest import mock

import pytest
import requests
from asgiref.testing import ApplicationCommunicator
from prometheus_client import REGISTRY, CollectorRegistry

Expand Down Expand Up @@ -108,64 +107,20 @@ def test_is_running_returns_true_when_server_thread_is_alive(self):
assert exporter.is_running


@mock.patch(
"logprep.util.http.ThreadingHTTPServer", new=mock.create_autospec(http.ThreadingHTTPServer)
)
@mock.patch(
"logprep.metrics.exporter.PrometheusExporter.prepare_multiprocessing",
new=lambda *args, **kwargs: None,
)
class TestHealthEndpoint:
def setup_method(self):
REGISTRY.__init__()
self.metrics_config = MetricsConfig(enabled=True, port=8000)

def test_health_endpoint_returns_503_as_default_health_state(self):
exporter = PrometheusExporter(self.metrics_config)
exporter.run(daemon=False)
resp = requests.get("http://localhost:8000/health", timeout=0.5)
assert resp.status_code == 503
exporter.server.shut_down()

def test_health_endpoint_calls_health_check_functions(self):
exporter = PrometheusExporter(self.metrics_config)
function_mock = mock.Mock(return_value=True)
exporter.healthcheck_functions = [function_mock]
exporter.run(daemon=False)
resp = requests.get("http://localhost:8000/health", timeout=0.5)
assert resp.status_code == 200
assert function_mock.call_count == 1

exporter.server.shut_down()

def test_health_endpoint_calls_updated_functions(self):
exporter = PrometheusExporter(self.metrics_config)
function_mock = mock.Mock(return_value=True)
exporter.healthcheck_functions = [function_mock]
exporter.run(daemon=False)
requests.get("http://localhost:8000/health", timeout=0.5)
assert function_mock.call_count == 1, "initial function should be called"
new_function_mock = mock.Mock(return_value=True)
exporter.update_healthchecks([new_function_mock])
requests.get("http://localhost:8000/health", timeout=0.5)
assert new_function_mock.call_count == 1, "New function should be called"
assert function_mock.call_count == 1, "Old function should not be called"

exporter.server.shut_down()

def test_health_check_returns_body_and_status_code(self):
exporter = PrometheusExporter(self.metrics_config)
exporter.run(daemon=False)
exporter.update_healthchecks([lambda: True])
resp = requests.get("http://localhost:8000/health", timeout=0.5)
assert resp.status_code == 200
assert resp.content.decode() == "OK"
exporter.server.shut_down()


class TestAsgiApp:
"""These tests uses the `asgiref.testing.ApplicationCommunicator` to test the ASGI app itself
For more information see: https://dokk.org/documentation/django-channels/2.4.0/topics/testing/
"""

def setup_method(self):
self.metrics_config = MetricsConfig(enabled=True, port=8000)
self.registry = CollectorRegistry()
self.captured_status = None
self.captured_headers = None
Expand Down Expand Up @@ -210,3 +165,45 @@ async def test_asgi_app(self, functions, expected_status, expected_body):
assert event["status"] == expected_status
event = await self.communicator.receive_output(timeout=1)
assert expected_body in event["body"]

@pytest.mark.asyncio
async def test_health_endpoint_calls_health_check_functions(self):
exporter = PrometheusExporter(self.metrics_config)
function_mock = mock.Mock(return_value=True)
exporter.healthcheck_functions = [function_mock]
exporter.run(daemon=False)
self.scope["path"] = "/health"
self.seed_app(exporter.app)
await self.communicator.send_input({"type": "http.request"})
event = await self.communicator.receive_output(timeout=1)
assert event["status"] == 200
event = await self.communicator.receive_output(timeout=1)
assert b"OK" in event["body"]
function_mock.assert_called_once()

@pytest.mark.asyncio
async def test_update_health_checks_injects_new_functions(self):
exporter = PrometheusExporter(self.metrics_config)
function_mock = mock.Mock(return_value=True)
exporter.healthcheck_functions = [function_mock]
exporter.run(daemon=False)
exporter.server.thread = None
self.scope["path"] = "/health"
self.seed_app(exporter.app)
await self.communicator.send_input({"type": "http.request"})
event = await self.communicator.receive_output(timeout=1)
assert event["status"] == 200
event = await self.communicator.receive_output(timeout=1)
assert b"OK" in event["body"]
assert function_mock.call_count == 1, "initial function should be called"
new_function_mock = mock.Mock(return_value=True)
exporter.update_healthchecks([new_function_mock])
self.scope["path"] = "/health"
self.seed_app(exporter.app)
await self.communicator.send_input({"type": "http.request"})
event = await self.communicator.receive_output(timeout=1)
assert event["status"] == 200
event = await self.communicator.receive_output(timeout=1)
assert b"OK" in event["body"]
assert new_function_mock.call_count == 1, "New function should be called"
assert function_mock.call_count == 1, "Old function should not be called"

0 comments on commit 52a8a33

Please sign in to comment.