REST API Test Design Complete Guide | pytest × requests — Happy Path · Error Cases · Auth Explained

test-automation

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 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.

DimensionAPI TestingE2E (Selenium) Testing
Speed⚡ Seconds🐢 Minutes
Stability✅ High — no UI dependency⚠️ Breaks when UI changes
EnvironmentNo browser neededBrowser required
What it coversBusiness logic, data integrityFull user interaction flows
CI/CD fit✅ Excellent⚠️ Requires headless setup
💡 Test pyramid principle: The ideal structure is “Unit tests (many) → API tests (middle) → E2E tests (few).” A strong API test layer gives you fast, stable coverage at the heart of the pyramid.

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 PathValid requests return the expected response
Error CasesInvalid requests return appropriate error responses
Auth & AuthorizationProtected endpoints enforce access control correctly
Boundary & Edge CasesExtreme values, empty inputs, and wrong types are handled safely

STEP 1: Environment Setup — pytest × requests

pip install requests pytest pytest-html
💡 pytest-html: Generates HTML test reports. Run pytest --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.ini

conftest.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 here
💡 Session tip: requests.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 data

POST 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
💡 Happy path checklist:

  • 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 == 401

STEP 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.

💡 PUT vs PATCH (REST principles): Per REST spec, PUT is idempotent — the same request sent multiple times always produces the same result. PATCH is not required to be idempotent per RFC, though many real APIs implement it as idempotent in practice. Always verify against your API spec.
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 == 404

STEP 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 == 404
💡 When to use mocks: External payment APIs, SMS gateways, email services — anything you can't safely hit from a test environment. Mock tests run without network access and never fluctuate based on external service availability, making CI pipelines significantly more stable.

STEP 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 == 404

STEP 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")
⚠️ Always set timeout: Without a timeout, an unresponsive API will hang the test indefinitely. TimeoutSession handles this automatically with timeout=(3, 10) — no need to set it per-request.

HTTP Status Code Reference for API Testing

CodeMeaningCommon use caseTest axis
200 OKSuccessGET, PUT, PATCHHappy path
201 CreatedResource createdPOSTHappy path
204 No ContentDeleted (typically)DELETEHappy path
400 Bad RequestMalformed requestFormat / param errorsError / boundary
401 UnauthorizedAuth failedMissing / invalid tokenAuth
403 ForbiddenNo permissionAccessing others' resourcesAuthorization
404 Not FoundResource absentNon-existent IDError
422 UnprocessableValidation failedMissing required field, wrong typeError / boundary
500 Server ErrorUnexpected server failureBug in server code500 in tests = bug to report
⚠️ Important: If any test — including boundary or SQL injection tests — returns 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.

AxisExample test casesExpected status
Happy pathValid parameters, expected response200 / 201 / 204
Error casesMissing required fields, wrong type, non-existent ID400 / 404 / 422
AuthNo token, invalid token, valid token, another user's resource401 / 403
BoundaryMax length, empty string, special characters, numbers at limits200 / 400 / 422
SchemaRequired fields present, correct types, correct formats200
Error safetySQL 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.

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 × requests with TimeoutSession to prevent hangs and safe_json for 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.

Copied title and URL