refactor: extract httpx mock helpers in price history tests

- create_mock_httpx_client() for mocking AsyncClient with various configs
- create_bitfinex_ticker_response() for creating ticker response arrays

Reduces test boilerplate significantly.
This commit is contained in:
counterweight 2025-12-22 16:21:18 +01:00
parent de12300593
commit 54709888e1
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C

View file

@ -2,6 +2,7 @@
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -11,14 +12,53 @@ from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
from worker import process_bitcoin_price_job
def create_mock_httpx_client(
json_response: list[Any] | dict[str, Any] | None = None,
raise_for_status_error: Exception | None = None,
get_error: Exception | None = None,
) -> AsyncMock:
"""
Create a mock httpx.AsyncClient for testing.
Args:
json_response: The JSON data to return from response.json()
raise_for_status_error: Exception to raise from response.raise_for_status()
get_error: Exception to raise from client.get()
Returns:
A mock AsyncClient configured as an async context manager.
"""
mock_client = AsyncMock()
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
if get_error:
mock_client.get.side_effect = get_error
else:
mock_response = MagicMock()
if raise_for_status_error:
mock_response.raise_for_status.side_effect = raise_for_status_error
else:
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = json_response
mock_client.get.return_value = mock_response
return mock_client
def create_bitfinex_ticker_response(price: float) -> list[float]:
"""Create a Bitfinex ticker response array with the given price at index 6."""
return [0, 0, 0, 0, 0, 0, price, 0, 0, 0]
class TestFetchBtcEurPrice:
"""Tests for the Bitfinex price fetcher."""
@pytest.mark.asyncio
async def test_parses_response_correctly(self):
"""Verify price is extracted from correct index in response array."""
mock_response = MagicMock()
mock_response.json.return_value = [
# Full Bitfinex ticker format for documentation
json_response = [
100.0, # BID
10.0, # BID_SIZE
101.0, # ASK
@ -30,12 +70,7 @@ class TestFetchBtcEurPrice:
96000.0, # HIGH
94000.0, # LOW
]
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client = create_mock_httpx_client(json_response=json_response)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_client):
price, timestamp = await fetch_btc_eur_price()
@ -49,15 +84,10 @@ class TestFetchBtcEurPrice:
"""Verify HTTP errors are propagated."""
import httpx
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
error = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=MagicMock()
)
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client = create_mock_httpx_client(raise_for_status_error=error)
with (
patch("price_fetcher.httpx.AsyncClient", return_value=mock_client),
@ -70,10 +100,8 @@ class TestFetchBtcEurPrice:
"""Verify network errors (ConnectError, TimeoutException) are propagated."""
import httpx
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
error = httpx.ConnectError("Connection refused")
mock_client = create_mock_httpx_client(get_error=error)
with (
patch("price_fetcher.httpx.AsyncClient", return_value=mock_client),
@ -84,14 +112,7 @@ class TestFetchBtcEurPrice:
@pytest.mark.asyncio
async def test_raises_on_invalid_response_format(self):
"""Verify ValueError is raised for unexpected response format."""
mock_response = MagicMock()
mock_response.json.return_value = {"error": "unexpected"}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client = create_mock_httpx_client(json_response={"error": "unexpected"})
with (
patch("price_fetcher.httpx.AsyncClient", return_value=mock_client),
@ -102,14 +123,7 @@ class TestFetchBtcEurPrice:
@pytest.mark.asyncio
async def test_raises_on_short_array(self):
"""Verify ValueError is raised if array is too short."""
mock_response = MagicMock()
mock_response.json.return_value = [1, 2, 3] # Too short
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client = create_mock_httpx_client(json_response=[1, 2, 3]) # Too short
with (
patch("price_fetcher.httpx.AsyncClient", return_value=mock_client),
@ -210,14 +224,9 @@ class TestManualFetch:
@pytest.mark.asyncio
async def test_creates_record_on_success(self, client_factory, admin_user):
"""Verify manual fetch creates a price history record."""
mock_response = MagicMock()
mock_response.json.return_value = [0, 0, 0, 0, 0, 0, 95123.45, 0, 0, 0]
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client = create_mock_httpx_client(
json_response=create_bitfinex_ticker_response(95123.45)
)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_client):
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
@ -235,14 +244,9 @@ class TestManualFetch:
@pytest.mark.asyncio
async def test_record_persisted_to_database(self, client_factory, admin_user):
"""Verify the created record can be retrieved."""
mock_response = MagicMock()
mock_response.json.return_value = [0, 0, 0, 0, 0, 0, 87654.32, 0, 0, 0]
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client = create_mock_httpx_client(
json_response=create_bitfinex_ticker_response(87654.32)
)
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_client):
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
@ -309,14 +313,9 @@ class TestProcessBitcoinPriceJob:
@pytest.mark.asyncio
async def test_stores_price_on_success(self):
"""Verify price is stored in database on successful fetch."""
mock_response = MagicMock()
mock_response.json.return_value = [0, 0, 0, 0, 0, 0, 95000.0, 0, 0, 0]
mock_response.raise_for_status = MagicMock()
mock_http_client = AsyncMock()
mock_http_client.get.return_value = mock_response
mock_http_client.__aenter__.return_value = mock_http_client
mock_http_client.__aexit__.return_value = None
mock_http_client = create_mock_httpx_client(
json_response=create_bitfinex_ticker_response(95000.0)
)
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
@ -338,15 +337,10 @@ class TestProcessBitcoinPriceJob:
"""Verify no exception is raised and no DB insert on API error."""
import httpx
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
error = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=MagicMock()
)
mock_http_client = AsyncMock()
mock_http_client.get.return_value = mock_response
mock_http_client.__aenter__.return_value = mock_http_client
mock_http_client.__aexit__.return_value = None
mock_http_client = create_mock_httpx_client(raise_for_status_error=error)
mock_conn = AsyncMock()
mock_pool = create_mock_pool(mock_conn)
@ -361,14 +355,9 @@ class TestProcessBitcoinPriceJob:
@pytest.mark.asyncio
async def test_fails_silently_on_db_error(self):
"""Verify no exception is raised on database error."""
mock_response = MagicMock()
mock_response.json.return_value = [0, 0, 0, 0, 0, 0, 95000.0, 0, 0, 0]
mock_response.raise_for_status = MagicMock()
mock_http_client = AsyncMock()
mock_http_client.get.return_value = mock_response
mock_http_client.__aenter__.return_value = mock_http_client
mock_http_client.__aexit__.return_value = None
mock_http_client = create_mock_httpx_client(
json_response=create_bitfinex_ticker_response(95000.0)
)
mock_conn = AsyncMock()
mock_conn.execute.side_effect = Exception("Database connection error")