Skip to main content
API Testing · lesson 2 of 11
API Testing beginner 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

api-testing python requests http rest

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.

Frequently Asked Questions

What's the real difference between PUT and PATCH?
PUT means replace the whole resource with what you send — fields you omit are expected to disappear or reset to a default. PATCH means apply a partial update — fields you omit are left alone. A test suite that only ever sends full payloads will never catch a server that gets PATCH's partial semantics wrong.
Why does data={...} not show up under response.json()['json'] on httpbin?
requests' data= kwarg form-encodes the dict as application/x-www-form-urlencoded, not JSON — httpbin puts form-encoded bodies under the 'form' key and only populates 'json' when the Content-Type is actually application/json. Sending data= to an API that expects JSON is one of the most common real-world integration bugs.
Why did requests.get(..., params={'active': True}) return the string 'True', not a bool?
A query string is text — there is no boolean type in a URL. requests converts every param value to its str() form before encoding it, so True becomes 'True' and 5 becomes '5'. Assert against the string, not the original Python type.
Does a DELETE request need a body?
Usually not — DELETE identifies the resource through the URL, and most APIs ignore or reject a body on it. requests.delete() supports data= and json= kwargs for the rare API that expects one, but check the specific API's docs before relying on it.
Is response.request useful for anything besides debugging?
Yes — asserting on it is how you test what your client actually sent, not just what came back. response.request.headers, .body, and .url let you catch a test that thinks it sent JSON but actually sent form data, which is exactly the bug this lesson walks through.