31
loading...
This website collects cookies to deliver better user experience
Ok, that's cool, but how can we use it?
'https://api.openweathermap.org/data/2.5/weather?q={city_name}&appid={api_key}&units=metric'
. For this tutorial, we’ll parametrize the city name and settle with the metric unit.requests
. We can create a function that receives a city name as parameter and returns a json. The json will contain the temperature, weather description, sunset, sunrise time and so on. def find_weather_for(city: str) -> dict:
"""Queries the weather API and returns the weather data for a particular city."""
url = API.format(city_name=city, api_key=API_KEY)
resp = requests.get(url)
return resp.json()
BASE_URL = "https://api.openweathermap.org/data/2.5/weather"
API = BASE_URL + "?q={city_name}&appid={api_key}&units=metric"
{
"coord": {
"lon": -0.13,
"lat": 51.51
},
"weather": [
{
"id": 800,
"main": "Clear",
"description": "clear sky",
"icon": "01d"
}
],
"base": "stations",
"main": {
"temp": 16.53,
"feels_like": 15.52,
"temp_min": 15,
"temp_max": 17.78,
"pressure": 1023,
"humidity": 72
},
"visibility": 10000,
"wind": {
"speed": 2.1,
"deg": 40
},
"clouds": {
"all": 0
},
"dt": 1600420164,
"sys": {
"type": 1,
"id": 1414,
"country": "GB",
"sunrise": 1600407646,
"sunset": 1600452509
},
"timezone": 3600,
"id": 2643743,
"name": "London",
"cod": 200
}
resp.json()
. In order to encapsulate all the details, we can represent them as a dataclass
. This class has a factory method that gets the dictionary and returns a WeatherInfo
instance.from_dict
method. So, other parts of the code won’t be affected. We can even get information from different sources and combining them in the from_dict
method!@dataclass
class WeatherInfo:
temp: float
sunset: str
sunrise: str
temp_min: float
temp_max: float
desc: str
@classmethod
def from_dict(cls, data: dict) -> "WeatherInfo":
return cls(
temp=data["main"]["temp"],
temp_min=data["main"]["temp_min"],
temp_max=data["main"]["temp_max"],
desc=data["weather"][0]["main"],
sunset=format_date(data["sys"]["sunset"]),
sunrise=format_date(data["sys"]["sunrise"]),
)
retrieve_weather
. We'll use this function to call the API and return a WeatherInfo
so we can build our HTML page.def retrieve_weather(city: str) -> WeatherInfo:
"""Finds the weather for a city and returns a WeatherInfo instance."""
data = find_weather_for(city)
return WeatherInfo.from_dict(data)
pytest
as our testing framework of choice. pytest
is a very extensible library that allows the extension through plugins. And to accomplish our mocking goals, let’s use pytest-mock
. This plugin abstracts a bunch of setups from unittest.mock
and makes our testing code very concise. If you are curious, I discuss more about it in another blog post.Ok, enough talking, show me the code.
retrieve_weather
function. This test uses two fixtures, one is the mocker
fixture provided by the pytest-mock
plugin. The other one is ours. It's just the static data we saved from a previous request.@pytest.fixture()
def fake_weather_info():
"""Fixture that returns a static weather data."""
with open("tests/resources/weather.json") as f:
return json.load(f)
def test_retrieve_weather_using_mocks(mocker, fake_weather_info):
"""Given a city name, test that a HTML report about the weather is generated
correctly."""
# Creates a fake requests response object
fake_resp = mocker.Mock()
# Mock the json method to return the static weather data
fake_resp.json = mocker.Mock(return_value=fake_weather_info)
# Mock the status code
fake_resp.status_code = HTTPStatus.OK
mocker.patch("weather_app.requests.get", return_value=fake_resp)
weather_info = retrieve_weather(city="London")
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
============================= test session starts ==============================
...[omitted]...
tests/test_weather_app.py::test_retrieve_weather_using_mocks PASSED [100%]
============================== 1 passed in 0.20s ===============================
Process finished with exit code 0
requests
anymore and decide to go with the standard’s lib urllib
. Every time we change the implementation of find_weather_for
we will have to adapt the test. A good test is a test that doesn’t change when our implementation change. So, by mocking, we end up coupling our test with the implementation....
# Creates a fake requests response object
fake_resp = mocker.Mock()
# Mock the json method to return the static weather data
fake_resp.json = mocker.Mock(return_value=fake_weather_info)
# Mock the status code
fake_resp.status_code = HTTPStatus.OK
...
Can we do better?
requests
using the mocker
feature has the downside of having a long setup. A good way to avoid that is to use a library that intercepts requests
calls and patch it. There are more than one lib for that, but simplest to me is responses
. Let’s see how can we use it to replace mock
.@responses.activate
def test_retrieve_weather_using_responses(fake_weather_info):
"""Given a city name, test that a HTML report about the weather is generated
correctly."""
api_uri = API.format(city_name="London", api_key=API_KEY)
responses.add(responses.GET, api_uri, json=fake_weather_info, status=HTTPStatus.OK)
weather_info = retrieve_weather(city="London")
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
fake_weather_info
fixture.============================= test session starts ==============================
...
tests/test_weather_app.py::test_retrieve_weather_using_responses PASSED [100%]
============================== 1 passed in 0.19s ===============================
responses
is that we don't need to patch requests
ourselves. We save some setup by delegating the abstraction to the library. However, in case you haven't noticed, we have problems.unittest.mock
, our test is coupled to the implementation. If we replace requests
, our test break.If by using mocks we couple our tests, what can we do?
requests
and we’ll have to replace it by urllib
, since it comes with Python. Not only that, we learned the lessons of not coupling test code with implementation and we want to avoid that in the future. We want to replace urllib
and not have to rewrite the tests.Really? How?
requests
in our find_weather_for
and expose it via a function that takes only the URL.def find_weather_for(city: str) -> dict:
"""Queries the weather API and returns the weather data for a particular city."""
url = API.format(city_name=city, api_key=API_KEY)
resp = requests.get(url)
return resp.json()
def find_weather_for(city: str) -> dict:
"""Queries the weather API and returns the weather data for a particular city."""
url = API.format(city_name=city, api_key=API_KEY)
return adapter(url)
def requests_adapter(url: str) -> dict:
resp = requests.get(url)
return resp.json()
retrieve_weather
function:def retrieve_weather(city: str) -> WeatherInfo:
"""Finds the weather for a city and returns a WeatherInfo instance."""
data = find_weather_for(city, adapter=requests_adapter)
return WeatherInfo.from_dict(data)
urllib
, we can just swap the adapters.def urllib_adapter(url: str) -> dict:
"""An adapter that encapsulates urllib.urlopen"""
with urllib.request.urlopen(url) as response:
resp = response.read()
return json.loads(resp)
def retrieve_weather(city: str) -> WeatherInfo:
"""Finds the weather for a city and returns a WeatherInfo instance."""
data = find_weather_for(city, adapter=urllib_adapter)
return WeatherInfo.from_dict(data)
Ok, how about the tests?
retrieve_weather
we can just create a fake adapter that is used during test time.@responses.activate
def test_retrieve_weather_using_adapter(
fake_weather_info,
):
def fake_adapter(url: str):
return fake_weather_info
weather_info = retrieve_weather(city="London", adapter=fake_adapter)
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
============================= test session starts ==============================
tests/test_weather_app.py::test_retrieve_weather_using_adapter PASSED [100%]
============================== 1 passed in 0.22s ===============================
requests
, like this:def requests_adapter(url: str) -> dict:
resp = requests.get(url, headers=<some broken headers>)
return resp.json()
VCR.py
is a library that simplifies a lot the tests that make HTTP requests.yaml
file called cassette. Both the request and the response are serialized. When we run the test for the second time, VCR.py
will intercept the call and return response for the request made. retrieve_weather
using VCR.py
.@vcr.use_cassette()
def test_retrieve_weather_using_vcr(fake_weather_info):
weather_info = retrieve_weather(city="London")
assert weather_info == WeatherInfo.from_dict(fake_weather_info)
Wow, is that it? No setup? What is that @vcr.use_cassette()
?
pytest
annotation to tell VCR to intercept the call and save the cassette file.How does the cassette file look like?
interactions:
- request:
body: null
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
User-Agent:
- python-requests/2.24.0
method: GET
uri: https://api.openweathermap.org/data/2.5/weather?q=London&appid=<YOUR API KEY HERE>&units=metric
response:
body:
string: '{"coord":{"lon":-0.13,"lat":51.51},"weather":[{"id":800,"main":"Clear","description":"clearsky","icon":"01d"}],"base":"stations","main":{"temp":16.53,"feels_like":15.52,"temp_min":15,"temp_max":17.78,"pressure":1023,"humidity":72},"visibility":10000,"wind":{"speed":2.1,"deg":40},"clouds":{"all":0},"dt":1600420164,"sys":{"type":1,"id":1414,"country":"GB","sunrise":1600407646,"sunset":1600452509},"timezone":3600,"id":2643743,"name":"London","cod":200}'
headers:
Access-Control-Allow-Credentials:
- 'true'
Access-Control-Allow-Methods:
- GET, POST
Access-Control-Allow-Origin:
- '*'
Connection:
- keep-alive
Content-Length:
- '454'
Content-Type:
- application/json; charset=utf-8
Date:
- Fri, 18 Sep 2020 10:53:25 GMT
Server:
- openresty
X-Cache-Key:
- /data/2.5/weather?q=london&units=metric
status:
code: 200
message: OK
version: 1
That's a lot!
VCR.py
takes care of that for us.VCR.py
is more than enough for our needs.