Mock function that is inherited while using pytest.mark.parametrize - python

I am new to unit testing. I created some classes to get from an API, deserialize it to JSON and input its values to a DB. All classes are working and I'm writing the unit tests right now. I have the following classes:
import requests
class GetResponse():
def getDeserialize(self, url: str):
ApiResponse = requests.get(f'{url}')
toJson = ApiResponse.json()
return toJson
class MercadoBitcoin(GetResponse):
def __init__(self) -> None:
#super().__init__()
self.beginningOfUrl = 'https://www.mercadobitcoin.net/api'
# This method is to get generally from the API
def standardGet(self, coin: str, method: str):
self.URL = f'{self.beginningOfUrl}/{coin}/{method}/'
urlGet = super().getDeserialize(self.URL)
return urlGet
# This method is to get specifically from the API Day Summary
def daySummary(self, year: int, month: int, day: int, coin: str):
method = 'day-summary'
self.URL = f'{self.beginningOfUrl}/{coin}/{method}/{year}/{month}/{day}'
urlGet = super().getDeserialize(self.URL)
return urlGet
I wrote this test:
class TestMercadoBitcoin():
#pytest.mark.parametrize(
"coin, method, expected",
[
("BTC", "ticker", "https://www.mercadobitcoin.net/api/BTC/ticker/"),
("ETH", "ticker", "https://www.mercadobitcoin.net/api/ETH/ticker/")
]
)
def test_standardGet(self, coin, method, expected):
actual = MercadoBitcoin()
actual.standardGet(coin=coin, method=method)
assert actual.URL == expected
I only want to test if the URL is the same as expected, not to run the request to the API. I tried these approaches for mockings and both didn't work:
#patch(requests.get)
#pytest.mark.parametrize(
"coin, method, expected",
[
("BTC", "ticker", "https://www.mercadobitcoin.net/api/BTC/ticker/"),
("ETH", "ticker", "https://www.mercadobitcoin.net/api/ETH/ticker/")
]
)
def test_standardGet(self, coin, method, expected, mock_requests):
actual = MercadoBitcoin()
actual.standardGet(coin=coin, method=method)
assert actual.URL == expected
#patch(super.getDeserialize)
#pytest.mark.parametrize(
"coin, method, expected",
[
("BTC", "ticker", "https://www.mercadobitcoin.net/api/BTC/ticker/"),
("ETH", "ticker", "https://www.mercadobitcoin.net/api/ETH/ticker/")
]
)
def test_standardGet(self, coin, method, expected, mock_requests):
actual = MercadoBitcoin()
actual.standardGet(coin=coin, method=method)
assert actual.URL == expected

Related

Creating a decorator to mock input() using monkeypatch in pytest

End goal: I want to be able to quickly mock the input() built-in function in pytest, and replace it with an iterator that generates a (variable) list of strings. This is my current version, which works:
from typing import Callable
import pytest
def _create_patched_input(str_list: list[str]) -> Callable:
str_iter = iter(str_list.copy())
def patched_input(prompt: str) -> str: # has the same signature as input
val = next(str_iter)
print(prompt + val, end="\n"),
return val
return patched_input
#pytest.fixture
def _mock_input(monkeypatch, input_string_list: list[str]):
patched_input = _create_patched_input(input_string_list)
monkeypatch.setattr("builtins.input", patched_input)
def mock_input(f):
return pytest.mark.usefixtures("_mock_input")(f)
# Beginning of test code
def get_name(prompt: str) -> str:
return input(prompt)
#mock_input
#pytest.mark.parametrize(
"input_string_list",
(["Alice", "Bob", "Carol"], ["Dale", "Evie", "Frank", "George"]),
)
def test_get_name(input_string_list):
for name in input_string_list:
assert get_name("What is your name?") == name
However, this feels incomplete for a few reasons:
It requires the parameter name in the parameterize call to be input_string_list, which feels brittle.
If I move the fixtures into another function, I need to import both mock_input and _mock_input.
What would feel correct to me is to have a decorator (factory) that can be used like #mock_input(strings), such that you could use it like
#mock_input(["Alice", "Bob", "Carol"])
def test_get_name():
....
or, more in line with my use case,
#pytest.mark.parametrize(
"input_list", # can be named whatever
(["Alice", "Bob", "Carol"], ["Dale", "Evie", "Frank", "George"]),
)
#mock_input(input_list)
def test_get_name():
....
The latter I don't think you can do, as pytest wont recognize it as a fixture. What's the best way to do this?
I'd use indirect parametrization for mock_input, since it cannot work without receiving parameters. Also, I would refactor mock_input into a fixture that does passing through the arguments it receives, performing the mocking on the way. For example, when using unittest.mock.patch():
import pytest
from unittest.mock import patch
#pytest.fixture
def inputs(request):
texts = requests.param # ["Alice", "Bob", "Carol"] etc
with patch('builtins.input', side_effect=texts):
yield texts
Or, if you want to use monkeypatch, the code gets a bit more complex:
#pytest.fixture
def inputs(monkeypatch, request):
texts = requests.param
it = iter(texts)
def fake_input(prefix):
return next(it)
monkeypatch.setattr('builtins.input', fake_input)
yield texts
Now use inputs as test argument and parametrize it indirectly:
#pytest.mark.parametrize(
'inputs',
(["Alice", "Bob", "Carol"], ["Dale", "Evie", "Frank", "George"]),
indirect=True
)
def test_get_name(inputs):
for name in inputs:
assert get_name("What is your name?") == name

Calling an async function from a class

My prof gave us this code to play with, but I have troubles calling an async function from a class
import asyncio
import aiohttp
import time
from pathlib import Path
from typing import List, Any, Dict, Union, Awaitable, Optional
import json
import toml
from mypy_extensions import TypedDict
# apikey = …
_coin_list: Dict[str, Any] = {}
async def fetch(url: str) -> Dict[str, Any]:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
text = await resp.text()
return json.loads(text)
async def fetch_price_data(from_currencies: List[str], to_currencies: List[str], full: bool=False) -> Dict[str, Any]:
if full:
endpoint = 'pricemultifull'
from_parameter = 'fsyms'
if 'USD' not in to_currencies:
to_currencies.append('USD')
else:
endpoint = 'pricemulti'
from_parameter = 'fsyms'
price_url = f'https://min-api.cryptocompare.com/data/{endpoint}?' \
f'{from_parameter}={",".join(from_currencies)}&' \
f'tsyms={",".join(to_currencies)}'
resp = await fetch(price_url)
if full:
resp = resp['RAW']
return resp
async def fetch_coin_list() -> Dict[str, Any]:
global _coin_list
async with asyncio.Lock():
if not _coin_list:
coin_list_url = 'https://min-api.cryptocompare.com/data/all/coinlist'
_coin_list = await fetch(coin_list_url)
return _coin_list['Data']
# warnings
class CryptoPriceException(Exception):
pass
class CurrencyNotFound(CryptoPriceException):
pass
class UnfetchedInformation(CryptoPriceException):
pass
CurrencyKwargs = TypedDict('CurrencyKwargs', {
'cache': int,
'target_currencies': List[str],
'full': bool,
'historical': Optional[str],
'human': bool
})
class Prices:
"""Prices object"""
def __init__(self, currency: 'Currency') -> None:
self._prices: Dict[str, float] = {}
self._currency = currency
async def get(self, target_currency: str, default: float=0.0) -> float:
"""
Gets the price for a specified currency, if currency is not in target currencies,
it's added there for the specific currency
:param target_currency: Currency to get converted price for
:param default: What to return if the desired currency is not found in fetched prices
"""
target_currency = target_currency.upper()
if target_currency not in self._currency.target_currencies:
self._currency.target_currencies.append(target_currency)
await self._currency.load()
# TODO float should be in the dict from when it's put there -> stop using .update() with data from api
return float(self._prices.get(target_currency, default))
def __getitem__(self, item: str) -> float:
try:
return float(self._prices[item.upper()])
except KeyError:
raise CurrencyNotFound("Desired target currency not found, make sure it's in desired_currencies "
"and that cryptocompare.com supports it.")
def __setitem__(self, key: str, value: float) -> None:
self._prices[key.upper()] = value
def __getattr__(self, item: str) -> float:
return self[item]
class Currency:
"""
Currency object
"""
def __init__(self, symbol: str, cache: int=60, target_currencies: List[str] = None,
full: bool = False, historical: Optional[str] = None, human: bool = False) -> None:
"""
:param symbol: Symbol of the currency (e.g. ZEC) - will be converted to uppercase automatically
:param cache: Seconds to keep prices in cache
:param target_currencies: Which currencies to convert prices to
:param full: Whether to fetch full data, like change, market cap, volume etc.
:param historical: Whether to fetch movement data, either None, or 'minute', 'hour', 'day'
:param human: Whether to fetch information that concern humans (logo, full name)
"""
self.symbol = symbol.upper()
self.cache = cache
self.target_currencies = target_currencies or ['USD', 'BTC']
self.last_loaded: Union[bool, float] = False
self.prices = Prices(self)
self.full = full
self.historical = historical
self.human = human
self.human_data: Dict[str, Any] = {}
self.full_data: Dict[str, Any] = {}
# #property
# def image_url(self) -> str:
# """Available only if human is True - url to a image of currency's logo"""
# if not self.human:
# raise UnfetchedInformation('human must be True to fetch image_url')
# return f'https://www.cryptocompare.com{self.human_data.get("ImageUrl")}'
# #property
# def name(self) -> str:
# """Available only if human is True - name of the currency (e.g. Bitcoin from BTC)"""
# if not self.human:
# raise UnfetchedInformation('human must be True to fetch name')
# return self.human_data.get('CoinName', '')
#property
def supply(self) -> float:
if not self.full:
raise UnfetchedInformation('full must be True to fetch supply')
return float(self.full_data['USD']['SUPPLY'])
#property
def market_cap(self) -> float:
# TODO should be in self.prices
if not self.full:
raise UnfetchedInformation('full must be True to fetch market_cap')
return float(self.full_data['USD']['MKTCAP'])
def volume(self):
raise NotImplementedError
async def load(self) -> None:
"""Loads the data if they are not cached"""
tasks: List[Awaitable[Any]] = []
if not self.last_loaded:
if self.human:
tasks.append(fetch_coin_list())
if not self.last_loaded or time.time() < self.last_loaded + self.cache:
tasks.append(self.__load())
await asyncio.gather(*tasks)
if self.human and not self.human_data:
extra_data = await fetch_coin_list()
self.human_data = extra_data.get(self.symbol, {})
self.last_loaded = time.time()
async def __load(self) -> None:
try:
json_data = await fetch_price_data([self.symbol], self.target_currencies, full=self.full)
except Exception as _:
fallback = self.__load_fallback()
for tsym, price in self.prices._prices.items():
self.prices._prices[tsym] = fallback[tsym]
else:
if self.full:
self.full_data = json_data.get(self.symbol, {})
for tsym, price in self.prices._prices.items():
if self.full_data.get(tsym):
self.prices._prices[tsym] = self.full_data.get(tsym, {}).get('PRICE')
else:
self.prices._prices.update(json_data.get(self.symbol, {}))
def __load_fallback(self):
fallback_toml = (Path(__file__).resolve().parent / 'fallbacks.tml')
with fallback_toml.open(mode='r') as f:
return toml.load(f)
class Currencies:
"""
Wrapper around currencies.
Paramaters will propagate to all currencies gotten through this wrapper.
If you want to share state across modules, you should import currencies with lowercase
and set their parameters manually.
"""
def __init__(self, cache: int=60, target_currencies: List[str]=None,
full: bool=False, historical: Optional[str]=None, human: bool=False) -> None:
"""
:param cache: Seconds to keep prices in cache
:param target_currencies: Which currencies to convert prices to
:param full: Whether to fetch full data, like change, market cap, volume etc.
:param historical: Whether to fetch movement data, either None, or 'minute', 'hour', 'day'
TODO https://min-api.cryptocompare.com/data/histominute?fsym=BTC&tsym=USD&limit=60&aggregate=3&e=CCCAGG
TODO aggregate -> po kolika minutach, cas je v timestampech
Bonusove argumenty v metode a vracet jen metodou?
:param human: Whether to fetch information that concern humans (logo, full name)
Will not work with classic currencies like USD or EUR
"""
self.currencies: Dict[str, Currency] = dict()
self.cache = cache
self.target_currencies = target_currencies or ['USD', 'BTC', 'ETH']
self.full = full
self.historical = historical
self.human = human
async def load_all(self) -> None:
"""Loads data for all currencies"""
symbols = []
for _, currency in self.currencies.items():
symbols.append(currency.symbol)
if self.human:
# This is done just once, as the contents don't change
await fetch_coin_list()
# TODO fetch only if at least one isn't cached
price_data = await fetch_price_data(symbols, self.target_currencies, full=self.full)
for symbol, currency in self.currencies.items():
if self.full:
currency.full_data = price_data.get(symbol, {})
currency.prices._prices.update(price_data.get(symbol, {}))
currency.last_loaded = time.time()
if self.human:
# Update the currency with already fetched extra information
await currency.load()
def add(self, *symbols: str) -> None:
"""Add to the list of symbols for which to load prices"""
for symbol in symbols:
if symbol not in self.currencies:
self.currencies[symbol] = Currency(symbol, **self.__currency_kwargs)
#property
def __currency_kwargs(self) -> CurrencyKwargs:
"""All kwargs that are propagated to individual currencies"""
return {
'cache': self.cache,
'target_currencies': self.target_currencies,
'full': self.full,
'historical': self.historical,
'human': self.human
}
def __getitem__(self, item: str) -> Currency:
"""Gets a currency, if not present, will create one"""
item = item.upper()
if item not in self.currencies:
self.currencies[item] = Currency(item, **self.__currency_kwargs)
return self.currencies[item]
def __getattr__(self, item: str) -> Currency:
"""Same as getitem, but accessible with dots"""
return self[item]
currencies = Currency()
For example, I'm trying to call fetch_coin_list function but it gives an error. And any method I'm trying to call gives an error.
I'm pretty sure I call it the wrong way but I have no idea how to fix it. Sorry I'm really stupid and it's my first time working with async functions, please help. I'll be incredibly thankful
A simple usage example is provided on the documentation page.
import asyncio
async def main():
print('Hello ...')
await asyncio.sleep(1)
print('... World!')
# Python 3.7+
asyncio.run(main())
If you want to run multiple asynchronous tasks, then you can refer to the method load of the class Currency in the code you provided, which uses the asyncio.gather method.

How to mock a python class dependency using pytest-mock?

I am struggling to understand how to stub a class / mock all the methods from a class dependency in Python & pytest. The listing below shows the class that I am testing. It has two internal dependencies: OWMProxy and PyOwmDeserializer.
Class Under Test
class OWM:
def __init__(self, api_key: str, units: WeatherUnits) -> None:
self._api_key = api_key
self._units = units
self._pyorm = OWMProxy.from_api_key(api_key)
self._deserializer = PyOwmDeserializer()
def at(self, city: str, iso_datetime: str) -> ForecastModel:
weather = self._pyorm.for_time(city, iso_datetime)
return self._deserializer.deserialize(weather, self._units)
def day(self, city: str, day: str) -> ForecastModel:
weather = self._pyorm.for_day(city, day)
return self._deserializer.deserialize(weather, self._units)
def now(self, city: str) -> ForecastModel:
weather = self._pyorm.now(city)
return self._deserializer.deserialize(weather, self._units)
My question is, is it possible to mock an entire class dependency when unit test with PyTest?
Currently, the unit test that I have uses mocker to mock each class method, including init method.
I could use a dependency injection approach, i.e. create an interface for the internal deserializer and proxy interface and add these interfaces to the constructor of the class under test.
Alternatively I could test using unittest.mock module, as suggested here. Is there an equivalent functionality in pytest-mock???
Unit Test So Far...
#pytest.mark.skip(reason="not implemented")
def test_owm_initialises_deserializer(
default_weather_units: WeatherUnits, mocker: MockFixture
) -> None:
api_key = "test_api_key"
proxy = OWMProxy(py_OWM(api_key))
patch_proxy = mocker.patch(
"wayhome_weather_api.openweathermap.client.OWMProxy.from_api_key",
return_value=proxy,
)
patch_val = mocker.patch(
"wayhome_weather_api.openweathermap.deserializers.PyOwmDeserializer",
"__init__",
return_value=None,
)
owm = OWM(api_key, default_weather_units)
assert owm is not None
You can just mock the whole class and control the return values and/or side effects of its methods as how its done in the docs.
>>> def some_function():
... instance = module.Foo()
... return instance.method()
...
>>> with patch('module.Foo') as mock:
... instance = mock.return_value
... instance.method.return_value = 'the result'
... result = some_function()
... assert result == 'the result'
Assuming the class under test is located in src.py.
test_owm.py
import pytest
from pytest_mock.plugin import MockerFixture
from src import OWM, WeatherUnits
#pytest.fixture
def default_weather_units():
return 40
def test_owm_mock(
default_weather_units: WeatherUnits, mocker: MockerFixture
) -> None:
api_key = "test_api_key"
# Note that you have to mock the version of the class that is defined/imported in the target source code to run. So here, if the OWM class is located in src.py, then mock its definition/import of src.OWMProxy and src.PyOwmDeserializer
patch_proxy = mocker.patch("src.OWMProxy.from_api_key")
patch_val = mocker.patch("src.PyOwmDeserializer")
owm = OWM(api_key, default_weather_units)
assert owm is not None
# Default patch
print("Default patch:", owm.day("Manila", "Today"))
# Customizing the return value
patch_proxy.return_value.for_day.return_value = "Sunny"
patch_val.return_value.deserialize.return_value = "Really Sunny"
print("Custom return value:", owm.day("Manila", "Today"))
patch_proxy.return_value.for_day.assert_called_with("Manila", "Today")
patch_val.return_value.deserialize.assert_called_with("Sunny", default_weather_units)
# Customizing the side effect
patch_proxy.return_value.for_day.side_effect = lambda city, day: f"{day} in hot {city}"
patch_val.return_value.deserialize.side_effect = lambda weather, units: f"{weather} is {units} deg celsius"
print("Custom side effect:", owm.day("Manila", "Today"))
patch_proxy.return_value.for_day.assert_called_with("Manila", "Today")
patch_val.return_value.deserialize.assert_called_with("Today in hot Manila", default_weather_units)
def test_owm_stub(
default_weather_units: WeatherUnits, mocker: MockerFixture
) -> None:
api_key = "test_api_key"
class OWMProxyStub:
#staticmethod
def from_api_key(api_key):
return OWMProxyStub()
def for_day(self, city, day):
return f"{day} in hot {city}"
class PyOwmDeserializerStub:
def deserialize(self, weather, units):
return f"{weather} is {units} deg celsius"
patch_proxy = mocker.patch("src.OWMProxy", OWMProxyStub)
patch_val = mocker.patch("src.PyOwmDeserializer", PyOwmDeserializerStub)
owm = OWM(api_key, default_weather_units)
assert owm is not None
# Default patch
print("Default patch:", owm.day("Manila", "Today"))
# If you want to assert the calls made as did in the first test above, you can use the mocker.spy() functionality
Output
$ pytest -q -rP
================================================================================================= PASSES ==================================================================================================
______________________________________________________________________________________________ test_owm_mock ______________________________________________________________________________________________
------------------------------------------------------------------------------------------ Captured stdout call -------------------------------------------------------------------------------------------
Default patch: <MagicMock name='PyOwmDeserializer().deserialize()' id='139838844832256'>
Custom return value: Really Sunny
Custom side effect: Today in hot Manila is 40 deg celsius
______________________________________________________________________________________________ test_owm_stub ______________________________________________________________________________________________
------------------------------------------------------------------------------------------ Captured stdout call -------------------------------------------------------------------------------------------
Default patch: Today in hot Manila is 40 deg celsius
2 passed in 0.06s
As you can see, we are able to control the return values of the methods of the mocked dependencies.

dependency_overrides does not override dependency

The following FastApi test should use my get_mock_db function instead of the get_db function, but it dosen't. Currently the test fails because it uses the real Database.
def get_mock_db():
example_todo = Todo(title="test title", done=True, id=1)
class MockDb:
def query(self, _model):
mock = Mock()
mock.get = lambda _param: example_todo
def all(self):
return [example_todo]
def add(self):
pass
def commit(self):
pass
def refresh(self, todo: CreateTodo):
return Todo(title=todo.title, done=todo.done, id=1)
return MockDb()
client = TestClient(app)
app.dependency_overrides[get_db] = get_mock_db
def test_get_all():
response = client.get("/api/v1/todo")
assert response.status_code == 200
assert response.json() == [
{
"title": "test title",
"done": True,
"id": 1,
}
]
Key is to understand that dependency_overrides is just a dictionary. In order to override something, you need to specify a key that matches the original dependency.
def get_db():
return {'db': RealDb()}
def home(commons: dict= Depends(get_db))
commons['db'].doStuff()
app.dependency_overrides[get_db] = lambda: {'db': MockDb()}
Here you have inside the Depends function call a reference to get_db function. Then you are referring to the exact same function with dependency_overrides[get_db]. Therefore it gets overridden. Start by verifying that 'xxx' in these two match exactly: Depends(xxx) and dependency_overrides[xxx].
It took some time to wrap my head around the fact that whatever is inside the Depends call is actually the identifier for the dependency. So in this example the identifier is function get_db and the same function is used as key in the dictionary.
So this means the following example does not work since you are overriding something else than what's specified for Depends.
def get_db(connection_string):
return {'db': RealDb(connection_string)}
def home(commons: dict= Depends(get_db(os.environ['connectionString']))
commons['db'].doStuff()
# Does not work
app.dependency_overrides[get_db] = lambda: {'db': MockDb()}

Decorator: Maintain state

I need to compose information regarding the given information like what parameter the given function takes etc. The example what I would like to do is
#author("Joey")
#parameter("name", type=str)
#parameter("id", type=int)
#returns("Employee", desc="Returns employee with given details", type="Employee")
def get_employee(name, id):
//
// Some logic to return employee
//
Skeleton of decorator could be as follows:
json = {}
def author(author):
def wrapper(func):
def internal(*args, **kwargs):
json["author"] = name
func(args, kwargs)
return internal
return wrapepr
Similarly, parameter decorator could be written as follows:
def parameter(name, type=None):
def wrapper(func):
def internal(*args, **kwargs):
para = {}
para["name"] = name
para["type"] = type
json["parameters"].append = para
func(args, kwargs)
return internal
return wrapepr
Similarly, other handlers could be written. At the end, I can just call one function which would get all formed JSONs for each function.
End output could be
[
{fun_name, "get_employee", author: "Joey", parameters : [{para_name : Name, type: str}, ... ], returns: {type: Employee, desc: "..."}
{fun_name, "search_employee", author: "Bob", parameters : [{para_name : age, type: int}, ... ], returns: {type: Employee, desc: "..."}
...
}
]
I'm not sure how I can maintain the state and know to consolidate the data regarding one function should be handled together.
How can I achieve this?
I don't know if I fully get your use case, but wouldn't it work to add author to your current functions as:
func_list = []
def func(var):
return var
json = {}
json['author'] = 'JohanL'
json['func'] = func.func_name
func.json = json
func_list.append(func.json)
def func2(var):
return var
json = {}
json['author'] = 'Ganesh'
func2.json = json
func_list.append(func2.json)
This can be automated using a decorator as follows:
def author(author):
json = {}
def author_decorator(func):
json['func'] = func.func_name
json['author'] = author
func.json = json
return func
return author_decorator
def append(func_list):
def append_decorator(func):
func_list.append(func.json)
return func
return append_decorator
func_list = []
#append(func_list)
#author('JohanL')
def func(var):
return var
#append(func_list)
#author('Ganesh')
def func2(var):
return var
Then you can access the json dict as func.json and func2.json or find the functions in the func_list. Note that for the decorators to work, you have to add them in the order I have put them and I have not added any error handling.
Also, if you prefer the func_list to not be explicitly passed, but instead use a globaly defined list with an explicit name, the code can be somewhat simplified to:
func_list = []
def author(author):
json = {}
def author_decorator(func):
json['func'] = func.func_name
json['author'] = author
func.json = json
return func
return author_decorator
def append(func):
global func_list
func_list.append(func.json)
return func
#append
#author('JohanL')
def func(var):
return var
#append
#author('Ganesh')
def func2(var):
return var
Maybe this is sufficient for you?

Categories