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, 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)

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

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.

This tutorial teaches API testing from scratch — HTTP basics, REST conventions, authentication, error handling, 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?
  • 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

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

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

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

Request 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"
}

Response Structure

HTTP/1.1 201 Created
Content-Type: application/json
X-Request-Id: abc123

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

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 and Variables

Organize tests into collections:

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

// Use variables in URL
// {{baseUrl}}/users/{{userId}}

// Use in headers
// Authorization: Bearer {{token}}

REST API Testing with Code

Python with requests

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)

    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

    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()

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));
    }

    @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());
    }
}

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

GraphQL Testing

GraphQL uses a single endpoint with queries and mutations.

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"

Error Testing

Test how your API handles problems.

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):
        response = requests.get(f"{BASE_URL}/users/nonexistent")

        assert response.status_code == 404
        error = response.json()
        assert "error" in error or "message" in error

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

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: pip install pytest requests

      - name: Run API tests
        run: pytest tests/api/ -v

      - name: Run Postman collection
        run: |
          npm install -g newman
          newman run collection.json -e environment.json

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 time under threshold
  • Response headers are correct
  • Response body matches schema

Security Tests

  • SQL injection payloads rejected
  • XSS payloads escaped in responses
  • Rate limiting works
  • Sensitive data not leaked in errors
  • CORS properly configured

AI-Assisted API Testing

AI tools can accelerate API test development.

What AI does well:

  • Generate test cases from OpenAPI/Swagger specs
  • Create valid and invalid test data
  • Write boilerplate for common patterns
  • Suggest edge cases to test

What still needs humans:

  • Understanding business requirements
  • Designing test strategy
  • Debugging flaky tests
  • Interpreting performance results

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 test cases covering:
- Valid order with multiple items
- Empty items array
- Invalid customerId
- Negative quantity
- Invalid coupon code
- Valid coupon application

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.

What tools are used for API testing?

Popular tools include:

  • Postman — GUI tool for manual testing and automation
  • REST Assured — Java library for API testing
  • Supertest — Node.js/JavaScript API testing
  • requests + pytest — Python API testing
  • k6 — Performance and load testing
  • Newman — CLI runner for Postman collections

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 for comprehensive coverage.

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

Official Resources

See Also