@@ -15,10 +15,209 @@ tags:
1515
1616common 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
23222pros:
24223* no change to client code
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