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:
parent
de12300593
commit
54709888e1
1 changed files with 63 additions and 74 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue