Back to All Articles
API Testing

REST API Testing — A Complete Guide

Honnesh Muppala May 5, 2026 16 min read

What is API Testing?

API testing validates that application programming interfaces work correctly — returning the expected responses, handling errors gracefully, enforcing security boundaries, performing within acceptable time thresholds, and maintaining consistent behaviour across different inputs and conditions. At its core, API testing is about communicating directly with the backend layer of your application, bypassing the graphical user interface entirely, and verifying that the contract between your frontend and backend (or between two backend services) is honoured faithfully.

Unlike UI testing, which drives a browser through a graphical interface and is inherently tied to the visual implementation of your application, API tests communicate at the HTTP protocol level. This makes them dramatically faster to execute — a full API test suite that would take 20 minutes to run through a browser-based UI suite can often complete in under two minutes. It also makes API tests more stable: they are not affected by CSS changes, element repositioning, animation timing, or browser rendering differences. An API test does not care what the button looks like — it cares what happens when you call the endpoint that button invokes.

Where API Testing Fits in the QA Pyramid

The QA test pyramid, first described by Mike Cohn and refined by Martin Fowler, places tests in three layers: unit tests at the bottom (fast, plentiful, isolated), service/API tests in the middle, and end-to-end UI tests at the top (slow, expensive, few). API tests occupy the middle layer precisely because they offer an excellent balance of coverage breadth, execution speed, and maintenance cost.

A well-structured API test suite covers every endpoint, every HTTP method, every significant input variation, and every expected error condition. Because APIs are the stable contracts between components of your system, they change less frequently than UI implementations — making API tests lower maintenance than UI tests over time. They are also considerably faster than UI tests, making them practical to run on every commit in a CI/CD pipeline.

Why API Testing is Critical in Microservices

In a monolithic application, the internal components communicate through function calls and shared memory, and testing the application end-to-end through the UI may be sufficient to catch most integration issues. In a microservices architecture, each service is an independent deployable unit communicating with other services exclusively through APIs — typically HTTP/REST or gRPC. A change to one service's API without corresponding changes to all consuming services causes silent breakage that may not be visible in the UI until an edge case is triggered in production.

API testing becomes the primary validation layer in microservices. Each service should have its own API test suite that validates the service contract: what requests it accepts, what responses it returns, and how it behaves under various conditions. These tests run as part of every service's CI pipeline, catching breaking changes before they propagate to dependent services or reach end users.

"API testing is where I've caught the most critical bugs in my career. At Viasat, a backend API change broke the inflight entertainment portal silently — the UI still loaded but content failed to fetch. Our API regression suite caught it within minutes of the change being deployed to staging, long before any QA or user saw it."

REST Concepts & HTTP Methods

REST (Representational State Transfer) is an architectural style for distributed hypermedia systems, defined by Roy Fielding in his 2000 doctoral dissertation. RESTful APIs follow a set of constraints that make them predictable, stateless, and composable. Understanding these principles is essential for testing REST APIs effectively — knowing what the API is designed to do helps you identify what to test and what to expect.

Core REST Principles

Statelessness — Each request from a client to the server must contain all the information needed to understand the request. The server stores no session state between requests. Authentication information (typically a token) is sent with every request rather than established once in a session. This makes REST APIs horizontally scalable and easier to test — each request can be tested in isolation.

Resource-based — REST APIs model the domain as resources (users, orders, products) identified by URLs. Operations are performed on resources using standard HTTP methods. A URL like /api/users/123 represents the user resource with ID 123, and you interact with it using different HTTP methods for different operations.

Uniform Interface — REST uses a small set of standard HTTP methods rather than custom verbs. This means a developer who knows REST conventions can understand any REST API with minimal documentation — GET fetches, POST creates, PUT replaces, PATCH updates, DELETE removes.

Method Purpose Idempotent Has Request Body
GETRetrieve a resource or collectionYesNo
POSTCreate a new resourceNoYes
PUTReplace a resource entirelyYesYes
PATCHPartial update of a resourceNoYes
DELETERemove a resourceYesNo
HEADGET without response body — check existence/headersYesNo
OPTIONSList supported methods for a resourceYesNo

RESTful URL Patterns

Understanding standard REST URL patterns helps you build complete test coverage by knowing which endpoints should exist and what each should do:

GET    /users          → List all users (collection)
GET    /users/123      → Get user with ID 123 (individual resource)
POST   /users          → Create a new user (returns 201 + location header)
PUT    /users/123      → Replace user 123 entirely
PATCH  /users/123      → Partially update user 123
DELETE /users/123      → Delete user 123

# Nested resources
GET    /users/123/orders        → List orders belonging to user 123
GET    /users/123/orders/456    → Get specific order for user 123

# Filtering and pagination
GET    /users?role=admin&page=2&limit=50

HTTP Status Codes Reference

HTTP status codes are the first thing you check in an API response. They immediately tell you whether the request succeeded, failed due to a client error, or failed due to a server error. As a tester, you need to verify not only that successful requests return 2xx codes, but also that error conditions return appropriate error codes — not 200 with an error message in the body, which is an anti-pattern that breaks client error handling.

Code Name Meaning Testing Note
200OKRequest succeededStandard success for GET, PUT, PATCH
201CreatedResource created successfullyExpected for POST — verify Location header
204No ContentSuccess with no response bodyCommon for DELETE — verify body is empty
301Moved PermanentlyResource has a new permanent URLTest that redirects are followed correctly
304Not ModifiedCached version is still validTest ETag / If-None-Match caching
400Bad RequestMalformed request or invalid dataSend invalid body — confirm error details in response
401UnauthorizedNo valid authentication providedCall without token — must return 401, not 403 or 200
403ForbiddenAuthenticated but lacking permissionUse low-privilege token on admin endpoint
404Not FoundResource does not existRequest non-existent ID — confirm 404, not 500
405Method Not AllowedHTTP method not supportedDELETE a read-only resource
409ConflictState conflict (e.g. duplicate)Create duplicate unique resource
422Unprocessable EntitySyntactically valid but semantically wrongValid JSON with invalid field values
429Too Many RequestsRate limit exceededSend rapid requests — verify Retry-After header
500Internal Server ErrorUnexpected server errorShould be rare — investigate any 500 immediately
502Bad GatewayUpstream service failureIndicates a downstream dependency issue
503Service UnavailableServer temporarily unable to handle requestsRetry after delay — check Retry-After header
504Gateway TimeoutUpstream service timed outOften indicates slow database or external service

A critical testing principle: 401 and 403 are different. A 401 means the server does not know who you are — you have not provided valid credentials, or your credentials have expired. A 403 means the server knows exactly who you are but you do not have permission to perform the requested action. Confusing these codes leads to security vulnerabilities where error messages reveal information about what resources exist. Test both scenarios explicitly: missing authentication (expect 401) and insufficient permission (expect 403).

API Testing Tools Overview

The API testing ecosystem offers a wide range of tools, from simple command-line clients for quick manual checks to full automation frameworks for CI-integrated regression suites. Choosing the right tool depends on your use case: exploratory testing during development, scripted automation in CI, performance validation, or contract verification.

Tool Type Best For Language
PostmanGUI + scriptingExploration, manual testing, collectionsJavaScript (tests)
Python requestsLibraryPython automation scripts and pytest suitesPython
RestAssuredLibraryJava automation frameworks (JUnit, TestNG)Java
NewmanCLI runnerRunning Postman collections in CI/CDJavaScript
curlCLIQuick manual checks, scriptingShell
InsomniaGUIREST and GraphQL explorationJavaScript (tests)
k6Load testingAPI performance and load testingJavaScript
PactContract testingConsumer-driven contract verificationMultiple languages

For most teams, the practical combination is Postman for exploration and manual testing during development, and Python requests with pytest for automated regression suites in CI. These two tools cover the full testing lifecycle: Postman for quick ad-hoc validation of new endpoints and Newman for running those collections in CI, plus a more maintainable Python pytest suite for the structured regression layer.

Postman Basics

Postman is the most widely used GUI tool for API exploration and manual testing. Its interface makes it easy to construct any HTTP request, inspect the full response (body, headers, timing, cookies), and write JavaScript test assertions that run automatically against every response. This makes it an excellent choice for exploring an unfamiliar API, verifying individual endpoints during development, and creating documented collections of requests that the whole team can share.

Making Your First Request

To make a GET request in Postman:

  1. Open Postman and click New → HTTP Request
  2. Ensure the method dropdown is set to GET
  3. Enter the URL: https://jsonplaceholder.typicode.com/users/1
  4. Click Send
  5. Inspect the response: Status 200, response time, body (JSON), and headers

Writing Test Assertions in Postman

The Tests tab in the request editor lets you write JavaScript assertions that Postman runs automatically after each request. These tests are visible in the Test Results panel and can be run across entire collections via Collection Runner or Newman:

// Tests tab in Postman — runs after every response
pm.test("Status code is 200", function() {
    pm.response.to.have.status(200);
});

pm.test("Response has user id property", function() {
    const json = pm.response.json();
    pm.expect(json).to.have.property("id");
    pm.expect(json.id).to.eql(1);
});

pm.test("Response has required user fields", function() {
    const json = pm.response.json();
    pm.expect(json).to.have.property("name");
    pm.expect(json).to.have.property("email");
    pm.expect(json).to.have.property("username");
    pm.expect(json).to.have.property("phone");
    pm.expect(json).to.have.property("address");
});

pm.test("Response time is under 2 seconds", function() {
    pm.expect(pm.response.responseTime).to.be.below(2000);
});

pm.test("Content-Type header is JSON", function() {
    pm.expect(pm.response.headers.get("Content-Type"))
      .to.include("application/json");
});

// Save value to environment for use in subsequent requests
pm.environment.set("userId", pm.response.json().id);

Collections & Environments

Postman Collections are organised groups of related requests — the equivalent of a test suite. They allow you to document your API, share request definitions with team members, run all requests in sequence with Collection Runner, and export to Newman for CI execution. A well-structured collection covers all endpoints and significant test scenarios for your API.

Environment Variables

Environments in Postman define sets of variables that your requests use. You define a "Staging" environment with BASE_URL=https://api-staging.example.com and a "Production" environment with BASE_URL=https://api.example.com. Your requests use {{BASE_URL}}/users in the URL field — switch the active environment and all requests point to the correct server without any changes to the requests themselves.

Common environment variables: {{BASE_URL}}, {{API_KEY}}, {{AUTH_TOKEN}}, {{USER_ID}}, {{ORDER_ID}}.

Collection Structure Example

User Management API Tests/
├── Auth/
│   ├── POST /auth/login          → Obtain JWT token, save to env
│   └── POST /auth/refresh        → Refresh expired token
├── Users/
│   ├── GET  /users               → List all users (200, array)
│   ├── GET  /users/{{userId}}    → Get specific user (200)
│   ├── POST /users               → Create user (201, id in response)
│   ├── PUT  /users/{{userId}}    → Full update (200)
│   └── DELETE /users/{{userId}} → Delete user (204)
└── Error Cases/
    ├── GET  /users/99999         → Non-existent user (404)
    ├── POST /users (bad email)   → Invalid data (400/422)
    └── GET  /users (no auth)    → Missing token (401)

Pre-request Script for Authentication

The Pre-request Script tab runs JavaScript before the request is sent. Use this at the collection level to automatically fetch and refresh an auth token before any request in the collection executes:

// Pre-request Script (set at Collection level — runs before every request)
const tokenExpiry = pm.environment.get("tokenExpiry");
const now = Date.now();

// Only fetch a new token if we don't have one or it's expired
if (!tokenExpiry || now >= parseInt(tokenExpiry)) {
    pm.sendRequest({
        url: pm.environment.get("BASE_URL") + "/auth/login",
        method: "POST",
        header: { "Content-Type": "application/json" },
        body: {
            mode: "raw",
            raw: JSON.stringify({
                username: pm.environment.get("USERNAME"),
                password: pm.environment.get("PASSWORD")
            })
        }
    }, function(err, res) {
        if (err) throw err;
        const body = res.json();
        pm.environment.set("authToken", body.token);
        // Cache token for 55 minutes (before typical 60-minute expiry)
        pm.environment.set("tokenExpiry", now + (55 * 60 * 1000));
    });
}

Running Postman collections in CI via Newman:

# Install Newman
npm install -g newman newman-reporter-htmlextra

# Run collection with environment, generate HTML report
newman run "User_API_Tests.postman_collection.json" \
    --environment "Staging.postman_environment.json" \
    --reporters cli,htmlextra \
    --reporter-htmlextra-export reports/newman-report.html

Python requests Library

The Python requests library is the de facto standard for HTTP in Python — clean, readable, and feature-complete. It handles connection pooling, session management, redirects, SSL verification, multipart file uploads, streaming responses, and authentication schemes with minimal boilerplate. For API test automation, combining requests with pytest gives you a powerful, maintainable framework that integrates naturally with any CI pipeline.

import requests
import os

BASE_URL = os.getenv("API_BASE_URL", "https://jsonplaceholder.typicode.com")

# Use a Session for connection reuse and shared headers
# A single Session object is more efficient for multiple requests
session = requests.Session()
session.headers.update({
    "Content-Type":  "application/json",
    "Accept":        "application/json",
    "User-Agent":    "QAChronicles-Test-Suite/1.0",
})

# Add auth token to session (all subsequent requests include it)
# session.headers.update({"Authorization": f"Bearer {token}"})

# Session automatically reuses connections — faster for large suites
response = session.get(f"{BASE_URL}/users/1")
print(response.status_code)   # 200
print(response.json()["name"])  # "Leanne Graham"

# Always close the session when done
session.close()

GET, POST, PUT, DELETE Examples

Working through all four primary HTTP methods with practical Python examples against the JSONPlaceholder public test API. These patterns form the foundation of any REST API test suite.

import requests

BASE_URL = "https://jsonplaceholder.typicode.com"


# ── GET ── Retrieve a resource ────────────────────────────────────────────────
response = requests.get(f"{BASE_URL}/users/1")

print(response.status_code)                        # 200
print(response.json()["name"])                     # "Leanne Graham"
print(response.headers["Content-Type"])            # "application/json; charset=utf-8"
print(response.elapsed.total_seconds())            # e.g. 0.234

# GET with query parameters
response = requests.get(f"{BASE_URL}/posts", params={"userId": 1})
print(response.status_code)                        # 200
print(len(response.json()))                        # 10 posts for user 1


# ── POST ── Create a new resource ─────────────────────────────────────────────
new_user = {
    "name":     "Honnesh Muppala",
    "username": "honnesh",
    "email":    "honnesh@example.com",
    "phone":    "+353-91-000-000",
    "website":  "honneshraju.com",
}
response = requests.post(f"{BASE_URL}/users", json=new_user)

print(response.status_code)        # 201 Created
print(response.json()["id"])       # New resource ID assigned by server
print(response.headers.get("Location", ""))  # Location of new resource (if provided)


# ── PUT ── Replace a resource entirely ────────────────────────────────────────
# PUT replaces the full resource — omitting a field removes it
updated_user = {
    "id":       1,
    "name":     "Updated Name",
    "username": "updateduser",
    "email":    "updated@example.com",
    "phone":    "+1-555-000-0000",
    "website":  "example.com",
}
response = requests.put(f"{BASE_URL}/users/1", json=updated_user)

print(response.status_code)        # 200 OK
print(response.json()["name"])     # "Updated Name"


# ── PATCH ── Partial update ───────────────────────────────────────────────────
# PATCH sends only the fields being changed
response = requests.patch(f"{BASE_URL}/users/1", json={"name": "Patched Name"})

print(response.status_code)        # 200 OK
print(response.json()["name"])     # "Patched Name"
# Other fields remain unchanged


# ── DELETE ── Remove a resource ───────────────────────────────────────────────
response = requests.delete(f"{BASE_URL}/users/1")

print(response.status_code)        # 200 or 204 No Content
# JSONPlaceholder returns 200 with empty body {}
# Production APIs typically return 204 with no body


# ── Error handling ────────────────────────────────────────────────────────────
response = requests.get(f"{BASE_URL}/users/99999")
if response.status_code == 404:
    print("User not found — expected for missing resource")

# raise_for_status() raises requests.HTTPError for 4xx and 5xx responses
try:
    response.raise_for_status()
except requests.HTTPError as e:
    print(f"HTTP error: {e}")

# Set timeouts — always specify to avoid hanging tests
response = requests.get(
    f"{BASE_URL}/users",
    timeout=(5, 30)   # (connect timeout, read timeout) in seconds
)

Response Validation

Effective API test validation goes well beyond checking the HTTP status code. A comprehensive response validation strategy examines the status code, response headers, response time, body structure, field types, field values, and business logic correctness. Writing a reusable validation helper function is a pattern that keeps test code clean and consistent across your suite.

"One pattern I always use: a validate_schema helper that checks the response matches an expected JSON structure. As APIs evolve, new fields appear and old ones occasionally disappear. Schema validation catches breaking changes that status-code-only tests miss completely."
import requests
import pytest
from jsonschema import validate, ValidationError

BASE_URL = "https://jsonplaceholder.typicode.com"

# JSON Schema for user resource — defines expected structure and types
USER_SCHEMA = {
    "type": "object",
    "required": ["id", "name", "username", "email", "phone", "address"],
    "properties": {
        "id":       {"type": "integer"},
        "name":     {"type": "string", "minLength": 1},
        "username": {"type": "string", "minLength": 1},
        "email":    {"type": "string", "format": "email"},
        "phone":    {"type": "string"},
        "website":  {"type": "string"},
        "address": {
            "type": "object",
            "required": ["street", "city", "zipcode"],
        },
    },
    "additionalProperties": True,  # Allow extra fields without failing
}


def validate_user_response(response: requests.Response) -> dict:
    """Full response validation — status, headers, timing, body, schema."""

    # 1. Status code
    assert response.status_code == 200, \
        f"Expected 200, got {response.status_code}: {response.text[:200]}"

    # 2. Content-Type header
    content_type = response.headers.get("Content-Type", "")
    assert "application/json" in content_type, \
        f"Expected JSON Content-Type, got: {content_type}"

    # 3. Response time SLA
    elapsed = response.elapsed.total_seconds()
    assert elapsed < 2.0, \
        f"Response too slow: {elapsed:.2f}s (limit: 2.0s)"

    # 4. Parse body — fail clearly if not valid JSON
    try:
        data = response.json()
    except ValueError as e:
        pytest.fail(f"Response body is not valid JSON: {e}")

    # 5. Schema validation via jsonschema
    try:
        validate(instance=data, schema=USER_SCHEMA)
    except ValidationError as e:
        pytest.fail(f"Response schema validation failed: {e.message}")

    # 6. Business logic validation
    assert isinstance(data["id"], int) and data["id"] > 0
    assert len(data["name"]) > 0
    assert "@" in data["email"], f"Invalid email: {data['email']}"

    return data


# Usage in tests
def test_get_user_validation():
    response = requests.get(f"{BASE_URL}/users/1")
    user = validate_user_response(response)
    assert user["id"] == 1

Authentication Testing

Authentication and authorisation testing is one of the most critical areas of API testing from a security perspective. There are several authentication schemes you will encounter in REST APIs, and each requires specific testing strategies to validate both the happy path (valid credentials work) and the negative cases (invalid, missing, or expired credentials are correctly rejected).

import requests

BASE_URL = "https://httpbin.org"


# ── 1. API Key in header ──────────────────────────────────────────────────────
response = requests.get(
    f"{BASE_URL}/headers",
    headers={"X-API-Key": "your-api-key-here"}
)
# Verify the key was received by the server
assert response.status_code == 200


# ── 2. Bearer Token (JWT) ─────────────────────────────────────────────────────
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.your.payload.here"
response = requests.get(
    f"{BASE_URL}/bearer",
    headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200


# ── 3. HTTP Basic Auth ────────────────────────────────────────────────────────
response = requests.get(
    f"{BASE_URL}/basic-auth/testuser/testpassword",
    auth=("testuser", "testpassword")
)
assert response.status_code == 200
assert response.json()["authenticated"] is True


# ── 4. OAuth 2.0 Client Credentials Flow ─────────────────────────────────────
token_response = requests.post(
    "https://auth.example.com/oauth/token",
    data={
        "grant_type":    "client_credentials",
        "client_id":     "your-client-id",
        "client_secret": "your-client-secret",
        "scope":         "api:read api:write",
    }
)
assert token_response.status_code == 200
access_token = token_response.json()["access_token"]
expires_in   = token_response.json()["expires_in"]

# Use token in subsequent requests
protected_headers = {"Authorization": f"Bearer {access_token}"}


# ── Security Negative Tests ───────────────────────────────────────────────────

# Test 1: No authentication → must return 401
response = requests.get(f"{BASE_URL}/bearer")
assert response.status_code == 401, \
    f"Expected 401 for missing auth, got {response.status_code}"

# Test 2: Invalid token → must return 401
response = requests.get(
    f"{BASE_URL}/bearer",
    headers={"Authorization": "Bearer this-is-not-a-valid-token"}
)
assert response.status_code == 401, \
    f"Expected 401 for invalid token, got {response.status_code}"

# Test 3: Malformed Authorization header → must return 401
response = requests.get(
    f"{BASE_URL}/bearer",
    headers={"Authorization": "NotBearer some-token"}
)
assert response.status_code == 401

# Test 4: Valid auth but insufficient permission → must return 403
# (Using a regular user token to access an admin endpoint)
regular_user_token = "valid-token-for-regular-user"
response = requests.get(
    "https://api.example.com/admin/users",
    headers={"Authorization": f"Bearer {regular_user_token}"}
)
assert response.status_code == 403, \
    f"Expected 403 for insufficient permission, got {response.status_code}"

# Test 5: Expired token → must return 401
expired_token = "valid-but-expired-jwt-token"
response = requests.get(
    "https://api.example.com/protected",
    headers={"Authorization": f"Bearer {expired_token}"}
)
assert response.status_code == 401

pytest for API Automation

pytest is the industry-standard Python testing framework, and it works beautifully for API test automation. Its fixture system handles test setup and teardown elegantly, @pytest.mark.parametrize eliminates duplication when testing the same logic across multiple inputs, and its plugin ecosystem provides parallel execution, HTML reports, and integration with most CI systems out of the box.

# conftest.py — shared fixtures for the entire test suite
import pytest
import requests
import os

BASE_URL = os.getenv("API_BASE_URL", "https://jsonplaceholder.typicode.com")


@pytest.fixture(scope="session")
def api_session():
    """Shared requests.Session for the entire test run.
    Session scope means one Session object is created and reused
    across all tests, which is more efficient than per-test sessions."""
    session = requests.Session()
    session.headers.update({
        "Content-Type": "application/json",
        "Accept":       "application/json",
    })
    yield session
    session.close()


@pytest.fixture(scope="session")
def base_url():
    """Base URL for the API under test."""
    return BASE_URL


@pytest.fixture(scope="session")
def auth_token(api_session, base_url):
    """Authenticate once for the session and cache the token."""
    response = api_session.post(f"{base_url}/auth/login", json={
        "username": os.environ.get("API_USERNAME", "test_user"),
        "password": os.environ.get("API_PASSWORD", "test_pass"),
    })
    # JSONPlaceholder doesn't have auth — this fixture is for real APIs
    # response.raise_for_status()
    # return response.json()["token"]
    return "mock-token-for-demo"


# tests/test_users_api.py
import pytest


class TestUsersAPI:
    """Test suite for the /users endpoint."""

    def test_get_user_success(self, api_session, base_url):
        """GET /users/1 returns 200 with correct user data."""
        response = api_session.get(f"{base_url}/users/1")

        assert response.status_code == 200
        data = response.json()
        assert data["id"] == 1
        assert "name" in data
        assert "email" in data

    def test_get_user_list(self, api_session, base_url):
        """GET /users returns a non-empty list of users."""
        response = api_session.get(f"{base_url}/users")

        assert response.status_code == 200
        users = response.json()
        assert isinstance(users, list)
        assert len(users) > 0

    def test_get_nonexistent_user_returns_404(self, api_session, base_url):
        """GET /users/99999 returns 404 for non-existent resource."""
        response = api_session.get(f"{base_url}/users/99999")
        assert response.status_code == 404

    @pytest.mark.parametrize("user_id", [1, 2, 3, 4, 5])
    def test_get_multiple_users_all_valid(self, api_session, base_url, user_id):
        """Each user 1–5 returns 200 with matching ID."""
        response = api_session.get(f"{base_url}/users/{user_id}")

        assert response.status_code == 200
        data = response.json()
        assert data["id"] == user_id
        assert len(data["name"]) > 0

    def test_create_user(self, api_session, base_url):
        """POST /users creates a new user and returns 201."""
        new_user = {
            "name":     "Test User",
            "username": "testuser_auto",
            "email":    "testuser@example.com",
        }
        response = api_session.post(f"{base_url}/users", json=new_user)

        assert response.status_code == 201
        created = response.json()
        assert "id" in created
        assert created["name"] == new_user["name"]
        assert created["email"] == new_user["email"]

    def test_update_user(self, api_session, base_url):
        """PUT /users/1 returns 200 with updated data."""
        update_data = {"name": "Updated User Name", "email": "updated@example.com"}
        response = api_session.put(f"{base_url}/users/1", json=update_data)

        assert response.status_code == 200
        assert response.json()["name"] == update_data["name"]

    def test_delete_user(self, api_session, base_url):
        """DELETE /users/1 returns 200 or 204."""
        response = api_session.delete(f"{base_url}/users/1")
        assert response.status_code in [200, 204]

    def test_response_time_sla(self, api_session, base_url):
        """Response time for /users must be under 3 seconds."""
        response = api_session.get(f"{base_url}/users")
        assert response.elapsed.total_seconds() < 3.0, \
            f"SLA breach: {response.elapsed.total_seconds():.2f}s"

    @pytest.mark.parametrize("invalid_email", [
        "not-an-email",
        "missing@domain",
        "@nodomain.com",
        "",
    ])
    def test_create_user_invalid_email_rejected(self, api_session, base_url, invalid_email):
        """POST /users with invalid email must return 400 or 422."""
        response = api_session.post(f"{base_url}/users", json={
            "name": "Test", "email": invalid_email
        })
        # JSONPlaceholder accepts everything — in a real API this would be 400/422
        # assert response.status_code in [400, 422]

Run the suite with parallel execution and HTML reporting:

# Run sequentially
pytest tests/ -v --html=report.html --self-contained-html

# Run in parallel (requires pytest-xdist)
pytest tests/ -n 4 -v --html=report.html

Contract Testing Concept

Contract testing is a technique for validating that two services — a consumer (the service calling the API) and a provider (the service serving the API) — can communicate correctly. Traditional integration tests verify this by running both services simultaneously, which can be slow and environment-dependent. Contract testing verifies the consumer's expectations and the provider's capabilities independently, using a shared "contract" document as the source of truth.

The most widely adopted contract testing tool is Pact. In a Pact workflow, the consumer defines the interactions it expects — the requests it will send and the responses it needs to function correctly. Pact records these as a contract (a JSON file called a "pact"). The provider then uses this contract to run its own verification tests, confirming it can satisfy every interaction the consumer expects.

# Conceptual Pact consumer test (Python pact-python library)
from pact import Consumer, Provider

pact = Consumer("FrontendApp").has_pact_with(Provider("UserAPI"))

# The consumer defines what it expects from the provider
(pact
    .upon_receiving("a request for an existing user")
    .with_request(method="GET", path="/users/1")
    .will_respond_with(200, body={
        "id":    1,
        "name":  "Like a string",  # Pact matchers — type check, not exact match
        "email": "Like a string",
    })
)

# When the consumer test runs, Pact starts a mock provider server
# that returns the expected response, and the consumer code is tested
# against that mock. The interaction is saved as a pact file.

# The pact file is then shared with the provider team (via a Pact Broker)
# and the provider runs verification tests against their real API.

Contract testing is most valuable in microservices organisations where multiple teams work on different services. Without it, a provider team can inadvertently change an API response structure, breaking consumer services that depended on the old structure — and the breakage may not be discovered until integration tests run in a shared environment, often hours or days later. With Pact, the provider's CI pipeline runs contract verification tests and fails immediately if any consumer's expectations are violated, catching the breaking change at the source before it propagates.

"One pattern I always use: a validate_schema helper that checks the response matches an expected JSON structure. As APIs evolve, new fields appear and old ones occasionally disappear. Schema validation catches breaking changes that status-code-only tests miss completely."

Best Practices

Effective API testing requires both technical skill and disciplined process. These practices, refined across real-world projects at Viasat and Amazon, will help you build API test suites that are thorough, maintainable, and genuinely valuable to your team.

The request/response cycle from test to API is shown below:

Test Script
Postman / pytest
HTTP Request
GET / POST / PUT
REST API Server
Endpoint Logic
Database
Data Layer
HTTP Response
JSON + Status
Assertions
Validate & Report
  1. Test the happy path, negative cases, and edge cases for every endpoint. Happy path testing alone is insufficient. For every endpoint, also test: missing required fields (expect 400/422), invalid data types (expect 400), out-of-range values, extremely long strings, special characters in text fields, and concurrent requests to the same resource.
  2. Always validate status code, response headers, AND response body. A test that only checks assert response.status_code == 200 can pass even if the body is empty, malformed, or missing critical fields. Validate all three dimensions of every response.
  3. Use schema validation for complex responses. The jsonschema library lets you define a JSON Schema for your expected response structure and validate every response against it automatically. This catches structural regressions — removed fields, changed types — that field-by-field assertions might miss.
  4. Parametrize tests with multiple valid and invalid inputs. Use @pytest.mark.parametrize to test the same endpoint with a range of inputs: multiple valid user IDs, multiple invalid email formats, multiple auth token scenarios. This dramatically increases coverage without duplicating test logic.
  5. Isolate tests — do not depend on data created by a previous test. Each test should either create its own test data via the API as part of the test setup (using a fixture), or use pre-seeded data that is guaranteed to exist. Tests that depend on execution order are fragile and difficult to debug.
  6. Use pytest fixtures for test data setup and teardown. Fixtures with appropriate scope (function, class, module, session) manage the lifecycle of test data cleanly. A function-scoped fixture creates a user at the start of a test and deletes it in the teardown, ensuring a clean slate for every test run.
  7. Test rate limiting explicitly. If your API enforces rate limits, write a test that sends rapid successive requests until a 429 is returned, then validates that the Retry-After header is present with a sensible value. Rate limit enforcement is a security and reliability feature that must be verified to work correctly.
  8. Test with the actual authentication flow, not bypassed tokens. Your tests should go through the real login flow to obtain tokens, not use hardcoded tokens that may expire or be invalid in different environments. This ensures your auth flow itself is tested alongside the endpoints it protects.
  9. Document your API test coverage with an endpoint matrix. Maintain a simple spreadsheet or wiki table listing every API endpoint, every HTTP method it supports, and which test cases cover it. This makes coverage gaps visible and helps onboard new team members to understand what is and is not tested.
  10. Run API tests in CI on every commit. API tests are fast — a comprehensive suite of 100 test cases should run in under two minutes. There is no justification for running them less frequently than every commit. Include them in your pull request checks so no change merges without the API regression suite passing.
From Experience — Virtusa: At Virtusa, Postman was our primary API testing tool across every client engagement. We built environment-variable-driven collections covering dev, staging, and production, with pre-request scripts handling OAuth token refresh automatically. The discipline that made the biggest difference: validating response schemas, not just status codes. This caught three breaking backend changes before they reached QA — each time, a field was renamed or a type changed. Status 200 with wrong data is a silent failure that status-code-only tests never catch.