REST API testing has one core difficulty: knowing where to stop. What actually needs to be tested?
QA engineers new to API testing often wrestle with questions like:
- Is testing the happy path enough?
- How far do I need to go with auth and error cases?
- How do I structure my API test cases?
This article answers all of that. We cover the 4-axis REST API test design framework — happy path, error cases, authentication, and boundary values — with working pytest × requests implementation code throughout.
What you’ll learn
- ✅ The 4-axis REST API test design framework (happy path · error cases · auth · boundary values)
- ✅ pytest × requests implementation patterns with full code examples
- ✅ How to structure API test cases and a reusable template
- ✅ How to think about auth, boundary, and error testing
- ✅ Common failure patterns in production API test suites
⚠️ About the code samples
Sample URLs in this article use jsonplaceholder.typicode.com (a fake API for learning). It does not implement authentication, validation, or persistent DELETE. Treat all code as “examples designed for a real production API” — substitute your own API URL before running.
API tests are generally faster and more stable than E2E tests, making them an excellent foundation for CI/CD pipelines. Structuring your test design around the 4 axes in this article leads to fewer bugs slipping through and significantly easier test maintenance.
📌 Who this article is for
- QA engineers starting API testing who aren’t sure what to test or where to begin
- Engineers who want to implement API tests with pytest × requests
- Anyone who wants to cover not just happy paths but auth, error cases, and boundary values
- Engineers working through the test automation roadmap at STEP 4 (API testing)
✅ What you’ll take away
- A clear picture of the 4-axis REST API test design framework
- Ready-to-use implementation patterns with pytest × requests
- A practical mindset for designing API test cases without gaps
👤 About the author
Written by Yoshi, a QA and test automation engineer with 15+ years of production experience. The patterns in this article are used daily in real projects and have been standardized across teams. Code is publicly available on GitHub: github.com/YOSHITSUGU728/automated-testing-portfolio
- REST API Testing vs E2E Testing — What’s the Difference?
- REST API Test Design — The 4-Axis Framework
- STEP 1: Environment Setup — pytest × requests
- STEP 2: Happy Path Test Design — Validating by HTTP Method
- STEP 3: Error Case Test Design — Covering Error Patterns
- STEP 4: Auth & Authorization Test Design
- STEP 5: Boundary Value & Edge Case Test Design
- STEP 6: Schema Validation — Type and Format Verification
- STEP 7: PUT/PATCH Test Design
- STEP 8: Mock Tests — Removing External API Dependencies
- STEP 9: DELETE Test Design
- STEP 10: Network Exception & Timeout Tests
- HTTP Status Code Reference for API Testing
- ⚠️ 5 Common API Test Mistakes
- FAQ
- 📋 Summary
REST API Testing vs E2E Testing — What’s the Difference?
REST API testing sends HTTP requests directly to API endpoints and validates that responses match expectations — no browser required. This makes it significantly faster than E2E testing.
| Dimension | API Testing | E2E (Selenium) Testing |
|---|---|---|
| Speed | ⚡ Seconds | 🐢 Minutes |
| Stability | ✅ High — no UI dependency | ⚠️ Breaks when UI changes |
| Environment | No browser needed | Browser required |
| What it covers | Business logic, data integrity | Full user interaction flows |
| CI/CD fit | ✅ Excellent | ⚠️ Requires headless setup |
REST API Test Design — The 4-Axis Framework
Structured API test design uses four axes. Cover all four and you get comprehensive, gap-free test coverage.
🎯 The 4 Axes of REST API Test Design
| ① | Happy Path | Valid requests return the expected response |
| ② | Error Cases | Invalid requests return appropriate error responses |
| ③ | Auth & Authorization | Protected endpoints enforce access control correctly |
| ④ | Boundary & Edge Cases | Extreme values, empty inputs, and wrong types are handled safely |
STEP 1: Environment Setup — pytest × requests
pip install requests pytest pytest-htmlpytest --html=report.html to produce a visual report — useful for sharing results in CI/CD.Project structure:
api-test-project/
├── tests/
│ ├── conftest.py # base_url, auth tokens, shared fixtures
│ ├── utils.py # safe_json helper
│ ├── test_users.py # user API tests
│ ├── test_posts.py # post API tests
│ └── test_auth.py # auth API tests
├── requirements.txt
└── pytest.iniconftest.py
# tests/conftest.py
import pytest
import requests
@pytest.fixture(scope="session")
def base_url():
"""Base URL as a fixture — easy to swap per environment."""
# ※ Sample URL. Replace with your actual API.
return "https://jsonplaceholder.typicode.com"
@pytest.fixture(scope="session")
def auth_headers():
"""Auth headers as a fixture."""
return {
"Authorization": "Bearer YOUR_TOKEN_HERE",
"Content-Type": "application/json"
}
class TimeoutSession(requests.Session):
"""Session with a default timeout.
requests.Session has no built-in default timeout — this wrapper adds one.
"""
def request(self, *args, **kwargs):
# connect timeout=3s / read timeout=10s
# Pass timeout=None to override per-request
kwargs.setdefault("timeout", (3, 10))
return super().request(*args, **kwargs)
@pytest.fixture(scope="session")
def api_session():
"""Shared Session fixture with default timeout.
Using Session reuses HTTP connections for better performance.
"""
session = TimeoutSession()
session.headers.update({"Content-Type": "application/json"})
yield session
session.close()tests/utils.py — safe_json helper
💡 safe_json and raise_for_status() — when to use which
Separate safe_json into tests/utils.py so it’s reusable across all test files.
# tests/utils.py
import pytest
def safe_json(response):
"""Safely parse JSON. Fails test with a clear message on parse error."""
try:
return response.json()
except ValueError:
# JSONDecodeError is a subclass of ValueError — one except covers both
pytest.fail("Response is not valid JSON")
# Import in test files:
from tests.utils import safe_json
# raise_for_status() raises an exception on 4xx/5xx responses.
# Use it in happy path tests to fail fast on unexpected errors.
# Do NOT use it in error case tests — you want to assert the status code directly.
# Happy path (raise_for_status is fine here):
response = api_session.get(f"{base_url}/users/1")
response.raise_for_status()
data = safe_json(response)
# Error case test (do NOT use raise_for_status):
response = api_session.get(f"{base_url}/users/99999")
assert response.status_code == 404 # raise_for_status() would throw hererequests.Session() reuses HTTP connections. On suites of 100+ API tests the speed difference becomes significant, which is why many teams make Session the default.STEP 2: Happy Path Test Design — Validating by HTTP Method
Happy path tests verify both the expected status code and the response body. Both matter.
GET requests
# tests/test_users.py
import pytest
import requests
from tests.utils import safe_json
class TestGetUser:
def test_get_user_returns_200(self, base_url, api_session):
"""Happy path: GET user returns 200."""
# TimeoutSession applies timeout=(3, 10) automatically — no need to specify
response = api_session.get(f"{base_url}/users/1")
assert response.status_code == 200
def test_get_user_returns_expected_fields(self, base_url, api_session):
"""Happy path: Response includes required fields."""
response = api_session.get(f"{base_url}/users/1")
data = safe_json(response)
assert "id" in data
assert "name" in data
assert "email" in data
def test_get_user_id_matches_request(self, base_url, api_session):
"""Happy path: Returned user ID matches the requested ID."""
user_id = 1
response = api_session.get(f"{base_url}/users/{user_id}")
data = safe_json(response)
assert data["id"] == user_id
def test_get_users_list_returns_array(self, base_url, api_session):
"""Happy path: User list is returned as an array."""
response = api_session.get(f"{base_url}/users")
data = safe_json(response)
assert isinstance(data, list)
assert len(data) > 0
@pytest.mark.performance # Isolate from CI — register in pytest.ini: markers = performance: ...
def test_response_time_is_acceptable(self, base_url, api_session):
"""Happy path: Response arrives within 2 seconds."""
response = api_session.get(f"{base_url}/users/1")
# response.elapsed covers the full HTTP round-trip:
# DNS resolution, TCP connection, TLS handshake, server response time
# It is NOT a measure of application processing time alone.
# network jitter makes this flaky in CI — run as "pytest -m 'not performance'" to exclude
assert response.elapsed.total_seconds() < 2.0
def test_response_is_valid_json(self, base_url, api_session):
"""Happy path: Response is valid JSON."""
response = api_session.get(f"{base_url}/users/1")
# If an HTML error page is returned, response.json() throws JSONDecodeError.
# Check Content-Type first for a clearer failure message.
# RFC 7807 error APIs may return application/problem+json
content_type = response.headers.get("Content-Type", "")
assert (
"application/json" in content_type
or "application/problem+json" in content_type
)
data = safe_json(response)
assert "id" in dataPOST requests
class TestCreatePost:
def test_create_post_returns_201(self, base_url, api_session):
"""Happy path: POST returns 201 Created."""
payload = {"title": "Test Post", "body": "Test body", "userId": 1}
response = api_session.post(f"{base_url}/posts", json=payload)
assert response.status_code == 201
def test_create_post_returns_created_data(self, base_url, api_session):
"""Happy path: Response includes the created post data."""
payload = {"title": "Title", "body": "Body", "userId": 1}
response = api_session.post(f"{base_url}/posts", json=payload)
data = safe_json(response)
assert data["title"] == payload["title"]
assert data["body"] == payload["body"]
assert "id" in data # New ID assigned by the server- Status code: GET=200, POST=201. PUT/PATCH return 200 or 204; DELETE is typically 204 — but verify against your API spec.
- Required fields present in response body
- Data matches what was sent in the request
- Response time (when an SLA is defined)
STEP 3: Error Case Test Design — Covering Error Patterns
Error case tests verify that invalid requests return appropriate error responses. pytest's parametrize makes covering multiple patterns efficient.
class TestErrorHandling:
def test_get_nonexistent_user_returns_404(self, base_url, api_session):
"""Error case: Non-existent user ID returns 404."""
response = api_session.get(f"{base_url}/users/99999")
assert response.status_code == 404
@pytest.mark.parametrize("invalid_id", [
"abc", # string
"!@#", # symbols
"-1", # negative
"0", # zero
"9" * 20, # extremely long number
])
def test_get_user_with_invalid_id(self, base_url, api_session, invalid_id):
"""Error case: Invalid IDs return 400 or 404."""
response = api_session.get(f"{base_url}/users/{invalid_id}")
# The specific code (400 vs 404) depends on your API spec.
# Ideally, pin to one value based on the spec. Multiple-allowed is a stopgap.
assert response.status_code in [400, 404]
@pytest.mark.parametrize("missing_field, payload", [
("title", {"body": "Body only", "userId": 1}),
("body", {"title": "Title only", "userId": 1}),
("userId", {"title": "Title", "body": "Body"}),
])
def test_create_post_with_missing_required_field(
self, base_url, api_session, missing_field, payload
):
"""Error case: Missing required field returns 400 or 422."""
response = api_session.post(f"{base_url}/posts", json=payload)
assert response.status_code in [400, 422]STEP 4: Auth & Authorization Test Design
Auth tests verify both that valid tokens grant access and that missing or invalid tokens are properly rejected.
# tests/test_auth.py
import pytest
import requests
class TestAuthentication:
"""Auth and authorization tests.
※ Endpoints like /protected/users are examples for a real production API.
jsonplaceholder.typicode.com does not implement them.
Replace with your actual authenticated API before running.
"""
def test_protected_endpoint_without_token_returns_401(
self, base_url, api_session
):
"""No token: 401 Unauthorized."""
response = api_session.get(f"{base_url}/protected/users")
assert response.status_code == 401
def test_protected_endpoint_with_invalid_token_returns_401(
self, base_url, api_session
):
"""Invalid token: 401 Unauthorized."""
headers = {"Authorization": "Bearer INVALID_TOKEN"}
response = api_session.get(f"{base_url}/protected/users", headers=headers)
assert response.status_code == 401
def test_protected_endpoint_with_valid_token_returns_200(
self, base_url, api_session, auth_headers
):
"""Valid token: 200 OK."""
response = api_session.get(
f"{base_url}/protected/users", headers=auth_headers
)
assert response.status_code == 200
def test_user_cannot_access_other_users_data(
self, base_url, api_session, auth_headers
):
"""Authorization: Access to another user's private data returns 403."""
response = api_session.get(
f"{base_url}/users/999/private", headers=auth_headers
)
assert response.status_code == 403
@pytest.mark.parametrize("expired_token", [
"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.expired",
"Bearer ", # empty token
"InvalidFormat", # missing Bearer prefix
])
def test_invalid_token_formats_return_401(
self, base_url, api_session, expired_token
):
"""Various invalid token formats return 401."""
headers = {"Authorization": expired_token}
response = api_session.get(f"{base_url}/protected/users", headers=headers)
assert response.status_code == 401STEP 5: Boundary Value & Edge Case Test Design
Boundary value tests verify API stability under extreme inputs — empty values, maximum lengths, wrong types, and security payloads.
BOUNDARY_TITLES = [
("empty_string", "", [400, 422]),
("single_char", "A", [200, 201]),
("max_length", "A" * 255, [200, 201]),
("over_max_length", "A" * 256, [400, 422]),
("special_chars", "!@#$%^&*()", [200, 400]),
("emoji", "📝 Test Post 🚀", [200, 201]),
# SQL injection test — goal: verify safe handling, not rejection
# ✅ No 500 error ✅ No DB error details exposed ✅ No auth bypass
# ⚠️ Never run against production or third-party APIs. Test env only.
# 💡 Sanitizing APIs may return 200 — that's valid. What matters is no 500.
# Reference: OWASP Testing Guide - Testing for SQL Injection
("sql_injection", "' OR '1'='1", [200, 400, 422]),
# XSS test — verify the script tag is handled safely (no 500, no execution)
("xss_script", "<script>alert('xss')</script>", [200, 400, 422]),
]
class TestBoundaryValues:
@pytest.mark.parametrize("case_name, title, expected_statuses", BOUNDARY_TITLES)
def test_post_title_boundary(
self, base_url, api_session, case_name, title, expected_statuses
):
"""Boundary value tests for the title field."""
payload = {"title": title, "body": "Body", "userId": 1}
response = api_session.post(f"{base_url}/posts", json=payload)
assert response.status_code in expected_statuses, \
f"Case: {case_name}, got {response.status_code}"
def test_pagination_first_page(self, base_url, api_session):
"""Pagination: First page returns at most the requested count."""
response = api_session.get(f"{base_url}/posts?_page=1&_limit=10")
data = safe_json(response)
assert response.status_code == 200
assert len(data) <= 10
def test_pagination_beyond_last_page_returns_empty(self, base_url, api_session):
"""Pagination: Page beyond end returns empty list (not 404)."""
response = api_session.get(f"{base_url}/posts?_page=99999&_limit=10")
assert response.status_code == 200
assert safe_json(response) == []STEP 6: Schema Validation — Type and Format Verification
Schema validation systematically confirms field types and required fields across responses.
# pip install jsonschema
from jsonschema import validate, ValidationError, FormatChecker
import pytest
# 💡 Note: format validators (email, uri, etc.) are only enforced
# when FormatChecker() is passed — omitting it silently skips format checks.
USER_SCHEMA = {
"type": "object",
"required": ["id", "name", "email", "username"],
"additionalProperties": False, # Reject undeclared fields (strict mode — use with caution)
# ⚠️ If the API adds a new field (even a backward-compatible one), this will break.
# You'll need to update the schema whenever the API evolves.
# Skip this for beginners. Relevant in Consumer-Driven Contract Testing (Pact, etc.)
"properties": {
"id": {"type": "integer"},
"name": {"type": "string", "minLength": 1},
"email": {"type": "string", "format": "email"},
"username": {"type": "string", "minLength": 1},
}
}
POST_SCHEMA = {
"type": "object",
"required": ["id", "title", "body", "userId"],
"properties": {
"id": {"type": "integer"},
"title": {"type": "string"},
"body": {"type": "string"},
"userId": {"type": "integer"},
}
}
class TestResponseSchema:
def test_user_response_schema(self, base_url, api_session):
"""Schema: User response matches expected schema."""
response = api_session.get(f"{base_url}/users/1")
data = safe_json(response)
try:
validate(
instance=data,
schema=USER_SCHEMA,
format_checker=FormatChecker()
)
except ValidationError as e:
pytest.fail(f"Schema validation failed: {e.message}")
def test_post_list_schema(self, base_url, api_session):
"""Schema: First 5 posts in the list match expected schema."""
response = api_session.get(f"{base_url}/posts")
posts = safe_json(response)
for post in posts[:5]:
try:
validate(
instance=post,
schema=POST_SCHEMA,
format_checker=FormatChecker()
)
except ValidationError as e:
pytest.fail(f"Post ID {post.get('id')}: schema failed — {e.message}")STEP 7: PUT/PATCH Test Design
Update tests verify that changes are actually reflected in the response. Always check the updated value, not just the status code.
class TestUpdatePost:
def test_patch_post_returns_200(self, base_url, api_session):
"""Happy path: Partial update returns 200."""
payload = {"title": "Updated Title"}
response = api_session.patch(f"{base_url}/posts/1", json=payload)
data = safe_json(response)
assert response.status_code == 200
assert data["title"] == "Updated Title"
def test_put_post_returns_200(self, base_url, api_session):
"""Happy path: Full replacement returns 200."""
payload = {
"id": 1,
"title": "Replaced Title",
"body": "Replaced body",
"userId": 1
}
response = api_session.put(f"{base_url}/posts/1", json=payload)
data = safe_json(response)
assert response.status_code == 200
assert data["title"] == payload["title"]
assert data["body"] == payload["body"]
def test_patch_nonexistent_post_returns_404(self, base_url, api_session):
"""Error case: PATCH on non-existent post returns 404."""
response = api_session.patch(
f"{base_url}/posts/99999", json={"title": "Update"}
)
assert response.status_code == 404STEP 8: Mock Tests — Removing External API Dependencies
External API calls (payments, email, SMS) can't always be hit from test environments. The responses library lets you mock HTTP calls without any real network traffic.
# pip install responses
import responses
import requests
import pytest
# responses mocks HTTP calls at the requests library level.
# Use it when testing code that calls external services you don't control.
@responses.activate
def test_external_api_mock():
"""Mock an external API call — no real network needed."""
responses.add(
responses.GET,
"https://api.example.com/users/1",
json={"id": 1, "name": "Test User"},
status=200
)
response = requests.get("https://api.example.com/users/1")
assert response.status_code == 200
assert safe_json(response)["name"] == "Test User"
assert len(responses.calls) == 1 # Verify it was called exactly once
@responses.activate
def test_api_error_response_with_mock():
"""Simulate an error response using a mock."""
responses.add(
responses.GET,
"https://api.example.com/users/999",
json={"message": "Not Found"},
status=404
)
response = requests.get("https://api.example.com/users/999")
assert response.status_code == 404STEP 9: DELETE Test Design
DELETE tests verify both that the deletion succeeds and that the deleted resource is no longer accessible.
💡 Managing test data with a fixture
@pytest.fixture
def test_post(base_url, api_session):
"""Create a test post, yield its ID, then clean it up."""
payload = {"title": "Test Post", "body": "Created by fixture", "userId": 1}
response = api_session.post(f"{base_url}/posts", json=payload)
post_id = safe_json(response)["id"]
yield post_id
# teardown — protect with try/except so cleanup failure doesn't affect test result
try:
api_session.delete(f"{base_url}/posts/{post_id}")
except Exception as e:
print(f"Cleanup failed for post_id={post_id}: {e}")class TestDeletePost:
def test_delete_post_returns_204(self, base_url, api_session):
"""Happy path: Delete returns 204."""
response = api_session.delete(f"{base_url}/posts/1")
assert response.status_code == 204
def test_delete_post_response_body_is_empty(self, base_url, api_session):
"""Happy path: 204 response has no body."""
response = api_session.delete(f"{base_url}/posts/1")
# Per RFC, 204 responses should have no body.
# Some APIs return empty JSON, empty arrays, or whitespace — verify against your spec.
assert response.status_code == 204
assert not response.content
# If your API returns something: assert response.content in [b"", b"null", b"{}"]
def test_delete_nonexistent_post_returns_404(self, base_url, api_session):
"""Error case: Deleting non-existent post returns 404."""
response = api_session.delete(f"{base_url}/posts/99999")
assert response.status_code == 404
def test_delete_without_auth_returns_401(self, base_url, api_session):
"""No auth: Delete returns 401."""
response = api_session.delete(f"{base_url}/protected/posts/1")
assert response.status_code == 401
def test_deleted_post_cannot_be_fetched(self, base_url, api_session, test_post):
"""After deletion, the resource returns 404."""
# NOTE: jsonplaceholder.typicode.com does not persist deletions.
# This sample assumes a real production API.
delete_response = api_session.delete(f"{base_url}/posts/{test_post}")
assert delete_response.status_code == 204
get_response = api_session.get(f"{base_url}/posts/{test_post}")
assert get_response.status_code == 404STEP 10: Network Exception & Timeout Tests
Testing how your code handles network failures is just as important as testing happy paths. Use unittest.mock to simulate these without real network calls.
import pytest
import requests
from unittest.mock import patch
class TestNetworkErrorHandling:
@patch.object(requests.Session, "get")
def test_timeout_exception(self, mock_get):
"""Timeout raises Timeout exception (mocked for CI stability)."""
mock_get.side_effect = requests.exceptions.Timeout
session = requests.Session()
with pytest.raises(requests.exceptions.Timeout):
session.get("https://example.com")
@patch.object(requests.Session, "get")
def test_connection_error(self, mock_get):
"""Connection failure raises ConnectionError (mocked — no DNS dependency)."""
mock_get.side_effect = requests.exceptions.ConnectionError
session = requests.Session()
with pytest.raises(requests.exceptions.ConnectionError):
session.get("https://example.com")TimeoutSession handles this automatically with timeout=(3, 10) — no need to set it per-request.HTTP Status Code Reference for API Testing
| Code | Meaning | Common use case | Test axis |
|---|---|---|---|
| 200 OK | Success | GET, PUT, PATCH | Happy path |
| 201 Created | Resource created | POST | Happy path |
| 204 No Content | Deleted (typically) | DELETE | Happy path |
| 400 Bad Request | Malformed request | Format / param errors | Error / boundary |
| 401 Unauthorized | Auth failed | Missing / invalid token | Auth |
| 403 Forbidden | No permission | Accessing others' resources | Authorization |
| 404 Not Found | Resource absent | Non-existent ID | Error |
| 422 Unprocessable | Validation failed | Missing required field, wrong type | Error / boundary |
| 500 Server Error | Unexpected server failure | Bug in server code | 500 in tests = bug to report |
500 Internal Server Error, that is a bug. File a report immediately.⚠️ 5 Common API Test Mistakes
① Only asserting the status code
A 200 status with an empty or wrong body still fails the user. Always assert on the response body too: assert data["id"] == expected_id.
② Only testing happy paths
Most bugs live in error cases — invalid input, auth failures, missing resources. Use parametrize to cover at least 5–10 error patterns per endpoint.
③ Tests sharing state across each other
"Test A creates data, test B uses it" creates hidden ordering dependencies. Each test should be fully independent. Manage test data in fixtures.
④ Forgetting the Content-Type header
requests.post(url, json=data) sets Content-Type: application/json automatically. requests.post(url, data=data) does not. If you're getting 415 Unsupported Media Type, this is likely why.
⑤ Running tests against production
API tests create, modify, and delete real data. Running against production can corrupt live data. Use the base_url fixture to manage environments, and switch via environment variables in CI/CD.
📋 API Test Case Design Template
Use these 4 axes to structure test cases and avoid gaps.
| Axis | Example test cases | Expected status |
|---|---|---|
| Happy path | Valid parameters, expected response | 200 / 201 / 204 |
| Error cases | Missing required fields, wrong type, non-existent ID | 400 / 404 / 422 |
| Auth | No token, invalid token, valid token, another user's resource | 401 / 403 |
| Boundary | Max length, empty string, special characters, numbers at limits | 200 / 400 / 422 |
| Schema | Required fields present, correct types, correct formats | 200 |
| Error safety | SQL injection, XSS payloads (test env only) | 500 = bug to report |
📋 API Test Checklist
| ✅ Status code verified (200 / 201 / 204 / 400 / 401 / 403 / 404 / 422 / 500) |
| ✅ Response body and required fields validated |
| ✅ Schema validation (field types and formats) |
| ✅ Auth test: no token / invalid token / valid token — all three patterns |
| ✅ Authorization test: 403 for accessing other users' resources |
| ✅ Response time (when an SLA is defined) |
| ✅ Boundary values and SQL injection / XSS inputs don't return 500 |
| ✅ Timeout exception handling tested |
FAQ
Q. Should I use requests or Playwright API Testing?
Start with requests — it's simpler and covers the vast majority of REST API testing needs. If you're already using Playwright for E2E tests, Playwright API Testing lets you write API tests in the same suite. Use requests first, expand to Playwright API Testing when it makes sense for your project.
Q. How should I manage test data?
Use pytest fixtures. Create test data in the fixture setup, yield it to the test, and clean it up in teardown — wrapped in try/except so cleanup failures don't affect test results. For test environments, use a dedicated test database rather than shared data.
Q. How do I handle tests that depend on an external API I don't control?
Mock it with the responses library or unittest.mock. This eliminates network dependency and makes your CI pipeline stable regardless of external service availability. Mock the response shape, not the internal implementation.
Q. Which status codes should I expect for which scenarios?
General guidance: GET=200, POST=201, PUT/PATCH=200 or 204, DELETE=204. Validation errors→422, missing required field→400, no auth→401, no permission→403, resource not found→404. A 500 from any test is a bug. The definitive answer is always your API spec or OpenAPI definition.
Q. How do I integrate API tests into CI/CD?
Run pytest tests/ in GitHub Actions. API tests need no browser, so no headless setup is required — they integrate more easily than E2E tests. Switch the base_url fixture via environment variables to target staging vs production. Exclude flaky performance tests with pytest -m "not performance".
Q. What's the difference between API testing and unit testing?
Unit tests validate individual functions or classes in isolation. API tests validate full HTTP endpoints from the outside — routing, validation, DB operations, and the response format all in one. API tests have higher integration coverage; unit tests are faster and more targeted. The test pyramid calls for both: many unit tests, a solid API test layer, and fewer E2E tests.
Q. Is Postman enough, or do I need Python tests?
Postman is excellent for manual verification and sharing API specs with a team. For running 50+ test cases automatically in CI/CD, managing fixtures, and using parametrize for error patterns, pytest + requests is significantly more powerful. A common pattern: use Postman for exploration and spec sharing, then codify the tests in pytest + requests for automation.
Q. How far should I take API test automation?
At minimum: happy path, auth (401/403), and the primary error cases (400/404) for each endpoint. Don't try to cover every possible combination — prioritize by risk. Auth, payments, and personal data endpoints warrant the most thorough coverage. UI-invisible concerns — validation, authorization, data integrity — are where API tests add the most unique value.
Q. Do I need mocks in API testing?
For third-party dependencies (payment gateways, email services, SMS providers) that can't be called from a test environment — yes. For APIs you own and control — use the real API in a test environment where possible. Over-mocking internal APIs creates a false sense of security: the mock passes but the real integration may fail.
Q. What's the difference between API testing and Contract Testing?
API testing verifies "does our API work correctly?" Contract Testing (Pact, etc.) verifies "does our API contract still match what its consumers expect?" Contract Testing is especially valuable in microservice architectures where multiple teams develop services independently. API testing is a prerequisite — Contract Testing is a layer on top for cross-team integration assurance.
📖 Related articles
- Test Automation Roadmap 2026 | Python · Selenium · pytest for QA Engineers
- Selenium × pytest Practical Guide | fixture · parametrize · conftest.py · mark
- Automate E2E Tests with GitHub Actions × Playwright
- Test Design Techniques | A QA Engineer's Practical Toolkit
- Python pytest Complete Guide | fixture · parametrize · conftest.py
The patterns in this article — the 4-axis test design framework, safe_json, TimeoutSession, fixture cleanup with try/except — are all used in production. In code reviews, auth test coverage is the first thing I check, because auth gaps tend to cause the most serious production incidents. Use the checklist in this article as a review baseline.
📋 Summary
- REST API tests are structured around 4 axes: happy path · error cases · auth · boundary values
- Use
pytest × requestswithTimeoutSessionto prevent hangs andsafe_jsonfor safe JSON parsing - parametrize makes it efficient to cover multiple error patterns with minimal code
- Schema validation with jsonschema + FormatChecker catches field type and format issues systematically
- Watch out for: status-code-only assertions, testing only happy paths, test data coupling, no timeout, running against production
Start by writing one GET test — send a request, assert the status code, and check a required field. From there, add error cases, then auth, then boundary values. The checklist in this article will tell you when you've covered enough.

