Skip to content

Commit 3070f4f

Browse files
committed
Add tests for metrics: refactor sampling rate tests and add Prometheus exporter mocks
1 parent 8211f2d commit 3070f4f

File tree

2 files changed

+195
-17
lines changed

2 files changed

+195
-17
lines changed

tests/test_exporters.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,86 @@ def test_func(x):
357357
test_func.clear_cache()
358358

359359

360+
@pytest.mark.memory
361+
def test_prometheus_prom_client_available_paths():
362+
"""Cover prometheus_client-available code paths via module-level patching.
363+
364+
Exercises: __init__ branch (L157-160), _setup_collector (L168-169),
365+
_init_prometheus_metrics (L179), CachierCollector.describe (L57), and
366+
CachierCollector.collect() None-metrics skip (L66 False branch).
367+
368+
"""
369+
from unittest.mock import MagicMock, patch
370+
371+
from cachier.exporters.prometheus import CachierCollector
372+
373+
mock_registry = MagicMock()
374+
375+
with (
376+
patch("cachier.exporters.prometheus.PROMETHEUS_CLIENT_AVAILABLE", True),
377+
patch("cachier.exporters.prometheus.CollectorRegistry", lambda: mock_registry),
378+
patch("cachier.exporters.prometheus.prometheus_client", MagicMock()),
379+
):
380+
exporter = PrometheusExporter(port=0, use_prometheus_client=True)
381+
assert exporter._prom_client is not None
382+
assert exporter._registry is mock_registry
383+
384+
# L57: CachierCollector.describe() -> []
385+
collector = CachierCollector(exporter)
386+
assert collector.describe() == []
387+
388+
# L66 False branch: register a function whose metrics is None
389+
class _NoMetrics:
390+
__module__ = "test"
391+
__name__ = "no_metrics"
392+
metrics = None
393+
394+
def __call__(self, *a, **kw):
395+
pass
396+
397+
exporter._registered_functions["test.no_metrics"] = _NoMetrics()
398+
399+
with (
400+
patch("cachier.exporters.prometheus.CounterMetricFamily", lambda *a, **kw: MagicMock()),
401+
patch("cachier.exporters.prometheus.GaugeMetricFamily", lambda *a, **kw: MagicMock()),
402+
):
403+
results = list(collector.collect())
404+
# Yields 8 families even though snapshots is empty (no non-None metrics)
405+
assert len(results) == 8
406+
407+
408+
def test_prometheus_module_import_with_prom_client():
409+
"""Cover the try-block import lines (L37-40) via module reload with a mocked prometheus_client."""
410+
import importlib
411+
import sys
412+
from unittest.mock import MagicMock
413+
414+
import cachier.exporters.prometheus as prom_mod
415+
416+
mock_prom = MagicMock()
417+
mock_prom_core = MagicMock()
418+
419+
saved_prom = sys.modules.get("prometheus_client")
420+
saved_core = sys.modules.get("prometheus_client.core")
421+
422+
sys.modules["prometheus_client"] = mock_prom
423+
sys.modules["prometheus_client.core"] = mock_prom_core
424+
try:
425+
importlib.reload(prom_mod)
426+
assert prom_mod.PROMETHEUS_CLIENT_AVAILABLE is True
427+
assert prom_mod.CollectorRegistry is mock_prom.CollectorRegistry
428+
finally:
429+
if saved_prom is None:
430+
sys.modules.pop("prometheus_client", None)
431+
else:
432+
sys.modules["prometheus_client"] = saved_prom
433+
if saved_core is None:
434+
sys.modules.pop("prometheus_client.core", None)
435+
else:
436+
sys.modules["prometheus_client.core"] = saved_core
437+
importlib.reload(prom_mod) # restore original state
438+
439+
360440
@pytest.mark.memory
361441
def test_prometheus_stop_when_not_started():
362442
"""Test that stop() is a no-op when the server was never started."""
@@ -464,6 +544,82 @@ def test_func(x):
464544
test_func.clear_cache()
465545

466546

547+
@pytest.mark.memory
548+
def test_prometheus_collector_collect_mocked():
549+
"""Test CachierCollector.collect() loop using mocked metric family types.
550+
551+
Covers lines 81-99 without requiring prometheus_client to be installed.
552+
553+
"""
554+
from unittest.mock import MagicMock, patch
555+
556+
from cachier.exporters.prometheus import CachierCollector
557+
558+
@cachier(backend="memory", enable_metrics=True)
559+
def test_func(x):
560+
return x * 2
561+
562+
test_func.clear_cache()
563+
test_func(5)
564+
test_func(5)
565+
566+
exporter = PrometheusExporter(port=0, use_prometheus_client=False)
567+
exporter.register_function(test_func)
568+
569+
with (
570+
patch("cachier.exporters.prometheus.CounterMetricFamily", lambda *a, **kw: MagicMock()),
571+
patch("cachier.exporters.prometheus.GaugeMetricFamily", lambda *a, **kw: MagicMock()),
572+
):
573+
collector = CachierCollector(exporter)
574+
results = list(collector.collect())
575+
# 5 counter families + 3 gauge families
576+
assert len(results) == 8
577+
578+
test_func.clear_cache()
579+
580+
581+
@pytest.mark.memory
582+
def test_prometheus_start_prometheus_server_mocked():
583+
"""Test _start_prometheus_server and its MetricsHandler without prometheus_client.
584+
585+
Covers lines 285-329 (start() prom branch, MetricsHandler.do_GET, log_message).
586+
587+
"""
588+
import sys
589+
import urllib.request
590+
from http.client import HTTPConnection
591+
from unittest.mock import MagicMock, patch
592+
593+
mock_exposition = MagicMock()
594+
mock_exposition.generate_latest.return_value = b"# mocked metrics"
595+
mock_exposition.CONTENT_TYPE_LATEST = "text/plain"
596+
597+
prom_mock = MagicMock()
598+
prom_mock.exposition = mock_exposition
599+
600+
exporter = PrometheusExporter(port=0, use_prometheus_client=False)
601+
# Manually inject prometheus state to trigger _start_prometheus_server path
602+
exporter._prom_client = prom_mock
603+
exporter._registry = MagicMock()
604+
605+
with patch.dict(sys.modules, {"prometheus_client": prom_mock, "prometheus_client.exposition": mock_exposition}):
606+
exporter.start()
607+
actual_port = exporter._server.server_address[1]
608+
assert exporter._server is not None
609+
try:
610+
response = urllib.request.urlopen(f"http://127.0.0.1:{actual_port}/metrics", timeout=5)
611+
assert b"# mocked metrics" in response.read()
612+
613+
conn = HTTPConnection("127.0.0.1", actual_port)
614+
conn.request("GET", "/notfound")
615+
resp = conn.getresponse()
616+
assert resp.status == 404
617+
conn.close()
618+
finally:
619+
exporter.stop()
620+
assert exporter._server is None
621+
622+
467623
@pytest.mark.memory
468624
def test_prometheus_collector_collect_skips_none_metrics():
469625
"""Test CachierCollector.collect() skips functions where metrics is None."""

tests/test_metrics.py

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -450,14 +450,20 @@ async def async_func(x):
450450

451451

452452
def test_metrics_zero_sampling_rate():
453-
"""Test that sampling_rate=0.0 records nothing."""
453+
"""Test that sampling_rate=0.0 records nothing for all record_* methods."""
454454
metrics = CacheMetrics(sampling_rate=0.0)
455-
for _ in range(100):
456-
metrics.record_hit()
457-
metrics.record_miss()
455+
metrics.record_hit()
456+
metrics.record_miss()
457+
metrics.record_stale_hit()
458+
metrics.record_wait_timeout()
459+
metrics.record_size_limit_rejection()
460+
metrics.record_latency(0.1)
458461
stats = metrics.get_stats()
459-
# With 0.0 rate nothing should be sampled
460462
assert stats.total_calls == 0
463+
assert stats.stale_hits == 0
464+
assert stats.wait_timeouts == 0
465+
assert stats.size_limit_rejections == 0
466+
assert stats.avg_latency_ms == 0.0
461467

462468

463469
def test_metrics_get_stats_zero_window():
@@ -490,18 +496,15 @@ def test_metrics_wait_timeout_direct():
490496
assert stats.wait_timeouts == 1
491497

492498

493-
def test_metrics_sampling_rate_zero_skips_all_methods():
494-
"""Test that sampling_rate=0.0 causes all record_* methods to skip recording."""
495-
metrics = CacheMetrics(sampling_rate=0.0)
496-
metrics.record_stale_hit()
497-
metrics.record_wait_timeout()
498-
metrics.record_size_limit_rejection()
499-
metrics.record_latency(0.1)
500-
stats = metrics.get_stats()
501-
assert stats.stale_hits == 0
502-
assert stats.wait_timeouts == 0
503-
assert stats.size_limit_rejections == 0
504-
assert stats.avg_latency_ms == 0.0
499+
def test_should_sample_deterministic():
500+
"""Test _should_sample returns True/False deterministically via mocking."""
501+
from unittest.mock import patch
502+
503+
metrics = CacheMetrics(sampling_rate=0.5)
504+
with patch.object(metrics._random, "random", return_value=0.1):
505+
assert metrics._should_sample() is True
506+
with patch.object(metrics._random, "random", return_value=0.9):
507+
assert metrics._should_sample() is False
505508

506509

507510
def test_metrics_context_manager():
@@ -519,6 +522,25 @@ def test_metrics_context_manager_none():
519522
pass # should not raise
520523

521524

525+
def test_metrics_context_record_wait_timeout():
526+
"""Test MetricsContext.record_wait_timeout records when metrics is set."""
527+
metrics = CacheMetrics()
528+
ctx = MetricsContext(metrics)
529+
ctx.record_wait_timeout()
530+
assert metrics.get_stats().wait_timeouts == 1
531+
532+
533+
def test_metrics_context_record_size_limit_rejection():
534+
"""Test MetricsContext.record_size_limit_rejection for both truthy and None metrics."""
535+
metrics = CacheMetrics()
536+
ctx = MetricsContext(metrics)
537+
ctx.record_size_limit_rejection()
538+
assert metrics.get_stats().size_limit_rejections == 1
539+
540+
ctx_none = MetricsContext(None)
541+
ctx_none.record_size_limit_rejection() # should be a no-op
542+
543+
522544
@pytest.mark.memory
523545
def test_metrics_entry_count_and_size_memory():
524546
"""Test that entry_count and total_size_bytes reflect cache state for memory backend.

0 commit comments

Comments
 (0)