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