API Testing · lesson 1 of 11
Testing a REST API with requests and pytest
Write your first automated API test in Python — a GET request, an assertion on the JSON body, and a failure that shows exactly what pytest caught.
By SkillByExample · Published
An API test sends a real HTTP request and asserts on the real response — no browser, no UI, just the contract between client and server.
Installing
pip install requests pytest
Successfully installed requests-2.34.2 pytest-9.1.1
requests sends the HTTP request; pytest runs the assertions and reports the result. Every
example in this track uses this same pair.
The first test
httpbin.org is a public service that echoes back whatever you send it — no account, no API key, and it never persists anything, which makes it a safe target for learning.
# test_api_intro.py
import requests
BASE_URL = "https://httpbin.org"
def test_get_returns_200():
response = requests.get(f"{BASE_URL}/get")
assert response.status_code == 200
def test_get_returns_json_with_our_url():
response = requests.get(f"{BASE_URL}/get")
data = response.json()
assert data["url"] == "https://httpbin.org/get"
pytest -v
============================= test session starts =============================
platform win32 -- Python 3.13.5, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\work\api-testing
collecting ... collected 2 items
test_api_intro.py::test_get_returns_200 PASSED [ 50%]
test_api_intro.py::test_get_returns_json_with_our_url PASSED [100%]
============================== 2 passed in 2.58s ==============================
Two requests went over the network, two responses came back, two assertions held. response
is a requests.Response object — .status_code is an int, .json() parses the body.
A failure that names the real difference
Write the assertion with a typo — a trailing slash that isn’t actually in the URL httpbin reports back:
def test_get_returns_json_with_trailing_slash():
response = requests.get(f"{BASE_URL}/get")
data = response.json()
assert data["url"] == "https://httpbin.org/get/"
__________________ test_get_returns_json_with_trailing_slash __________________
def test_get_returns_json_with_trailing_slash():
response = requests.get(f"{BASE_URL}/get")
data = response.json()
> assert data["url"] == "https://httpbin.org/get/"
E AssertionError: assert 'https://httpbin.org/get' == 'https://httpbin.org/get/'
E
E - https://httpbin.org/get/
E ? -
E + https://httpbin.org/get
test_bug.py:9: AssertionError
pytest’s string diff points at the exact character that differs — the trailing -. That
single character is the entire bug: the test’s expectation, not the API, was wrong. This is
the shape every API test failure should take: a specific field, a specific mismatch, not a
generic “request failed.”
Query params and headers
def test_query_params_are_echoed_back():
response = requests.get(f"{BASE_URL}/get", params={"category": "books", "limit": 5})
data = response.json()
assert data["args"] == {"category": "books", "limit": "5"}
def test_content_type_header_is_json():
response = requests.get(f"{BASE_URL}/get")
assert response.headers["Content-Type"] == "application/json"
test_params.py::test_query_params_are_echoed_back PASSED [ 50%]
test_params.py::test_content_type_header_is_json PASSED [100%]
============================== 2 passed in 2.61s ==============================
Two things worth noticing. First, params={"limit": 5} sends an int, but the query string is
always text — httpbin echoes it back as "5", and the assertion expects a string to match.
Second, response.headers behaves like a dict but is case-insensitive: headers["content-type"]
would have matched too, because HTTP header names aren’t case-sensitive and requests respects
that.
Practice
1. Write a test that POSTs a JSON body to /post and asserts it comes back unchanged.
def test_post_echoes_json_body():
payload = {"title": "Foundation", "year": 1951}
response = requests.post(f"{BASE_URL}/post", json=payload)
assert response.status_code == 200
assert response.json()["json"] == payload
test_practice.py::test_post_echoes_json_body PASSED [ 25%]
json=payload does two things: serializes the dict and sets Content-Type: application/json
for you. httpbin echoes the parsed body back under the "json" key of its response.
2. Write a test asserting that /status/404 actually returns a 404.
def test_unknown_status_path_returns_404():
response = requests.get(f"{BASE_URL}/status/404")
assert response.status_code == 404
test_practice.py::test_unknown_status_path_returns_404 PASSED [ 50%]
requests does not raise an exception on a 4xx or 5xx status by default — it hands you the
response and lets you decide. response.raise_for_status() is the opt-in for the opposite
behavior, covered later in this track.
3. Assert the wrong content type on purpose and read what pytest reports.
def test_content_type_header_wrong_on_purpose():
response = requests.get(f"{BASE_URL}/get")
assert response.headers["Content-Type"] == "text/plain"
> assert response.headers["Content-Type"] == "text/plain"
E AssertionError: assert 'application/json' == 'text/plain'
E
E - text/plain
E + application/json
Same diff format as the URL failure above — pytest doesn’t care whether the string came from a header or a body field, the introspection works identically on both.
4. Assert that the default User-Agent header identifies the requests library.
def test_default_user_agent_identifies_requests():
response = requests.get(f"{BASE_URL}/get")
assert "python-requests" in response.json()["headers"]["User-Agent"]
test_practice.py::test_default_user_agent_identifies_requests PASSED [100%]
requests sets User-Agent: python-requests/<version> unless you override it. Some APIs
reject the default value and require a custom one — worth checking early rather than debugging
a mysterious 403 later.
Next: the rest of the HTTP verbs, and structuring requests beyond a single GET.