API Testing · lesson 2 of 11
HTTP Methods and Body Encodings
Test PUT, PATCH, and DELETE against a real API, then chase down the bug that happens when a JSON API receives a form-encoded body instead.
By SkillByExample · Published
Every HTTP verb carries an intent, and testing that intent means asserting on more than just the status code.
PUT, PATCH, and DELETE
import requests
BASE_URL = "https://httpbin.org"
def test_put_replaces_the_whole_resource():
response = requests.put(f"{BASE_URL}/put", json={"title": "Dune", "year": 1965})
assert response.status_code == 200
assert response.json()["json"] == {"title": "Dune", "year": 1965}
def test_patch_sends_a_partial_update():
response = requests.patch(f"{BASE_URL}/patch", json={"year": 1966})
assert response.status_code == 200
assert response.json()["json"] == {"year": 1966}
def test_delete_returns_200():
response = requests.delete(f"{BASE_URL}/delete")
assert response.status_code == 200
pytest test_verbs.py -v
test_verbs.py::test_put_replaces_the_whole_resource PASSED [ 33%]
test_verbs.py::test_patch_sends_a_partial_update PASSED [ 66%]
test_verbs.py::test_delete_returns_200 PASSED [100%]
============================== 3 passed in 3.67s ==============================
httpbin doesn’t actually store anything, so it can’t enforce PUT-replaces-fields semantics
for you — against a real API, the test for PUT would go on to GET the resource afterward
and assert the fields you left out actually reset, not just that the PUT call itself returned
200.
The bug: sending a form body to a JSON API
def test_data_kwarg_sends_form_not_json():
response = requests.post(f"{BASE_URL}/post", data={"title": "Dune", "year": 1965})
payload = response.json()
assert payload["json"] == {"title": "Dune", "year": 1965}
_____________________ test_data_kwarg_sends_form_not_json _____________________
def test_data_kwarg_sends_form_not_json():
response = requests.post(f"{BASE_URL}/post", data={"title": "Dune", "year": 1965})
payload = response.json()
> assert payload["json"] == {"title": "Dune", "year": 1965}
E AssertionError: assert None == {'title': 'Dune', 'year': 1965}
test_encoding_bug.py:9: AssertionError
payload["json"] is None — not the dict, not an empty dict, None. data= told requests
to form-encode the body and send Content-Type: application/x-www-form-urlencoded. The API
never received JSON at all, so there was nothing for it to parse into the "json" field. This
is the single most common integration bug between a Python client and a JSON API: data= and
json= look interchangeable and are not.
The fix, and what each kwarg actually sends
def test_data_kwarg_lands_in_form_field():
response = requests.post(f"{BASE_URL}/post", data={"title": "Dune", "year": 1965})
payload = response.json()
assert payload["form"] == {"title": "Dune", "year": "1965"}
assert payload["json"] is None
assert response.request.headers["Content-Type"] == "application/x-www-form-urlencoded"
def test_json_kwarg_sets_content_type_and_json_field():
response = requests.post(f"{BASE_URL}/post", json={"title": "Dune", "year": 1965})
payload = response.json()
assert payload["json"] == {"title": "Dune", "year": 1965}
assert response.request.headers["Content-Type"] == "application/json"
test_encoding_fixed.py::test_data_kwarg_lands_in_form_field PASSED [ 50%]
test_encoding_fixed.py::test_json_kwarg_sets_content_type_and_json_field PASSED [100%]
============================== 2 passed in 2.57s ==============================
response.request is the prepared request requests actually sent — asserting on
response.request.headers["Content-Type"] is how a test catches “sent the wrong encoding”
directly, instead of inferring it from a confusing response. Note also that data=
form-encodes 1965 as the string "1965", while json= preserves it as the number 1965 —
JSON has a native integer type, a query string and a form body don’t.
Practice
1. Write a test for a HEAD request and assert its body is empty.
def test_head_request_has_no_body():
response = requests.head(f"{BASE_URL}/get")
assert response.status_code == 200
assert response.text == ""
test_practice.py::test_head_request_has_no_body PASSED [ 25%]
HEAD asks a server “what would GET return” without paying for the body — same status code and headers, empty payload. Useful for checking a resource exists without downloading it.
2. PUT a payload with one field missing and check what happens.
def test_put_full_replace_drops_unset_fields():
response = requests.put(f"{BASE_URL}/put", json={"title": "Dune"})
assert response.json()["json"] == {"title": "Dune"}
test_practice.py::test_put_full_replace_drops_unset_fields PASSED [ 50%]
httpbin just echoes whatever you send — it can’t demonstrate the “replace” contract by itself. Against a real API this is the test that catches a server that treats PUT like PATCH: fetch the resource after the PUT and assert the omitted field reset rather than survived.
3. Send an int and a bool as query params and assert what they look like on the other side.
def test_query_string_values_are_always_strings():
response = requests.get(f"{BASE_URL}/get", params={"page": 2, "active": True})
args = response.json()["args"]
assert args == {"page": "2", "active": "True"}
test_practice.py::test_query_string_values_are_always_strings PASSED [ 75%]
Every query param becomes text — 2 becomes "2" and True becomes "True", not "true".
A server expecting lowercase JSON booleans in its query string parsing has to handle this
explicitly; it won’t come for free.
4. Send a custom header and assert the API received it.
def test_custom_header_is_sent():
response = requests.get(f"{BASE_URL}/get", headers={"X-Client-Id": "acme-42"})
assert response.json()["headers"]["X-Client-Id"] == "acme-42"
test_practice.py::test_custom_header_is_sent PASSED [100%]
headers= merges with requests’ defaults rather than replacing them — the custom header
shows up alongside User-Agent and Accept, not instead of them.
Next: pulling the repeated BASE_URL and request setup into pytest fixtures.