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

api-testing python requests pytest rest

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.

Frequently Asked Questions

Why test an API directly instead of through the UI?
A UI test exercises the browser, the JavaScript framework, and the API all at once, so a failure could be any of the three. Calling the API directly isolates it — the test only fails when the backend's contract actually changes, and it runs in milliseconds instead of seconds because there is no browser to drive.
Is it safe to run tests against httpbin.org?
httpbin.org is a public service built for exactly this — it echoes back whatever you send it and never persists data. It's fine for learning and for read-only examples, but a real test suite should not depend on a third party's uptime; later lessons in this track build a local stub server for anything that needs to be fully offline.
Do I still need Postman if I'm writing pytest tests?
Postman is fine for poking at an API by hand while you're exploring it, but it doesn't belong in a CI pipeline the way a pytest suite does — there's no clean diff, no straightforward way to run it headless, and assertions live in a GUI rather than in version control. Use one for exploration, the other for automation.
Why check response.headers instead of just the JSON body?
The body only tells you what data came back. Headers tell you how the server is treating the request — content type, caching directives, rate-limit counters, auth challenges — and a broken API often reports a 200 with a correct-looking body while getting one of those wrong.
What does requests.get(...).json() actually do?
It reads the raw response body as text and parses it as JSON, raising a `JSONDecodeError` if the body isn't valid JSON. It's a convenience wrapper — you could call `json.loads(response.text)` yourself and get the same result.