Skip to content

Commit b8c46ba

Browse files
committed
more on blog post
1 parent 307aa79 commit b8c46ba

1 file changed

Lines changed: 202 additions & 4 deletions

File tree

_posts/2020-01-25-testing_external_api_calls.md

Lines changed: 202 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,209 @@ tags:
1515

1616
common question, how do i write tests for external api calls
1717

18-
example tbc
1918

20-
common solution. use mocks and mock.patch. fine, actually, if you
21-
have just one call to one endpoint
19+
Let's take an example to do with logistics, we have a model of a shipment which contains a number
20+
of order lines. We also care about its estimated time of arrival (`eta`) and a bit of jargon
21+
called the "incoterm".
22+
23+
```python
24+
from dataclasses import dataclass
25+
from datetime import date
26+
from typing import List, Optional
27+
28+
@dataclass
29+
class OrderLine:
30+
sku: str
31+
qty: int
32+
33+
34+
@dataclass
35+
class Shipment:
36+
reference: str
37+
lines: List[OrderLine]
38+
eta: Optional[date]
39+
incoterm: str
40+
41+
def save(self):
42+
...
43+
```
44+
45+
46+
We want to sink our shipments model with a third party, the cargo freight company, via their API.
47+
We have a couple of use cases, new shipment creation, and checking for updated etas:
48+
49+
50+
Creating a new shipment isn't too hard:
51+
```python
52+
def create_shipment(quantities: Dict[str, int], incoterm):
53+
reference = uuid.uuid4().hex[:10]
54+
order_lines = [OrderLine(sku=sku, qty=qty) for sku, qty in quantities.items()]
55+
shipment = Shipment(reference=reference, lines=order_lines, eta=None, incoterm=incoterm)
56+
shipment.save()
57+
sync_to_api(shipment)
58+
```
59+
60+
61+
How do we sync to the API? a simple post request, with a bit of datatype conversion and wrangling.
62+
63+
```python
64+
def sync_to_api(shipment):
65+
requests.post(f'{API_URL}/shipments/', json={
66+
'client_reference': shipment.reference,
67+
'arrival_date': shipment.eta.isoformat(),
68+
'products': [
69+
{'sku': ol.sku, 'quantity': ol.quantity}
70+
for ol in shipment.lines
71+
]
72+
})
73+
```
74+
75+
Not too bad! In a case like this, the typical reaction is to reach for mocks,
76+
and _as long as things stay simple_, it's pretty manageable
77+
78+
79+
```python
80+
def test_create_shipment_does_post_to_external_api():
81+
with mock.patch('use_cases.requests') as mock_requests:
82+
shipment = create_shipment({'sku1': 10}, incoterm='EXW')
83+
expected_data = {
84+
'client_reference': shipment.reference,
85+
'arrival_date': None,
86+
'products': [{'sku': 'sku1', 'quantity': 10}],
87+
}
88+
assert mock_requests.post.call_args == mock.call(
89+
API_URL + '/shipments/', json=expected_data
90+
)
91+
```
92+
93+
And you can imagine adding a few more tests, perhaps one that checks that
94+
we do the date-to-isoformat conversion correctly, maybe one that checks we can handle multiple
95+
lines. Three tests, one mock each, we're ok.
96+
97+
The trouble is that it never stays quite that simple does it? For example,
98+
the cargo company may already have a shipment on record, because reasons.
99+
So we first need to check whether they have a shipment on file, using
100+
a GET request, and then we either do a POST if it's new, or a PUT for
101+
an existing one:
102+
103+
```python
104+
def get_shipment_id(our_reference) -> Optional[str]:
105+
their_shipments = requests.get(f"{API_URL}/shipments/").json()['items']
106+
return next(
107+
(s['id'] for s in their_shipments if s['client_reference'] == our_reference),
108+
None
109+
)
110+
111+
112+
113+
def sync_to_api(shipment):
114+
external_shipment_id = get_shipment_id(shipment.reference)
115+
if external_shipment_id is None:
116+
requests.post(f'{API_URL}/shipments/', json={
117+
'client_reference': shipment.reference,
118+
'arrival_date': shipment.eta,
119+
'products': [
120+
{'sku': ol.sku, 'quantity': ol.quantity}
121+
for ol in shipment.lines
122+
]
123+
})
124+
125+
else:
126+
requests.put(f'{API_URL}/shipments/{external_shipment_id}', json={
127+
'client_reference': shipment.reference,
128+
'arrival_date': shipment.eta,
129+
'products': [
130+
{'sku': ol.sku, 'quantity': ol.quantity}
131+
for ol in shipment.lines
132+
]
133+
})
134+
135+
136+
137+
```
138+
139+
* because things are never easy, the third party has different reference numbers to us,
140+
so we need the `get_shipment_id()` function that finds the right one for us
141+
142+
* and we need to use POST if it's a new shipment, or PUT if it's an existing one.
143+
don't ask why they would know about a shipment before we do, it happens.
144+
145+
Already you can imagine we're going to need to write quite a few tests to cover all these options.
146+
147+
```python
148+
def test_does_PUT_if_shipment_already_exists():
149+
with mock.patch('use_cases.uuid') as mock_uuid, mock.patch('use_cases.requests') as mock_requests:
150+
mock_uuid.uuid4.return_value.hex = 'our-id'
151+
mock_requests.get.return_value.json.return_value = {
152+
'items': [{'id': 'their-id', 'client_reference': 'our-id'}]
153+
}
154+
155+
shipment = create_shipment({'sku1': 10}, incoterm='EXW')
156+
assert mock_requests.post.called is False
157+
expected_data = {
158+
'client_reference': 'our-id',
159+
'arrival_date': None,
160+
'products': [{'sku': 'sku1', 'quantity': 10}],
161+
}
162+
assert mock_requests.put.call_args == mock.call(
163+
API_URL + '/shipments/their-id/', json=expected_data
164+
)
165+
```
166+
167+
168+
yeesh. This is getting less pleasant.
169+
170+
171+
But it gets better! We want to poll our third party api now and again to get updated etas
172+
for ours shipments. Depending on the eta, we have some business logic about notifying
173+
people of delays...
174+
175+
176+
```python
177+
def get_updated_eta(shipment):
178+
external_shipment_id = get_shipment_id(shipment.reference)
179+
if external_shipment_id is None:
180+
logging.warning(
181+
'tried to get updated eta for shipment %s not yet sent to partners',
182+
shipment.reference
183+
)
184+
return
185+
186+
[journey] = requests.get(f"{API_URL}/shipments/{external_shipment_id}/journeys").json()['items']
187+
latest_eta = journey['eta']
188+
if latest_eta == shipment.eta:
189+
return
190+
logging.info(
191+
'setting new shipment eta for %s: %s (was %s)',
192+
shipment.reference, latest_eta, shipment.eta
193+
)
194+
if shipment.eta is not None and latest_eta > shipment.eta:
195+
notify_delay(shipment_ref=shipment.reference, delay=latest_eta - shipment.eta)
196+
if shipment.eta is None and shipment.incoterm == 'FOB' and len(shipment.lines) > 10:
197+
notify_new_large_shipment(shipment_ref=shipment.reference, eta=latest_eta)
198+
199+
shipment.eta = latest_eta
200+
shipment.save()
201+
```
202+
203+
I haven't coded up what all the tests would look like, but you could imagine them:
204+
205+
* a test that if the shipment does not exist, we log a warning. Needs to mock `requests.get` or `get_shipment_id()`
206+
* a test that if the eta has not changed, we do nothing. Needs two different mocks on `requests.get`
207+
* a test for the error case where the shipments api has no journeys
208+
* a test for the edge case where the shipment has multiple journeys
209+
* two tests to check that if the eta is is later than the current one, we do a notification.
210+
* two test for the large shipments notification
211+
* and a general test that we update the local eta and save it.
212+
213+
And each one of these tests needs to set up three or four mocks. We're getting into what Ed Jung
214+
calls [Mock Hell](https://www.youtube.com/watch?v=CdKaZ7boiZ4).
215+
216+
On top of our tests being hard to read and write, they're also brittle. If we change the way we
217+
import, from `import requests` to `from requests import get` (not that you'd ever do that, but
218+
you get the point), then all our mocks break. Or perhaps we decide to stop using
219+
`requests.get()` because we want to use `requests.Session()` for whatever reason.
220+
22221

23222
pros:
24223
* no change to client code
@@ -37,7 +236,6 @@ cons:
37236
* and you still need an integration test or two, and maybe an E2E test.
38237
so you need a sandbox, or some way of faking it out irl
39238

40-
link to ed "mocking pitfalls" video
41239

42240

43241
# step 1: DI

0 commit comments

Comments
 (0)