Resource library

QA How-To

API Testing with pytest and requests: Step-by-Step Tutorial

Learn API testing with pytest and requests using a local Books API, isolated fixtures, parameterized negative tests, response checks, and a CI-ready suite.

24 min read | 2,794 words

TL;DR

Build a local Books API, start it through a pytest fixture, and call it with Requests. Assert the full HTTP contract plus readback or deletion state, parameterize negative cases, and run the suite with a JUnit XML report.

Key Takeaways

  • Start a fresh local HTTP server for each test to remove order dependence.
  • Use requests.Session for shared headers and specify a timeout on every call.
  • Check response status, media type, body, headers, and follow-up state.
  • Parameterize distinct invalid inputs and assert that rejected writes leave no record.
  • Treat expected 4xx responses differently from transport exceptions.
  • Generate a JUnit XML report from the complete pytest suite for CI.

API Testing with pytest and requests works best when each test makes a real HTTP call, checks the response contract, and verifies the resulting state. In this tutorial you will run a small Books API on your own machine and build a pytest suite that creates, reads, lists, rejects, and deletes books. The server is deliberately tiny so you can see what each assertion proves.

You will use requests.Session for shared request headers, pytest fixtures for a fresh server per test, and parametrization for input boundaries. The finished suite needs no hosted test account or public demo API. That matters when you want failures to reflect your code and assertions rather than an unrelated service outage.

What You Will Build

  • A local HTTP service with GET /health, GET /books, GET /books/{id}, POST /books, and DELETE /books/{id}.
  • A function-scoped pytest fixture that starts the service on an available loopback port and closes it after each test.
  • Positive tests for creation, retrieval, collection queries, and deletion.
  • Negative tests for missing credentials, malformed bodies, invalid fields, unsupported media types, and unknown resources.
  • A final test command that produces a JUnit XML report suitable for a CI job.

The service uses an in-memory dictionary. Each test receives an empty one, so assertions do not depend on test order. The API key is a local test value, not a security model. You can reuse the same testing pattern against a real service after replacing the server fixture with an environment-specific base URL and credentials.

Prerequisites

Use Python 3.12 for the walkthrough. The examples were checked against the documented APIs of pytest 9.1.1 and Requests 2.34.2. Those published versions are specific reference points, not guesses about what your machine has installed. If your team pins packages, match the versions approved in your environment; capture them with the commands below before comparing results.

python3.12 --version
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install pytest requests
python -m pytest --version
python -c "import requests; print(requests.__version__)"

On Windows PowerShell, activate with .venv\Scripts\Activate.ps1; use py -3.12 -m venv .venv if that is how Python 3.12 is registered. The commands from this point run in a new directory named pytest-requests-books. Put the files shown below in that directory. An environment with a blocked package index needs your organization's configured mirror; the example HTTP server itself uses only the Python standard library.

Check the version output before writing tests. If python -m pytest --version fails, the active interpreter and the interpreter used for installation probably differ. python -m pip --version shows which environment receives packages. For background on test discovery and the command line, see the pytest tutorial for beginners.

Step 1: Set Up API Testing with pytest and requests

Make a dedicated directory so pytest does not collect unrelated project tests. Add an initial test that checks only the runner. It gives you a known-good baseline before a failed HTTP assertion complicates diagnosis.

mkdir pytest-requests-books
cd pytest-requests-books

Save this as test_environment.py:

def test_pytest_can_collect_tests():
    assert 2 + 2 == 4

Verify with python -m pytest -q test_environment.py. Expect 1 passed. The file name begins with test_, and the function name does too, which lets pytest discover it without custom configuration. If pytest reports that it collected no tests, check both names and run the command from the directory containing the file.

This test is intentionally simple. Its value is diagnostic: it separates interpreter, package, and discovery problems from the API behavior you add next. Keep it while learning, or remove it after the service tests pass. Do not infer that a passing baseline proves the HTTP service is running; the service does not exist yet.

Step 2: Start a controlled Books API

Save the following as book_api.py. It binds to loopback, chooses a free port by default, and returns JSON for ordinary responses. A write requires the X-API-Key header. Successful deletion returns 204 with no JSON body.

import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse


def make_server(host="127.0.0.1", port=0):
    books = {}
    next_id = 1

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, format, *args):
            pass

        def send_result(self, status, payload=None, extra_headers=None):
            data = b"" if payload is None else json.dumps(payload).encode("utf-8")
            self.send_response(status)
            if payload is not None:
                self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            for name, value in (extra_headers or {}).items():
                self.send_header(name, value)
            self.end_headers()
            if data:
                self.wfile.write(data)

        def authorized(self):
            if self.headers.get("X-API-Key") == "local-test-key":
                return True
            self.send_result(401, {"error": "API key required"})
            return False

        def do_GET(self):
            parsed = urlparse(self.path)
            if parsed.path == "/health":
                return self.send_result(200, {"status": "ok"})
            if parsed.path == "/books":
                raw_limit = parse_qs(parsed.query).get("limit", ["50"])[0]
                try:
                    limit = int(raw_limit)
                except ValueError:
                    return self.send_result(400, {"error": "Invalid limit"})
                if not 1 <= limit <= 50:
                    return self.send_result(400, {"error": "Invalid limit"})
                items = list(books.values())[:limit]
                return self.send_result(200, {"items": items, "count": len(items)})
            if parsed.path.startswith("/books/"):
                book = books.get(parsed.path.removeprefix("/books/"))
                if book is not None:
                    return self.send_result(200, book)
            return self.send_result(404, {"error": "Book not found"})

        def do_POST(self):
            nonlocal next_id
            if urlparse(self.path).path != "/books":
                return self.send_result(404, {"error": "Book not found"})
            if not self.authorized():
                return
            media_type = self.headers.get("Content-Type", "").split(";")[0].strip().lower()
            if media_type != "application/json":
                return self.send_result(415, {"error": "JSON required"})
            try:
                length = int(self.headers.get("Content-Length", "0"))
                payload = json.loads(self.rfile.read(length))
            except (ValueError, UnicodeDecodeError):
                return self.send_result(400, {"error": "Invalid JSON"})
            if not isinstance(payload, dict):
                return self.send_result(400, {"error": "Invalid book"})
            title, author = payload.get("title"), payload.get("author")
            if not all(isinstance(value, str) and value.strip() for value in (title, author)):
                return self.send_result(400, {"error": "Title and author required"})
            book_id = str(next_id)
            next_id += 1
            book = {"id": book_id, "title": title.strip(), "author": author.strip()}
            books[book_id] = book
            self.send_result(201, book, {"Location": f"/books/{book_id}"})

        def do_DELETE(self):
            path = urlparse(self.path).path
            if not path.startswith("/books/"):
                return self.send_result(404, {"error": "Book not found"})
            if not self.authorized():
                return
            book_id = path.removeprefix("/books/")
            if book_id not in books:
                return self.send_result(404, {"error": "Book not found"})
            del books[book_id]
            self.send_result(204)

    return ThreadingHTTPServer((host, port), Handler)


if __name__ == "__main__":
    server = make_server(port=8000)
    print("Books API listening on http://127.0.0.1:8000", flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()

Verify syntax with python -m py_compile book_api.py. Then run python book_api.py in one terminal and curl -i http://127.0.0.1:8000/health in another. Expect status 200 and {"status": "ok"}. Stop the foreground server with Ctrl+C before continuing. Port 8000 is only for this manual check; the pytest fixture will request an available port.

The handler is teaching code, not a production server. It has no database, concurrent write protection, or real authentication. Those omissions keep the test oracle visible: you can point to each expected status, field, and side effect in one file. In an actual product, derive expectations from its published API contract, not from whatever implementation happens to return today. The REST API test case guide shows how to turn those rules into a coverage map.

Step 3: Give each pytest test its own server and client

Save this as conftest.py in the same directory. Pytest discovers fixtures in that file automatically. The fixture yields a base URL and a requests.Session; after the test, it closes the session and shuts down the server thread.

from threading import Thread

import pytest
import requests

from book_api import make_server


@pytest.fixture
def api():
    server = make_server()
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    client = requests.Session()
    client.headers.update({"X-API-Key": "local-test-key"})
    try:
        yield f"http://127.0.0.1:{server.server_port}", client
    finally:
        client.close()
        server.shutdown()
        thread.join(timeout=5)
        server.server_close()

Save this as test_health.py:

def test_health_is_reachable(api):
    base_url, client = api
    response = client.get(f"{base_url}/health", timeout=(2, 5))
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}

Verify with python -m pytest -q test_health.py. Expect 1 passed. The pair in timeout=(2, 5) is a connection timeout and a read timeout in seconds. It is not a total wall-clock deadline. Requests does not set a timeout by default, so include one on every call in this suite. A real service might need different values based on measured behavior, but an unlimited wait makes a broken pipeline hard to diagnose. See the Requests quickstart for timeout and response behavior.

Function scope is useful here because make_server() constructs a new dictionary and ID counter on every invocation. A failed test cannot leave a book that changes the next test's expected count. A session centralizes the toy credential and closes its connection pool during teardown. For a deployed API, the same fixture can create and delete a unique test namespace rather than starting a local process.

Step 4: Test creation and readback, not just a 201

Save this as test_create.py. The test checks the status, media type, generated ID, representation, and Location header. It follows that location to prove the new resource is readable. This is stronger than asserting only that the POST returned 201.

def test_create_and_read_book(api):
    base_url, client = api
    payload = {"title": "API Design", "author": "Nina"}
    created = client.post(f"{base_url}/books", json=payload, timeout=(2, 5))

    assert created.status_code == 201
    assert created.headers["Content-Type"].split(";")[0] == "application/json"
    assert created.headers["Location"] == "/books/1"
    assert created.json() == {"id": "1", **payload}

    fetched = client.get(base_url + created.headers["Location"], timeout=(2, 5))
    assert fetched.status_code == 200
    assert fetched.json() == created.json()

Verify with python -m pytest -q test_create.py. Expect 1 passed. json=payload serializes the dictionary and supplies the JSON content type. The test uses an independent GET as a second observation of state; it does not use the POST response as the only source of expected data. Here the first ID is deterministic because the server fixture starts empty for this test. In a shared staging environment, assert that the returned ID is well formed and use it for follow-up calls instead of assuming the value is 1.

The test intentionally checks the media type without insisting on one exact header string. A server may append a charset parameter. Checking a meaningful contract while allowing harmless representation details reduces brittle failures. If the resource were created asynchronously, readback might require bounded polling against a documented status endpoint; do not add a fixed sleep to this synchronous example.

Step 5: Parameterize invalid inputs and isolate authorization

Save this as test_rejections.py. Each parameter exercises a distinct invalid body. The extra GET after rejection confirms that no book was inserted. Two separate tests cover the missing key and an unsupported media type, because those failures occur before field validation.

import pytest
import requests


@pytest.mark.parametrize(
    "payload",
    [
        {},
        {"title": "", "author": "Nina"},
        {"title": "   ", "author": "Nina"},
        {"title": "API Design", "author": 7},
        ["API Design", "Nina"],
    ],
)
def test_invalid_book_is_rejected_without_insert(api, payload):
    base_url, client = api
    response = client.post(f"{base_url}/books", json=payload, timeout=(2, 5))
    assert response.status_code == 400
    assert "error" in response.json()

    listing = client.get(f"{base_url}/books", timeout=(2, 5))
    assert listing.status_code == 200
    assert listing.json() == {"items": [], "count": 0}


def test_missing_api_key_is_rejected(api):
    base_url, _ = api
    response = requests.post(
        f"{base_url}/books",
        json={"title": "API Design", "author": "Nina"},
        timeout=(2, 5),
    )
    assert response.status_code == 401
    assert response.json()["error"] == "API key required"


def test_non_json_media_type_is_rejected(api):
    base_url, client = api
    response = client.post(
        f"{base_url}/books", data="title=API+Design", timeout=(2, 5)
    )
    assert response.status_code == 415
    assert response.json()["error"] == "JSON required"

Verify with python -m pytest -q test_rejections.py. Expect 7 passed: five parameter cases plus two independent protocol cases. A failure ID such as test_invalid_book_is_rejected_without_insert[payload2] identifies which input failed. For a larger suite, add readable ids= values to the decorator when the default display becomes hard to scan.

Do not call raise_for_status() before asserting an expected 400, 401, or 415. Requests raises an exception for those responses, which would hide the contract checks you meant to make. In a real API, the status code for a validation error may be 400 or 422; use the documented contract. The negative API testing guide covers a broader error matrix.

Step 6: Exercise collection boundaries and deletion

Save this as test_lifecycle.py. It creates two books, asks for one, then deletes the first. The suite checks collection membership and the state after deletion. A parameterized boundary test covers the accepted limit range defined by this sample API.

import pytest


def test_list_limit_and_delete(api):
    base_url, client = api
    for title in ("First", "Second"):
        response = client.post(
            f"{base_url}/books",
            json={"title": title, "author": "Nina"},
            timeout=(2, 5),
        )
        assert response.status_code == 201

    first_page = client.get(
        f"{base_url}/books", params={"limit": 1}, timeout=(2, 5)
    )
    assert first_page.status_code == 200
    assert first_page.json()["count"] == 1
    assert [book["title"] for book in first_page.json()["items"]] == ["First"]

    deleted = client.delete(f"{base_url}/books/1", timeout=(2, 5))
    assert deleted.status_code == 204
    assert deleted.content == b""

    missing = client.get(f"{base_url}/books/1", timeout=(2, 5))
    assert missing.status_code == 404
    remaining = client.get(f"{base_url}/books", timeout=(2, 5))
    assert [book["title"] for book in remaining.json()["items"]] == ["Second"]


@pytest.mark.parametrize("limit", [0, -1, 51, "many"])
def test_invalid_collection_limit(api, limit):
    base_url, client = api
    response = client.get(
        f"{base_url}/books", params={"limit": limit}, timeout=(2, 5)
    )
    assert response.status_code == 400
    assert response.json() == {"error": "Invalid limit"}

Verify with python -m pytest -q test_lifecycle.py. Expect 5 passed. params={"limit": 1} lets Requests encode the query string; you do not need to concatenate ?limit=1 yourself. The server's limit is a simple slice, not cursor pagination. For a real paged API, test stable ordering, continuation tokens, empty final pages, and records inserted between page requests. The API pagination testing guide develops those cases.

Notice the 204 assertion uses deleted.content == b"". Calling deleted.json() on an empty response raises a JSON decoding error; it cannot demonstrate a successful delete. Also notice the second GET checks a persistent effect. A 204 alone would not reveal a handler that acknowledged deletion but left the record available.

Step 7: Verify API Testing with pytest and requests in CI

Save this as test_failure_semantics.py. A 404 is a completed HTTP exchange. raise_for_status() turns that response into an HTTPError if your client code wants exception-based handling. The test checks the attached response so a different status cannot satisfy the assertion accidentally.

import pytest
import requests


def test_404_can_be_inspected_as_http_error(api):
    base_url, client = api
    response = client.get(f"{base_url}/books/999", timeout=(2, 5))
    assert response.status_code == 404
    assert response.json() == {"error": "Book not found"}

    with pytest.raises(requests.HTTPError) as error:
        response.raise_for_status()
    assert error.value.response.status_code == 404

Verify this step with python -m pytest -q test_failure_semantics.py. Expect 1 passed. Then run all examples and generate a machine-readable report:

python -m pytest -q --junitxml=report.xml

Expect 16 passed: the baseline, health, create, seven rejection cases, five lifecycle cases, and one failure-semantics case. report.xml is a generated local artifact; keep it out of source control if you move this example into a repository. In CI, run the same command after installing dependencies and publish the XML as a test report. A failed assertion should make the process exit unsuccessfully; the XML preserves individual test names and failure details for the build UI.

Outcome What the test receives Useful check
201 or 204 Completed HTTP response Assert contract and follow-up state
400 or 404 Completed HTTP response Assert expected error details
Connection error or timeout Requests exception Diagnose transport or service availability

A refused connection, DNS failure, or timeout is a transport problem: there may be no HTTP status to assert. Requests raises ConnectionError or Timeout for those conditions. Do not label a 404 as a network outage, and do not convert a timeout into an expected 404. This distinction controls whether an application should retry, report a missing resource, or investigate infrastructure. For larger suites, the Python API automation framework guide covers how to organize clients and data builders.

Troubleshooting

Problem: ModuleNotFoundError: No module named 'requests' -> Run python -m pip install pytest requests with the same python executable used for python -m pytest. Check python -m pip --version; a globally installed package does not automatically appear in a virtual environment.

Problem: ConnectionRefusedError during the manual health check -> Confirm python book_api.py is still running and that curl targets 127.0.0.1:8000. If another program owns port 8000, stop it or change the port only in the manual __main__ block and curl command. The pytest fixture already uses port zero to choose an available port.

Problem: pytest reports fixture 'api' not found -> Put conftest.py beside the test_*.py files and run pytest from the project directory. Do not import the fixture directly into each test module; pytest resolves it by name.

Problem: POST returns 401 while you expected 400 -> A session with X-API-Key is required for the validation cases. Use the client yielded by api; reserve a plain requests.post for the intentional missing-key test. Request processing order determines which contract branch is reached.

Problem: a delete test raises a JSON decoding error -> Read status_code and content for the 204 response. There is no JSON document to parse after a successful deletion. Fetch the deleted resource separately to verify state.

Problem: a test hangs or fails only in CI -> Check that each request includes a timeout and the fixture reaches its finally cleanup. Inspect the first failed test in the JUnit report, then reproduce it alone with python -m pytest -q test_lifecycle.py. A shared staging server may require unique data and explicit cleanup; this local fixture supplies isolation automatically.

Where To Go Next

Replace the toy server with your team's OpenAPI-backed service once the mechanics are familiar. Keep the fixture boundary: have it provide an authenticated client, base URL, and test data lifecycle. Add contract assertions for required fields, types, content type, and documented error bodies; avoid comparing every volatile field to one snapshot. The API contract testing guide is a useful next exercise.

Expand one risk at a time. Authentication needs role and ownership cases; idempotent writes need duplicate request checks; pagination needs continuity checks; and a real database needs cleanup that survives partial failures. For interview preparation, practice explaining why a response assertion and a state assertion answer different questions. You can use the API testing interview questions to rehearse that explanation.

Interview Questions and Answers

Q: Why use a fixture for the HTTP server?

It provides a fresh dataset and port for each test, then guarantees teardown through yield. That makes test order irrelevant. For an external API, I would keep the fixture interface and replace local startup with controlled test data creation and cleanup.

Q: What does requests.Session add here?

It holds the API key header for tests that need authorized writes and manages connection pooling. A session also gives a single place to configure repeated client behavior. I still specify a timeout on each call because the session does not automatically set one.

Q: Why test a GET after POST?

A 201 shows the endpoint acknowledged creation, but it does not prove the resource can be retrieved. Readback checks a separate observable behavior and catches an API that reports success without persisting usable state. I would also verify downstream side effects where the product contract requires them.

Q: When should a test call raise_for_status()?

Use it when a successful response is expected and an exception is useful to the client flow. For a negative contract test, assert the exact expected 4xx response and parse its error body first. Otherwise the exception can prevent the test from checking the details it was written to verify.

Q: Why pass a timeout to every request?

Without one, Requests can wait indefinitely for network activity. A connection/read timeout pair bounds two different waits, although it is not an end-to-end deadline. I choose values from service behavior and investigate timeouts instead of blindly raising them.

Q: How does parametrization improve this suite?

It runs the same contract check against several invalid bodies and boundary values while reporting each case separately. The cases are still intentionally selected: missing title, whitespace title, wrong author type, and wrong top-level shape challenge different validation paths. I would split a parameter set if expected outcomes diverged.

The model answers above focus on the reason behind each assertion. When discussing a production suite, explain data isolation, contract ownership, and which failures are retryable rather than listing pytest features alone.

Common Mistakes

  • Asserting only status_code == 200 or 201 while ignoring the response shape and durable state.
  • Using one shared record across tests, which creates order dependence and confusing reruns.
  • Calling .json() on every response, including an intentionally empty 204.
  • Testing invalid input through an unauthenticated client and accidentally testing the authentication branch instead.
  • Hard-coding a generated ID in a shared environment where concurrent runs can change it.
  • Treating a timeout as a failed HTTP response with a status code.
  • Adding automatic retries to every failing assertion, which can conceal a real state defect.

Review the contract and the test data boundary before adding more endpoints. When a failure appears, preserve the request, expected response, actual response, and follow-up state in the report without logging secrets.

Conclusion

API Testing with pytest and requests becomes reliable when tests check the whole HTTP exchange and the state it changes. You now have a local service, isolated fixture, positive and negative cases, query boundaries, deletion checks, and a CI-readable test command.

Run the complete suite, change one server rule deliberately, and confirm that the relevant test fails for the right reason. Then map the same pattern to one endpoint in your actual API: define its contract, choose an isolated data strategy, send requests with explicit timeouts, and assert both the response and the behavior that follows.

Interview Questions and Answers

How would you structure a pytest and Requests API suite?

I keep HTTP client setup and test data lifecycle in fixtures, and group tests by endpoint behavior or risk. Each test asserts the documented status, relevant headers and body fields, and observable state after the request. I avoid shared mutable records so tests can run in any order.

What is the advantage of a function-scoped server fixture?

It gives every test a new store and a port that does not conflict with other local processes. The `yield` teardown closes the client and server even after an assertion failure. The cost is startup for each case, which is acceptable for this small example.

How do you verify a POST beyond its status code?

I assert the returned representation, content type, and resource location, then fetch the new resource. For a production service I also check any documented durable or downstream effect. A 201 alone can hide a write that was not usable afterward.

What is the difference between an HTTP 404 and a connection error in Requests?

A 404 means the server completed an HTTP exchange and returned a response that I can inspect. A connection error means the client could not complete that exchange, so no response status may exist. They lead to different diagnostics and retry decisions.

When would you call `raise_for_status()` in a test?

I use it when unexpected non-success responses should stop a positive client workflow. For a test whose expected result is 400 or 404, I assert that status and the error contract directly. Calling it first would interrupt those assertions with an `HTTPError`.

Why parameterize negative API inputs?

Parametrization runs a common oracle against selected boundary and invalid-shape cases while keeping separate test results. I choose cases for distinct risks rather than generating random malformed values. If cases have different expected semantics, I give them separate tests or explicit expected parameters.

How would you avoid test order dependence against a shared staging API?

I would create uniquely named resources for each test run, keep returned IDs in the test, and clean up with the API in a fixture finalizer. I would avoid assuming the next numeric ID or collection count. If cleanup is unreliable, I would use a dedicated namespace or disposable environment.

Frequently Asked Questions

Can pytest test an API without a browser?

Yes. Requests sends HTTP calls directly, and pytest runs the assertions and fixtures. This tutorial starts a local HTTP service so the suite needs neither a browser nor an external account.

How do I install pytest and Requests in the same Python environment?

Activate a virtual environment, then run `python -m pip install pytest requests`. Invoke tests with `python -m pytest` so installation and execution use the same interpreter. Print each installed version when diagnosing environment differences.

Should I use `requests.get` or a `requests.Session` in API tests?

A session is helpful when several calls share headers or authentication and when you want connection pooling. One-off calls with `requests.get` are fine for isolated requests. Close a session in fixture teardown and pass explicit timeouts either way.

Why do I get a JSON decoding error after DELETE?

A 204 response has no body, so `.json()` has nothing to decode. Assert the 204 status and empty content, then issue a separate GET to verify that the resource is gone.

How can I test validation errors with pytest?

Select inputs that exercise different validation rules and pass them through `@pytest.mark.parametrize`. Assert the documented error status and body for each input, then verify no unintended state change. Use an authenticated client when validation happens after authentication.

Does a Requests timeout limit the entire API call?

No. A two-value timeout sets separate connection and read waits; it is not a total download deadline. Use explicit timeouts to avoid indefinite waits, and add a higher-level deadline if your workflow needs one.

How do I run these API tests in CI?

Install the project's approved Python, pytest, and Requests versions, then run `python -m pytest -q --junitxml=report.xml`. Configure the CI system to collect that XML report. Use isolated data or an ephemeral service so parallel jobs do not affect one another.

Related Guides