Python API Testing Example: Step-by-Step with Pytest

Your Python client works on your laptop, and every mocked test passes. Then an upstream endpoint changes its response shape, a deployment sends the wrong authentication header, or a proxy rejects the connection. Users get errors while your build stays green.
A useful Python API testing example needs to expose that gap, not just demonstrate assert response.status_code == 200. Start with a passing request, then separate client logic, API contracts, and real network behavior into tests you can run deliberately.
Which Test Layer Answers Which Question
Unit tests answer whether your code behaves correctly when it receives a particular response. They're the right place to test parsing, custom exceptions, and retry decisions without waiting for a server.
Integration tests answer whether your configured client can communicate with a service. They exercise the transport, authentication headers, and real payloads. Contract tests answer whether responses match an agreed interface: status codes, media types, required fields, and pagination semantics.
Test layers, tools and what each one proves
Treat these runtimes as planning estimates, not promises about an external service.
| Layer | Main tools | What it proves | Typical runtime |
|---|---|---|---|
| Unit | pytest, responses, unittest.mock | Client code parses responses and maps errors correctly | Milliseconds per test |
| Integration | pytest with httpx or requests against staging | Auth headers, TLS, timeouts and real payloads work | One to five seconds per call |
| Contract | pytest, jsonschema, header checks | Status codes, field types and pagination match the spec | Milliseconds to a few seconds |
Contract describes what you assert, not necessarily where you run it. A schema check against a recording is offline; the same check against staging also provides integration coverage.
The 99tests guide to testing coverage is useful context when deciding which functional checks belong alongside reliability checks. For property-search applications, compare those checks with the supported query shapes in the API introduction, rather than treating every search as an interchangeable GET.
Prepare the project
Use this example configuration:
- Interpreter: Python 3.11 or newer.
- Isolation: A virtual environment, so test dependencies don't leak into other projects.
- Layout: A
srcdirectory, atestsdirectory, and a requirements file.
The HTTP examples use JSONPlaceholder, a public JSON demo API, so no paid account is needed. Mocked tests emulate its responses; the security cases later are hypothetical property-search contracts, not features that this demo implements.
Practical rule: Keep most coverage in unit and contract tests, a few integration checks for the real network, and one or two live smoke tests against production-like data.
How to Build Your First Passing Endpoint Test
Aim for a green run within about ten minutes in this walkthrough. Don't build a generic framework before you've verified a single resource.
The setup follows this short sequence.

Create the client and tests
- Create the project and activate a virtual environment. Set
DEMO_API_BASE_URLto JSONPlaceholder's public base address in your shell; the code reads that configuration instead of embedding an endpoint.
mkdir api-tests
cd api-tests
mkdir src tests
touch src/__init__.py
python3 -m venv .venv
source .venv/bin/activate
python -m pip install pytest requests httpx
python -m pip freeze > requirements.txt
On Windows PowerShell, activate with .venv\Scripts\Activate.ps1.
- Put the reusable client in
tests/conftest.py. The pytest fixture reference documents how fixtures supply shared context by argument name and howconftest.pymakes them available throughout a test directory.
# tests/conftest.py
import os
import pytest
import requests
BASE_URL = os.environ["DEMO_API_BASE_URL"].rstrip("/")
class DemoClient:
def __init__(self):
self.session = requests.Session()
def get(self, path, **kwargs):
kwargs.setdefault("timeout", 10.0)
return self.session.get(f"{BASE_URL}{path}", **kwargs)
@pytest.fixture
def client():
instance = DemoClient()
yield instance
instance.session.close()
- Write one test for retrieving a post and another for a missing resource. Multiple assertions can establish one behavior; “one behavior” doesn't mean “one assertion.”
# tests/test_endpoint.py
def test_get_post(client):
response = client.get("/posts/1")
assert response.status_code == 200
body = response.json()
assert body["id"] == 1
assert isinstance(body["title"], str)
assert body["title"].strip()
def test_missing_post(client):
response = client.get("/posts/999999")
assert response.status_code == 404
- Run
pytest -v, temporarily change the expected ID, and rerun. Pytest reports the test name and an assertion comparison; restore the assertion afterward, and usepytest -qto see compact dot progress.
pytest -v
pytest -q
Compare requests and httpx
Here's the same GET beside its requests equivalent, using the configured constant:
import httpx
import requests
from conftest import BASE_URL
response = requests.get(f"{BASE_URL}/posts/1", timeout=10.0)
with httpx.Client(base_url=BASE_URL, timeout=10.0) as client:
response = client.get("/posts/1")
Use requests for simple synchronous scripts. Choose httpx when the client family must also support async code or HTTP/2; async execution uses httpx.AsyncClient, not httpx.Client.
The common slip is forgetting the timeout. Also remember that a requests timeout limits connection and read waits, not the total duration of every possible operation.
Your project should now look like this:
api-tests/
├── requirements.txt
├── src/
│ └── __init__.py
└── tests/
├── conftest.py
└── test_endpoint.py
A clean compact summary includes 2 passed, followed by elapsed time. For a real property endpoint, the interactive API Playground lets you inspect a response before deciding which fields your test should require.
Unit Tests With Mocked HTTP Calls
Mocks earn their place when you're testing transformations and error mapping. They're especially useful for branches that would otherwise require waiting for an outage or deliberately exhausting a service quota.
Install the HTTP double and the later contract tools:
python -m pip install responses vcrpy jsonschema
python -m pip freeze > requirements.txt
Wrap the request you actually use
This small listing adapter uses demo posts as stand-ins for listing records. Its exception preserves the status without dumping a potentially sensitive response body.
# src/listings.py
import requests
class ListingError(RuntimeError):
def __init__(self, status):
self.status = status
super().__init__(f"Listing request failed with status {status}")
def fetch_listings(base_url, user_id):
response = requests.get(
f"{base_url}/posts",
params={"userId": user_id},
timeout=10.0,
)
if response.status_code != 200:
raise ListingError(response.status_code)
return response.json()
Register canned success and error replies, then verify both the result and the request:
# tests/test_listings_unit.py
from urllib.parse import parse_qs, urlsplit
import pytest
import responses
from conftest import BASE_URL
from src.listings import ListingError, fetch_listings
@pytest.mark.parametrize("status", [200, 404, 500])
@responses.activate
def test_fetch_listings(status):
payload = [{"id": 1, "title": "Demo listing"}] if status == 200 else {}
responses.add(
responses.GET,
f"{BASE_URL}/posts",
json=payload,
status=status,
)
if status == 200:
assert fetch_listings(BASE_URL, 1) == payload
else:
with pytest.raises(ListingError) as caught:
fetch_listings(BASE_URL, 1)
assert caught.value.status == status
assert len(responses.calls) == 1
query = parse_qs(urlsplit(responses.calls[0].request.url).query)
assert query == {"userId": ["1"]}
A request-count assertion catches accidental duplicate calls. Query assertions catch a renamed or dropped filter that canned response data alone would miss.
Patch at the lookup location
The Python unittest.mock documentation explains that patch() replaces an object temporarily and must target the location where the tested code looks it up; autospec=True checks the callable's signature.
For this module, the explicit target is:
from unittest.mock import patch
with patch("src.listings.requests.get", autospec=True) as mocked_get:
# Configure mocked_get.return_value before calling fetch_listings.
pass
If you instead imported get directly into src.listings, you'd patch src.listings.get. Patching an unrelated name won't intercept the request.
A mock proves that your client matches your assumptions. It doesn't prove that the provider still shares those assumptions.
Integration and Contract Tests Against Real Endpoints
A mocked request can pass while a provider changes its response shape or rejects authentication in production. Use recorded responses to keep routine tests offline, a schema check to catch contract drift, and one or two scheduled live checks to verify the endpoint.
VCR.py saves a real request and response in a cassette, then replays that interaction on later runs. Filter credentials before recording, and inspect cassettes for secrets in query strings, cookies, and response bodies.

Record, then enforce the contract
Create tests/recording.py and an empty tests/__init__.py:
# tests/recording.py
import os
import vcr
recorder = vcr.VCR(
cassette_library_dir="tests/cassettes",
record_mode=os.getenv("VCR_RECORD_MODE", "none"),
filter_headers=["authorization"],
match_on=["method", "scheme", "host", "port", "path", "query"],
)
Use a separate cassette for each endpoint case:
from tests.recording import recorder
@recorder.use_cassette("post.yaml")
def test_get_post(client):
response = client.get("/posts/1")
assert response.status_code == 200
assert response.json()["id"] == 1
Keep the title assertion from the original test. Give the missing-resource case its own missing-post.yaml cassette, then record both interactions explicitly:
VCR_RECORD_MODE=once pytest tests/test_endpoint.py -v
Review the saved files before committing. Header filtering protects the authorization header, not secrets stored elsewhere in the request or response.
A cassette checks behavior against a captured response. Add a schema assertion as a separate contract check:
# tests/test_contract.py
from jsonschema import validate
from tests.recording import recorder
POST_SCHEMA = {
"type": "object",
"required": ["id", "title"],
"properties": {
"id": {"type": "integer"},
"title": {"type": "string", "minLength": 1},
},
}
@recorder.use_cassette("post-contract.yaml")
def test_post_contract(client):
response = client.get("/posts/1")
assert response.status_code == 200
media_type = response.headers["Content-Type"].split(";")[0].strip()
assert media_type == "application/json"
validate(response.json(), POST_SCHEMA)
For property collections, check that following a next-page link preserves filters and advances the page. Use the OpenAPI integration documentation to choose contractual fields, rather than requiring every incidental field in a sample payload.
The video provides another walkthrough of Python API testing:
Keep a narrow live path
# tests/test_live.py
import os
import pytest
@pytest.mark.live
@pytest.mark.skipif(
not os.getenv("LIVE_API_KEY"), reason="Live checks disabled"
)
def test_live_post(client):
response = client.get(
"/posts/1",
headers={"Authorization": f"Bearer {os.environ['LIVE_API_KEY']}"},
)
assert response.status_code == 200
assert response.json()["id"] == 1
The demo endpoint does not validate the credential, so point this check at an authenticated staging endpoint. The QAJobFit testing guidance distinguishes mocked behavior from real integration coverage. Run one or two live tests on a schedule, and re-record cassettes when the contract changes. Replay cannot expose a new proxy, encoding, or authentication failure.
Testing Auth Failures, Rate Limits and Bad Input
Beginner suites often stop at the happy path. Production clients also need predictable behavior when access is denied, inputs are invalid, or the server can't complete the request.
Use this table as a proposed contract for an authenticated property-search API. Confirm its statuses and error shape with the provider; they aren't guarantees about every endpoint.
Failure cases every endpoint test should cover
| Case | Expected status | What the test asserts | Retry behavior |
|---|---|---|---|
| Missing or expired API key | 401 | Stable error code, key not echoed back | No retry |
| Valid key without permission | 403 | Access denied message, no data returned | No retry |
| Malformed input or unknown identifier | 400 or 404 | Error format and offending field named | No retry |
| Injection-style input in query, filter or sort | 400 | Rejected cleanly, no server error or echoed payload | No retry |
| Too many requests | 429 | Retry-After header read and respected | Wait, then retry up to three attempts |
| Server error | 5xx | Clear error after retries are exhausted | Exponential backoff, max three attempts |
| Timeout | No response | Client raises its own timeout error within the limit | Retry only idempotent calls |
Make each bad input visible
The pytest parametrization guide documents how one function runs against separate argument sets, with each case reported independently. Fixture parametrization is useful when the same flow must run with different tokens or environments.
For a hypothetical search contract, the case list could be:
BAD_SEARCH_CASES = [
pytest.param(
{"coordinates": "not-coordinates"}, 400, id="invalid-coordinate"
),
pytest.param(
{"place_id": "unknown-place"}, 404, id="unknown-identifier"
),
pytest.param(
{"filter": "unsupported-filter"}, 400, id="unsupported-filter"
),
]
Feed this list to @pytest.mark.parametrize("params, expected", BAD_SEARCH_CASES) in your provider-specific search test. Assert the status and stable error code, then check both the serialized response and captured logs for credential leakage.
Don't send these invented search parameters to the post demo and mistake its permissive response for validation coverage.
Bound retries deliberately
For this example policy, retry only eligible 429 and 5xx responses, with exponential backoff and a cap of three total attempts. Honor Retry-After when present, including its date form; if the delay exceeds your operation budget, fail or defer rather than retrying early.
Timeouts need separate transport tests using a raised requests.exceptions.Timeout. Assert that the client exposes a useful timeout error, and use a wall-clock deadline if the application needs a strict total-operation limit.
- Write safety: Retry webhook-triggering POSTs only with a server-supported idempotency key, reused across attempts.
- Error hygiene: Return a stable application error, not a raw response dump containing credentials.
- Provider policy: Consult the rate-limit documentation before setting throttling expectations.
For media-type failures, request troubleshooting with Server Scheduler provides useful context for distinguishing a malformed request from a transient server problem. Resending the same incorrectly encoded body won't fix it.
Running API Tests in CI Without Flaky Builds
A reliable pipeline makes recording an explicit maintenance task, not something pytest does unexpectedly during a pull request. Commit reviewed cassettes and install fixed dependency versions from requirements.lock, created from a clean environment.
This checklist shows the basic job sequence.

Separate push checks from scheduled checks
Register the marker and make ordinary runs exclude live requests:
# pytest.ini
[pytest]
addopts = -m "not live"
markers =
live: exercises a real remote endpoint
Use the following job skeleton in .github/workflows/api-tests.yml. Store the demo base address as a repository variable, not as an invented endpoint in workflow code.
name: API tests
on:
push:
pull_request:
schedule:
- cron: "0 3 * * *"
jobs:
offline:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
DEMO_API_BASE_URL: ${{ vars.DEMO_API_BASE_URL }}
VCR_RECORD_MODE: none
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: requirements.lock
- run: python -m pip install -r requirements.lock
- run: pytest -m "not live" --junitxml=reports/offline.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: offline-results
path: reports/offline.xml
Add a separate live job restricted by if: github.event_name == 'schedule'. Give only that job LIVE_API_KEY: ${{ secrets.LIVE_API_KEY }}, repeat the setup steps, and run:
pytest -m live --junitxml=reports/live.xml
Upload its report even on failure. The example job timeout is ten minutes, providing a final bound if test teardown or networking hangs.
Make failures actionable
- Lock dependencies: Copy the reviewed requirements snapshot to
requirements.lock; the cache key controls cache reuse, not dependency resolution. - Reject unexpected recording: Keep
VCR_RECORD_MODE=nonein CI so unmatched requests fail instead of contacting the service. - Assign ownership: Each failing suite needs a named maintainer who can distinguish client defects from provider changes.
A recording's age alone won't cause VCR.py to fail. Track refresh dates and compare freshly recorded responses with the current contract during scheduled maintenance.
Don't make a red build green by deleting an inconvenient cassette. Establish whether the provider changed or your request stopped matching.
Allow a limited rerun policy only for the live job, retaining the original failure report. Repeated network failures belong in a quarantined, owned suite, not behind unlimited retries.
Start With One Endpoint and Grow the Suite
Choose the endpoint behind the last production incident, or the one your team edits most often. That gives the suite a concrete responsibility instead of turning it into a collection of easy assertions.
Build three complementary checks for that endpoint. A mocked success test verifies parsed listing fields and the request count; a contract test checks the response status, media type, and schema; a LIVE_API_KEY-gated smoke test makes one real request.
Finish the endpoint before expanding
Add its denied-access, invalid-input, throttling, server-error, and timeout cases before starting another endpoint. Use the shared fixture so the next test introduces a path constant, a case list, and endpoint-specific assertions rather than another client implementation.
For a real listing schema, require the fields your application consumes. Accept optional additions unless the contract forbids them, and don't hard-code changing prices, availability, or result ordering into a smoke test.
Before calling the suite ready, check these five items:
- Offline default: Ordinary test runs cannot reach the network.
- Access coverage: Every authenticated endpoint has a denied-access case.
- Bounded clients: Every client request has an explicit timeout.
- Safe retries: Retry decisions respect operation semantics and idempotency support.
- Scheduled verification: The live suite runs without someone remembering to launch it.
A recorded response alone cannot catch a change on the day it ships. Arrange a fresh contract check after provider releases or deployments, and use scheduled checks as a fallback.
Start by adding the mocked success test for your chosen endpoint today. Then give the same endpoint a contract that can reject a real upstream change before users have to report it.
RealtyAPI.io offers a unified real estate data API for property searches, listing details, availability, and market signals. Visit RealtyAPI.io to choose a search endpoint, inspect its Python request example, and build unit, contract, and live checks around the fields your application uses.