TL;DR

  • API testing verifies backend services work correctly without UI — faster and more reliable than E2E tests
  • Test: status codes, response body, headers, error handling, authentication, schema validation, performance
  • Tools: Postman (manual/learning), REST Assured (Java), Supertest (Node.js), requests (Python)
  • Automate in CI/CD — APIs change frequently, catch breaking changes early
  • Cover both happy path and error scenarios (400s, 401, 404, 500)
  • Validate response schemas to prevent contract drift between frontend and backend

Best for: Backend developers, QA engineers, anyone testing microservices Skip if: You only need to test static websites or simple frontends

Your frontend tests pass. Users report the app is broken. The API changed, and nobody tested the contract.

API testing catches these issues before they reach users. It’s faster than UI testing, more reliable, and tests the actual business logic your application depends on.

I’ve caught production-breaking changes in API contracts dozens of times — a renamed field, a changed status code, a missing pagination header. Each one would have caused downtime if not caught by API tests in CI.

This tutorial teaches API testing from scratch — HTTP basics, REST conventions, authentication, error handling, schema validation, and automation with popular tools.

What is API Testing?

API (Application Programming Interface) testing verifies that your backend services work correctly. Instead of clicking through a UI, you send HTTP requests directly to endpoints and verify responses.

What API testing covers:

  • Functionality — does the endpoint do what it should?
  • Data validation — are responses structured correctly?
  • Error handling — does it fail gracefully?
  • Authentication — is access properly controlled?
  • Schema compliance — does the response match the contract?
  • Performance — can it handle load?

Why API testing matters:

  • Faster than UI tests — no browser rendering, milliseconds vs seconds
  • More stable — no flaky selectors or timing issues
  • Earlier feedback — test before frontend exists
  • Better coverage — test edge cases impossible via UI

Where API tests fit in the testing pyramid:

        /  UI Tests  \          ← Slow, expensive, few
       / Integration  \         ← API tests live here
      /   Unit Tests   \        ← Fast, cheap, many

API tests are the sweet spot — they’re fast enough to run on every commit, but thorough enough to catch real integration bugs that unit tests miss.

HTTP Fundamentals

Before testing APIs, understand HTTP basics.

HTTP Methods

GET     /users          # Retrieve all users
GET     /users/123      # Retrieve user 123
POST    /users          # Create new user
PUT     /users/123      # Replace user 123
PATCH   /users/123      # Update parts of user 123
DELETE  /users/123      # Delete user 123
MethodPurposeHas BodyIdempotent
GETRead dataNoYes
POSTCreate resourceYesNo
PUTReplace resourceYesYes
PATCHPartial updateYesNo
DELETERemove resourceOptionalYes

Idempotency matters for testing: Calling GET or PUT 5 times should produce the same result as calling it once. POST creates a new resource each time. Your tests should account for this — PUT tests can retry safely, POST tests need cleanup.

Status Codes

2xx Success
├── 200 OK              # Request succeeded
├── 201 Created         # Resource created
├── 204 No Content      # Success, nothing to return

4xx Client Errors
├── 400 Bad Request     # Invalid input
├── 401 Unauthorized    # Missing/invalid auth
├── 403 Forbidden       # Valid auth, no permission
├── 404 Not Found       # Resource doesn't exist
├── 409 Conflict        # Conflicts with current state
├── 422 Unprocessable   # Validation failed
├── 429 Too Many Req    # Rate limit exceeded

5xx Server Errors
├── 500 Internal Error  # Server bug
├── 502 Bad Gateway     # Upstream error
├── 503 Unavailable     # Server overloaded/maintenance

Common testing mistake: Only checking for 200. Always verify the specific expected code — a 200 when you expected 201 means something is wrong.

Request and Response Structure

POST /api/users HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

{
  "name": "John Doe",
  "email": "john@example.com"
}
HTTP/1.1 201 Created
Content-Type: application/json
X-Request-Id: abc123
Location: /api/users/456

{
  "id": 456,
  "name": "John Doe",
  "email": "john@example.com",
  "createdAt": "2026-01-20T10:30:00Z"
}

Test all parts: status code, headers (Content-Type, Location, rate limit headers), and body structure.

Testing with Postman

Postman is the easiest way to start API testing.

First Request

  1. Open Postman
  2. Enter URL: https://jsonplaceholder.typicode.com/posts/1
  3. Method: GET
  4. Click Send

Response:

{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere...",
  "body": "quia et suscipit..."
}

Adding Tests

In Postman, add JavaScript tests in the “Tests” tab:

// Status code check
pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});

// Response time
pm.test("Response time is less than 500ms", function () {
    pm.expect(pm.response.responseTime).to.be.below(500);
});

// Body validation
pm.test("Has correct structure", function () {
    const json = pm.response.json();
    pm.expect(json).to.have.property("id");
    pm.expect(json).to.have.property("title");
    pm.expect(json.id).to.eql(1);
});

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

Collections, Variables, and Chaining

Organize tests into collections with environment variables:

// Environment variables
pm.environment.set("baseUrl", "https://api.example.com");

// Chain requests: extract token from login, use in next request
pm.test("Save auth token", function () {
    const response = pm.response.json();
    pm.environment.set("token", response.token);
    pm.environment.set("userId", response.user.id);
});

// Use variables in URL: {{baseUrl}}/users/{{userId}}
// Use in headers: Authorization: Bearer {{token}}

Running collections from CLI with Newman:

npm install -g newman
newman run collection.json -e production.json --reporters cli,html

Newman lets you run Postman collections in CI/CD — same tests, automated execution.

REST API Testing with Code

Python with requests + pytest

import requests
import pytest

BASE_URL = "https://api.example.com"

class TestUsersAPI:

    def test_get_users_returns_list(self):
        response = requests.get(f"{BASE_URL}/users")

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

    def test_create_user(self):
        payload = {
            "name": "John Doe",
            "email": "john@example.com"
        }

        response = requests.post(
            f"{BASE_URL}/users",
            json=payload,
            headers={"Content-Type": "application/json"}
        )

        assert response.status_code == 201
        data = response.json()
        assert data["name"] == payload["name"]
        assert "id" in data
        assert "createdAt" in data

    def test_get_nonexistent_user(self):
        response = requests.get(f"{BASE_URL}/users/99999")

        assert response.status_code == 404

    def test_create_user_invalid_email(self):
        payload = {"name": "John", "email": "not-an-email"}

        response = requests.post(f"{BASE_URL}/users", json=payload)

        assert response.status_code == 400
        assert "email" in response.json().get("error", "").lower()

    def test_update_user_partial(self):
        response = requests.patch(
            f"{BASE_URL}/users/1",
            json={"name": "Updated Name"}
        )

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

    def test_delete_user(self):
        response = requests.delete(f"{BASE_URL}/users/1")

        assert response.status_code == 204

        # Verify deletion
        get_response = requests.get(f"{BASE_URL}/users/1")
        assert get_response.status_code == 404

JavaScript with Supertest

const request = require('supertest');
const app = require('../src/app');

describe('Users API', () => {
  test('GET /users returns list of users', async () => {
    const response = await request(app)
      .get('/users')
      .expect(200)
      .expect('Content-Type', /json/);

    expect(Array.isArray(response.body)).toBe(true);
  });

  test('POST /users creates new user', async () => {
    const newUser = {
      name: 'John Doe',
      email: 'john@example.com'
    };

    const response = await request(app)
      .post('/users')
      .send(newUser)
      .expect(201);

    expect(response.body).toMatchObject(newUser);
    expect(response.body.id).toBeDefined();
  });

  test('GET /users/:id returns 404 for unknown user', async () => {
    await request(app)
      .get('/users/99999')
      .expect(404);
  });
});

Java with REST Assured

import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.*;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;

public class UsersApiTest {

    @BeforeAll
    public static void setup() {
        RestAssured.baseURI = "https://api.example.com";
    }

    @Test
    public void getUsersReturnsList() {
        given()
            .when()
                .get("/users")
            .then()
                .statusCode(200)
                .contentType(ContentType.JSON)
                .body("$", instanceOf(List.class))
                .body("size()", greaterThan(0));
    }

    @Test
    public void createUserReturnsCreated() {
        String requestBody = """
            {
                "name": "John Doe",
                "email": "john@example.com"
            }
            """;

        given()
            .contentType(ContentType.JSON)
            .body(requestBody)
        .when()
            .post("/users")
        .then()
            .statusCode(201)
            .body("name", equalTo("John Doe"))
            .body("id", notNullValue())
            .body("createdAt", notNullValue());
    }

    @Test
    public void getNonexistentUserReturns404() {
        given()
        .when()
            .get("/users/99999")
        .then()
            .statusCode(404);
    }
}

Response Schema Validation

Testing individual fields is not enough. Validate the entire response structure to catch contract drift.

JSON Schema Validation with Python

from jsonschema import validate

user_schema = {
    "type": "object",
    "required": ["id", "name", "email", "createdAt"],
    "properties": {
        "id": {"type": "integer"},
        "name": {"type": "string", "minLength": 1},
        "email": {"type": "string", "format": "email"},
        "createdAt": {"type": "string", "format": "date-time"},
        "role": {"type": "string", "enum": ["admin", "user", "viewer"]}
    },
    "additionalProperties": False
}

def test_user_response_matches_schema():
    response = requests.get(f"{BASE_URL}/users/1")
    validate(instance=response.json(), schema=user_schema)

Schema Validation with REST Assured

@Test
public void responseMatchesJsonSchema() {
    given()
    .when()
        .get("/users/1")
    .then()
        .assertThat()
        .body(matchesJsonSchemaInClasspath("user-schema.json"));
}

Why schema validation matters: A developer adds a field, renames another, or changes a type. Individual assertions miss this. Schema validation catches every structural change — it’s your API contract guard.

Authentication Testing

Basic Authentication

import requests
from requests.auth import HTTPBasicAuth

response = requests.get(
    "https://api.example.com/protected",
    auth=HTTPBasicAuth("username", "password")
)

Bearer Token (JWT)

# Step 1: Login to get token
login_response = requests.post(
    "https://api.example.com/auth/login",
    json={"email": "user@example.com", "password": "secret"}
)
token = login_response.json()["token"]

# Step 2: Use token in requests
response = requests.get(
    "https://api.example.com/protected",
    headers={"Authorization": f"Bearer {token}"}
)

API Key

# In header
response = requests.get(
    "https://api.example.com/data",
    headers={"X-API-Key": "your-api-key"}
)

# In query parameter
response = requests.get(
    "https://api.example.com/data?api_key=your-api-key"
)

Testing Auth Scenarios

class TestAuthentication:

    def test_protected_endpoint_requires_auth(self):
        response = requests.get(f"{BASE_URL}/protected")
        assert response.status_code == 401

    def test_invalid_token_rejected(self):
        response = requests.get(
            f"{BASE_URL}/protected",
            headers={"Authorization": "Bearer invalid_token"}
        )
        assert response.status_code == 401

    def test_expired_token_rejected(self):
        expired_token = create_expired_token()
        response = requests.get(
            f"{BASE_URL}/protected",
            headers={"Authorization": f"Bearer {expired_token}"}
        )
        assert response.status_code == 401

    def test_valid_token_grants_access(self):
        token = get_valid_token()
        response = requests.get(
            f"{BASE_URL}/protected",
            headers={"Authorization": f"Bearer {token}"}
        )
        assert response.status_code == 200

    def test_insufficient_permissions(self):
        viewer_token = get_token_for_role("viewer")
        response = requests.delete(
            f"{BASE_URL}/users/1",
            headers={"Authorization": f"Bearer {viewer_token}"}
        )
        assert response.status_code == 403

GraphQL Testing

GraphQL uses a single endpoint with queries and mutations. Testing is fundamentally different from REST.

Query Testing

def test_graphql_query():
    query = """
    query GetUser($id: ID!) {
        user(id: $id) {
            id
            name
            email
        }
    }
    """

    response = requests.post(
        f"{BASE_URL}/graphql",
        json={
            "query": query,
            "variables": {"id": "123"}
        }
    )

    assert response.status_code == 200
    data = response.json()
    assert "errors" not in data
    assert data["data"]["user"]["id"] == "123"

Mutation Testing

def test_graphql_mutation():
    mutation = """
    mutation CreateUser($input: CreateUserInput!) {
        createUser(input: $input) {
            id
            name
            email
        }
    }
    """

    response = requests.post(
        f"{BASE_URL}/graphql",
        json={
            "query": mutation,
            "variables": {
                "input": {
                    "name": "John Doe",
                    "email": "john@example.com"
                }
            }
        }
    )

    assert response.status_code == 200
    data = response.json()
    assert data["data"]["createUser"]["name"] == "John Doe"

GraphQL-Specific Test Cases

def test_graphql_invalid_query():
    """GraphQL returns 200 with errors array, not 400"""
    response = requests.post(
        f"{BASE_URL}/graphql",
        json={"query": "{ nonExistentField }"}
    )
    assert response.status_code == 200
    assert "errors" in response.json()

def test_graphql_query_depth_limit():
    """Prevent deeply nested queries (DoS prevention)"""
    deep_query = """
    { user(id: "1") { friends { friends { friends { friends { name } } } } } }
    """
    response = requests.post(f"{BASE_URL}/graphql", json={"query": deep_query})
    assert "errors" in response.json()

Key difference from REST: GraphQL always returns 200 for valid HTTP requests. Errors appear in the errors array inside the response body. Don’t check status codes alone — always check response.json()["errors"].

Error Testing

Test how your API handles problems. This is where most bugs live.

class TestErrorHandling:

    def test_malformed_json_returns_400(self):
        response = requests.post(
            f"{BASE_URL}/users",
            data="not valid json",
            headers={"Content-Type": "application/json"}
        )
        assert response.status_code == 400

    def test_missing_required_field_returns_400(self):
        response = requests.post(
            f"{BASE_URL}/users",
            json={"name": "John"}  # missing email
        )
        assert response.status_code == 400
        assert "email" in response.json()["message"].lower()

    def test_duplicate_email_returns_409(self):
        # Create first user
        requests.post(f"{BASE_URL}/users", json={
            "name": "John", "email": "john@example.com"
        })

        # Try creating duplicate
        response = requests.post(f"{BASE_URL}/users", json={
            "name": "Jane", "email": "john@example.com"
        })

        assert response.status_code == 409

    def test_error_response_format(self):
        """Every error should return consistent structure"""
        response = requests.get(f"{BASE_URL}/users/nonexistent")

        assert response.status_code == 404
        error = response.json()
        assert "error" in error or "message" in error
        # No stack traces or internal details leaked
        assert "stackTrace" not in str(error)

Rate Limiting Testing

Most production APIs enforce rate limits. Test them.

def test_rate_limiting():
    """Verify rate limit headers and enforcement"""
    response = requests.get(f"{BASE_URL}/users")

    # Check rate limit headers exist
    assert "X-RateLimit-Limit" in response.headers
    assert "X-RateLimit-Remaining" in response.headers

def test_rate_limit_exceeded():
    """Hit the rate limit and verify 429 response"""
    for _ in range(110):  # Assuming 100 req/min limit
        requests.get(f"{BASE_URL}/users")

    response = requests.get(f"{BASE_URL}/users")
    assert response.status_code == 429
    assert "Retry-After" in response.headers

Performance Testing

Test API response times and throughput.

Basic Performance Check

import time

def test_response_time():
    start = time.time()
    response = requests.get(f"{BASE_URL}/users")
    duration = time.time() - start

    assert response.status_code == 200
    assert duration < 0.5  # Under 500ms

Load Testing with k6

// k6 script: load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 20 },   // Ramp to 20 users
    { duration: '1m', target: 20 },    // Stay at 20 users
    { duration: '10s', target: 0 },    // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95% under 500ms
    http_req_failed: ['rate<0.01'],    // Less than 1% failures
  },
};

export default function () {
  const response = http.get('https://api.example.com/users');

  check(response, {
    'status is 200': (r) => r.status === 200,
    'response time OK': (r) => r.timings.duration < 500,
  });

  sleep(1);
}
k6 run load-test.js

CI/CD Integration

GitHub Actions

# .github/workflows/api-tests.yml
name: API Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: pip install pytest requests jsonschema

      - name: Start API server
        run: |
          python -m app &
          sleep 5

      - name: Run API tests
        run: pytest tests/api/ -v --tb=short

      - name: Run Postman collection
        run: |
          npm install -g newman newman-reporter-htmlextra
          newman run collection.json -e environment.json \
            --reporters cli,htmlextra \
            --reporter-htmlextra-export report.html

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-reports
          path: report.html

API Testing Checklist

For Every Endpoint

  • Happy path returns correct status and data
  • Invalid input returns 400 with helpful message
  • Missing auth returns 401
  • Insufficient permissions return 403
  • Not found returns 404
  • Response matches JSON schema
  • Response time under threshold
  • Response headers are correct (Content-Type, CORS, cache)
  • Pagination works (limit, offset, cursors)

Security Tests

  • SQL injection payloads rejected
  • XSS payloads escaped in responses
  • Rate limiting works
  • Sensitive data not leaked in errors
  • CORS properly configured
  • IDOR — can’t access other users’ data by changing IDs

AI-Assisted API Testing

AI tools can accelerate API test development significantly in 2026.

What AI does well:

  • Generate test cases from OpenAPI/Swagger specs — paste the spec, get 50+ tests
  • Create valid and invalid test data for boundary testing
  • Write boilerplate for authentication flows and CRUD operations
  • Convert between frameworks (Postman scripts to pytest, REST Assured to Supertest)
  • Suggest edge cases: “What about empty arrays? Unicode characters? 10MB payloads?”

What still needs humans:

  • Understanding business requirements and domain constraints
  • Designing test strategy (which endpoints are critical?)
  • Debugging flaky tests caused by data dependencies
  • Interpreting performance results and setting thresholds
  • Deciding when a 500 error is a bug vs expected behavior

Useful prompt:

I have this REST API endpoint:
POST /api/orders
Body: { customerId: string, items: [{productId: string, quantity: number}], couponCode?: string }
Returns: { orderId: string, total: number, status: string }

Generate pytest test cases covering:
- Valid order with multiple items
- Empty items array
- Invalid customerId
- Negative quantity
- Invalid coupon code
- Valid coupon application
Include schema validation for the response.

FAQ

What is API testing?

API testing verifies that APIs work correctly by sending HTTP requests and validating responses. It tests functionality, data validation, error handling, authentication, and performance. Unlike UI testing, API testing directly tests the business logic layer, making it faster and more reliable. A typical API test takes milliseconds vs seconds for a browser test.

What tools are used for API testing?

Popular tools include:

  • Postman — GUI tool for manual testing and automation, best for learning
  • REST Assured — Java library with fluent BDD-style syntax
  • Supertest — Node.js/JavaScript API testing, integrates with Jest
  • requests + pytest — Python API testing, most flexible
  • k6 — Performance and load testing with JavaScript
  • Newman — CLI runner for Postman collections in CI/CD
  • Karate — BDD framework combining API and performance testing

What is the difference between API testing and unit testing?

Unit tests verify individual functions in isolation, mocking all dependencies. API tests verify complete HTTP endpoints, including routing, middleware, authentication, database operations, and response formatting. API tests are integration tests that verify components work together. Both are needed — unit tests for logic bugs, API tests for integration bugs.

How do I test authenticated APIs?

  1. Send login request with credentials
  2. Extract token from response
  3. Include token in Authorization header for subsequent requests
  4. Store token in environment variable for reuse
  5. Implement token refresh for expiring tokens
  6. Test both authenticated and unauthenticated scenarios
  7. Test role-based access — viewer can’t delete, admin can

How is REST API testing different from GraphQL testing?

REST uses multiple endpoints (GET /users, POST /users) with fixed response shapes. GraphQL uses a single endpoint (/graphql) where clients request specific fields. Key differences for testing: GraphQL returns 200 even for errors (check the errors array), you need to test query depth limits to prevent DoS, and you should validate that resolvers don’t cause N+1 database queries. REST testing is more straightforward — one endpoint, one test set.

How many API tests do I need?

For each endpoint: 1 happy path test, 2-3 negative tests (400, 401, 404), 1 schema validation test, and 1 auth test. A typical CRUD resource (5 endpoints) needs 15-20 tests. For an API with 20 resources, that’s 300-400 tests. Start with business-critical endpoints first — login, payment, core data — then expand coverage over time.

Official Resources

See Also