API testing is one of the biggest reasons QA engineers learn Python — and requests is the reason it's pleasant. The library wraps Python's verbose built-in HTTP client (urllib) in a clean API: three lines for a GET, one method per HTTP verb, automatic JSON parsing, sensible defaults. This lesson covers GET, POST, PUT, PATCH, DELETE, headers, query parameters, status codes, timeouts, error handling, and Session for cookie-based flows. By the end you'll be writing real API tests.
Installing requests
requests is a third-party library. Install it once per project, ideally inside a virtual environment:
pip install requestsThen in your script:
import requestsThat's it. No client to instantiate, no boilerplate to wire up.
A GET request — the basic shape
import requests
response = requests.get("https://jsonplaceholder.typicode.com/users")
print(response.status_code) # 200
print(response.json()[0]["name"]) # parses the JSON body, indexes the first userrequests.get(url) sends a GET and returns a Response object. Three properties you'll touch most often:
response.status_code— the HTTP status as an int (200, 404, 500, …).response.json()— parses the body as JSON, returns Python dicts/lists. Raises if the body isn't valid JSON.response.text— the raw response body as a string. Useful when the response isn't JSON, or for debugging an unexpected error page.
Compare this to Java's HttpClient or Rest Assured — fewer lines, no fluent builders. That terseness is exactly what makes requests the de facto standard.
Other HTTP methods
Same shape, different verbs:
requests.get(url)
requests.post(url, json=payload)
requests.put(url, json=payload)
requests.patch(url, json=payload)
requests.delete(url)Use the verb that matches the API contract:
GET— read.POST— create (usually).PUT— replace.PATCH— partial update.DELETE— remove.
Sending JSON — the json= parameter
For a POST with a JSON body, pass json= and requests serialises the dict and sets Content-Type: application/json for you:
payload = {"name": "Alice", "email": "alice@test.com"}
response = requests.post(
"https://api.example.com/users",
json=payload
)
print(response.status_code) # 201 Created
print(response.json()["id"]) # the new user's IDDon't confuse json= with data=. json= serialises a dict to JSON; data= sends a form-encoded body (name=Alice&email=...) — the kind of body a browser submits from a <form>. Pick the right one for the API you're testing.
Headers — auth, content type, custom
Pass headers as a dict via headers=:
headers = {
"Authorization": "Bearer eyJhbGciOiJI...",
"Accept": "application/json",
"X-Request-Id": "test-1234"
}
response = requests.get("https://api.example.com/me", headers=headers)requests already sends sensible defaults — Accept-Encoding: gzip, deflate, a User-Agent. You only need to set the headers your API actually requires.
A common QA pattern: store the auth token in a variable once, reuse it:
TOKEN = "eyJhbGciOiJI..."
auth_header = {"Authorization": f"Bearer {TOKEN}"}
requests.get(url1, headers=auth_header)
requests.get(url2, headers=auth_header)For repeated calls, Session (later) is even cleaner.
Query parameters — params=
Don't hand-build query strings — pass a dict and requests URL-encodes everything for you:
params = {"page": 1, "limit": 10, "role": "admin"}
response = requests.get("https://api.example.com/users", params=params)
print(response.url)
# https://api.example.com/users?page=1&limit=10&role=adminSpecial characters (spaces, &, +, non-ASCII) are escaped automatically — no chance of building a malformed URL by hand.
The Response object — the rest of it
A Response exposes more than just status and body:
response = requests.get("https://jsonplaceholder.typicode.com/users/1")
response.status_code # 200
response.ok # True if status < 400
response.json() # parsed JSON
response.text # raw body as string
response.content # raw body as bytes (binary safe)
response.headers # dict-like of response headers
response.headers["Content-Type"] # 'application/json; charset=utf-8'
response.elapsed # timedelta — how long the round-trip took
response.elapsed.total_seconds() # 0.142 — useful for SLA assertions
response.url # final URL after any redirects
response.history # list of intermediate redirectsresponse.ok is a quick "did it succeed at the HTTP level" boolean. Useful, but raise_for_status() (next section) is what most tests should use.
Failing fast on errors — raise_for_status()
If you want a 4xx or 5xx response to abort the test loudly, call raise_for_status():
response = requests.get("https://api.example.com/missing")
response.raise_for_status() # raises HTTPError for 4xx / 5xxA passing test that gets a 500 should fail visibly. A failing call that you assumed was succeeding will silently trip up later code with confusing errors. raise_for_status() collapses that ambiguity.
The pattern in test code:
response = requests.get(url)
response.raise_for_status()
data = response.json()
# ... use data ...For a defensive check that expects a particular failure, compare response.status_code directly:
response = requests.get("https://api.example.com/users/0")
assert response.status_code == 404, f"expected 404, got {response.status_code}"Timeouts — always pass one
By default, requests has no timeout — it will wait forever for a hanging server. In a test suite that's a recipe for stuck CI jobs. Always pass timeout=:
response = requests.get("https://api.example.com/users", timeout=5)timeout=5 waits up to 5 seconds for the server to start sending bytes; if it doesn't, requests.exceptions.Timeout is raised. For finer control: timeout=(connect, read) accepts a tuple — timeout=(3, 10) for "3s to connect, 10s to read."
Sessions — for cookie-based flows
A common API test pattern: log in (gets a session cookie), then perform actions while authenticated. Session keeps cookies and default headers across calls:
session = requests.Session()
session.post("https://api.example.com/login", json={
"email": "alice@test.com",
"password": "SecurePass123"
})
# the session now holds whatever cookies the login set
response = session.get("https://api.example.com/dashboard")
print(response.json()) # authenticated request — cookies sent automaticallyYou can also set default headers once on the session: session.headers.update({"X-Tenant": "acme"}) and they apply to every subsequent request. For test scripts with many calls, prefer Session over re-passing headers.
Common error types
Three exceptions you'll meet, all from requests.exceptions:
import requests
try:
response = requests.get("https://api.example.com/users", timeout=5)
response.raise_for_status()
data = response.json()
except requests.exceptions.Timeout:
print("Request timed out")
except requests.exceptions.ConnectionError:
print("Could not connect — DNS, network, or refused")
except requests.exceptions.HTTPError as e:
print(f"HTTP error: {e.response.status_code}")We'll cover try/except properly in chapter 6. For now, know that wrapping a request in one stops a single bad call from killing the whole script.
A QA example — login then fetch profile
A short end-to-end script that logs in, fetches the user profile, and asserts the basics:
import requests
BASE = "https://api.example.com"
def login(email: str, password: str) -> requests.Session:
"""Log in and return an authenticated Session."""
session = requests.Session()
response = session.post(
f"{BASE}/login",
json={"email": email, "password": password},
timeout=5
)
response.raise_for_status()
return session
def get_profile(session: requests.Session) -> dict:
response = session.get(f"{BASE}/me", timeout=5)
response.raise_for_status()
return response.json()
session = login("alice@test.com", "SecurePass123")
profile = get_profile(session)
assert profile["email"] == "alice@test.com"
assert profile["role"] in ("admin", "tester", "viewer")
print(f"Logged in as {profile['name']} ({profile['role']})")Two helpers, three lines of test logic. The Session keeps the cookies between calls; raise_for_status() makes any HTTP failure abort the script with a useful traceback. That's the skeleton most API tests share.
A request, end to end
Step 1 of 6
Build the request
Pick a verb (get/post/...), a URL, plus headers, params, and json= as needed. requests handles encoding for you.
Six steps, the same shape on every call. Internalise it once and the rest of API testing is just picking the right verb and the right assertion.
⚠️ Common mistakes
- No timeout passed.
requests.get(url)with no timeout waits forever on a hung server. CI jobs sit there until they're killed externally. Always passtimeout=5(or whatever your SLA allows). Treat it as a required argument. - Confusing
json=anddata=.json=payloadsends{"key": "value"}as JSON with the right Content-Type.data=payloadsendskey=value&...as form-urlencoded. Pick the one your API expects — using the wrong one usually returns a 400 or 415. - Calling
.json()without checking the status. A 500 response from an HTML error page raisesJSONDecodeErrorfrom.json(), masking the real status. Checkresponse.ok(or callresponse.raise_for_status()) before parsing.
🎯 Practice task
Hit a real public API. 25-30 minutes.
- Make sure
requestsis installed:pip install requests. Use a venv if possible. - Create
api_play.py. Use JSONPlaceholder — a free public test API. - GET — fetch
https://jsonplaceholder.typicode.com/users. Passtimeout=5. Assertresponse.status_code == 200. Print the count of users and the first user'snamefromresponse.json(). - GET with params — fetch
https://jsonplaceholder.typicode.com/postswithparams={"userId": 1}. Printresponse.urlto confirm the query string andlen(response.json())to see how many posts came back. - POST — call
requests.post("https://jsonplaceholder.typicode.com/posts", json={"title": "QA test", "body": "hello", "userId": 1}). Print the resulting status code (should be 201) and the new post'sid. - Headers — repeat the GET from step 3 but pass
headers={"Accept": "application/json", "X-Request-Id": "test-001"}. Printresponse.request.headers["X-Request-Id"]to confirm it was sent. - Error handling — call
requests.get("https://jsonplaceholder.typicode.com/users/9999", timeout=5). Print thestatus_code. Wrap aresponse.raise_for_status()in atry/except requests.exceptions.HTTPErrorand print the caught error. - Timing — print
response.elapsed.total_seconds()after one of your calls. Addassert response.elapsed.total_seconds() < 2.0. - Stretch: open a
Session, set a default header (session.headers.update({"X-Suite": "smoke"})), then make three GET calls through the session. Confirm viaresponse.request.headersthat the custom header is sent on every call.
You can now make any HTTP call a test needs to make. The next lesson focuses on what to do with the response — parsing, validating, and asserting on the JSON you get back.