API Testing: The Complete Guide for Developers in 2026

Try Our HTTP Request Tester

Test APIs directly in your browser with our free HTTP Request Tester. Send GET, POST, PUT, PATCH, and DELETE requests, set custom headers and authentication, and inspect full responses -- no installation required.

1. Introduction to API Testing

APIs are the connective tissue of modern software. Every time you log into an app with Google, check the weather on your phone, or pay for something online, dozens of API calls happen behind the scenes. API testing is the practice of sending requests to these interfaces and verifying that the responses are correct, fast, and secure.

Unlike UI testing, which interacts with buttons and forms, API testing operates at the protocol level. You send an HTTP request with a specific method, URL, headers, and body, then validate the response status code, headers, and payload. This makes API tests faster, more reliable, and easier to automate than end-to-end browser tests.

API testing matters because:

Whether you are building a REST API, a GraphQL server, or a real-time WebSocket service, understanding how to test your APIs thoroughly is one of the most valuable skills a developer can have. This guide covers everything you need to know, from the fundamentals of HTTP methods to advanced topics like contract testing and API mocking.

2. Types of API Tests

Not all API tests serve the same purpose. A well-rounded testing strategy combines multiple types, each catching different categories of bugs at different stages of development.

Unit Tests

Unit tests verify that individual endpoints behave correctly in isolation. You test a single route handler or controller method, mocking any external dependencies like databases or third-party services.

// Example: Unit testing an Express route handler
const { getUser } = require('./handlers');

test('returns 404 when user not found', async () => {
    const req = { params: { id: 'nonexistent' } };
    const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };

    await getUser(req, res);

    expect(res.status).toHaveBeenCalledWith(404);
    expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
});

Unit tests are fast (no network calls), deterministic (no flaky external dependencies), and pinpoint exactly where failures occur. Every endpoint should have unit tests covering its happy path and error cases.

Integration Tests

Integration tests verify that multiple components work together correctly. You spin up the actual server, connect to a test database, and send real HTTP requests. These tests catch issues like incorrect database queries, misconfigured middleware, and serialization bugs.

// Example: Integration test with supertest
const request = require('supertest');
const app = require('./app');

test('POST /api/users creates a user and returns 201', async () => {
    const response = await request(app)
        .post('/api/users')
        .send({ name: 'Alice', email: 'alice@example.com' })
        .expect(201);

    expect(response.body).toMatchObject({
        name: 'Alice',
        email: 'alice@example.com'
    });
    expect(response.body.id).toBeDefined();
});

Contract Tests

Contract tests verify that the API's request and response schemas have not changed unexpectedly. When your API is consumed by multiple clients (web app, mobile app, third-party integrators), a schema change can break all of them simultaneously. Contract testing tools like Pact let consumers define the response shape they expect, and producers verify they still match.

// Pact consumer test example
const { Pact } = require('@pact-foundation/pact');

const provider = new Pact({
    consumer: 'WebApp',
    provider: 'UserService'
});

describe('User API contract', () => {
    it('returns user by ID', async () => {
        await provider.addInteraction({
            state: 'user with ID 1 exists',
            uponReceiving: 'a request for user 1',
            withRequest: {
                method: 'GET',
                path: '/api/users/1',
                headers: { Accept: 'application/json' }
            },
            willRespondWith: {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
                body: {
                    id: 1,
                    name: like('Alice'),
                    email: like('alice@example.com')
                }
            }
        });
    });
});

Load and Performance Tests

Load tests measure how your API performs under stress. You simulate hundreds or thousands of concurrent requests and measure response times, throughput, error rates, and resource consumption. Tools like k6, Artillery, and Apache JMeter are purpose-built for this.

// k6 load test script
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
    stages: [
        { duration: '30s', target: 50 },   // Ramp up to 50 users
        { duration: '1m',  target: 50 },   // Stay at 50 users
        { duration: '30s', target: 200 },  // Spike to 200 users
        { duration: '1m',  target: 200 },  // Stay at 200 users
        { duration: '30s', target: 0 },    // Ramp down
    ],
};

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

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

    sleep(1);
}

Security Tests

Security tests probe your API for vulnerabilities. This includes testing for SQL injection, broken authentication, excessive data exposure, missing rate limiting, and insecure direct object references (IDOR). Tools like OWASP ZAP and Burp Suite automate many of these checks, but manual testing is equally important.

Key security tests to run on every API:

3. HTTP Methods Deep Dive

HTTP methods (also called verbs) tell the server what action you want to perform on a resource. Understanding them is fundamental to both building and testing APIs.

GET -- Retrieve a Resource

GET requests retrieve data without modifying anything on the server. They are safe (no side effects) and idempotent (same request always returns the same result, assuming no other changes). GET requests should never have a request body.

# Fetch a list of users
curl -X GET https://api.example.com/users

# Fetch a single user
curl -X GET https://api.example.com/users/42

# Fetch with query parameters
curl -X GET "https://api.example.com/users?page=2&limit=10&sort=name"

When testing GET endpoints, verify: correct status codes (200 for success, 404 for missing resources), proper pagination, query parameter handling, and that sensitive data is not leaked in responses.

POST -- Create a Resource

POST requests create new resources. They are neither safe nor idempotent -- sending the same POST twice may create two resources. The request body contains the data for the new resource, and the response typically includes the created resource with its server-assigned ID.

# Create a new user
curl -X POST https://api.example.com/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "alice@example.com"}'

# Expected response: 201 Created
# {
#   "id": 42,
#   "name": "Alice",
#   "email": "alice@example.com",
#   "createdAt": "2026-02-11T10:30:00Z"
# }

Test POST endpoints for: validation errors (missing required fields, invalid formats), duplicate handling, proper 201 status codes, Location header pointing to the new resource, and that the response body matches what was sent.

PUT -- Replace a Resource

PUT replaces the entire resource with the provided data. If you omit a field, it should be removed or set to its default value. PUT is idempotent -- sending the same PUT request multiple times produces the same result.

# Replace user 42 entirely
curl -X PUT https://api.example.com/users/42 \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice Smith", "email": "alice.smith@example.com", "role": "admin"}'

A common testing mistake is sending a PUT with partial data and expecting it to work like PATCH. Always test that omitted fields are handled correctly (either rejected with 400 or set to defaults).

PATCH -- Partially Update a Resource

PATCH applies a partial modification to a resource. You send only the fields that need to change. This is more bandwidth-efficient than PUT and is the preferred method for most update operations.

# Update only the email
curl -X PATCH https://api.example.com/users/42 \
  -H "Content-Type: application/json" \
  -d '{"email": "newemail@example.com"}'

# JSON Patch format (RFC 6902)
curl -X PATCH https://api.example.com/users/42 \
  -H "Content-Type: application/json-patch+json" \
  -d '[{"op": "replace", "path": "/email", "value": "newemail@example.com"}]'

DELETE -- Remove a Resource

DELETE removes the specified resource. It is idempotent -- deleting the same resource twice should return 204 (or 404 the second time, depending on your design). Some APIs use soft deletes (marking records as deleted without removing them).

# Delete user 42
curl -X DELETE https://api.example.com/users/42

# Expected: 204 No Content (or 200 with body)

Test DELETE for: proper authorization (can only delete your own resources), cascade behavior (what happens to related data), and idempotency (second delete returns 404 or 204).

HEAD -- Get Headers Only

HEAD is identical to GET but returns only response headers, not the body. It is useful for checking if a resource exists, verifying content type, or reading cache headers without downloading the full response.

# Check if a resource exists
curl -I https://api.example.com/users/42

# HTTP/2 200
# Content-Type: application/json
# Content-Length: 256
# ETag: "abc123"
# Cache-Control: max-age=3600

OPTIONS -- Discover Allowed Methods

OPTIONS returns the HTTP methods that a resource supports. It is also used for CORS preflight requests, where the browser checks if a cross-origin request is permitted before sending the actual request.

# Check allowed methods
curl -X OPTIONS https://api.example.com/users

# HTTP/2 204
# Allow: GET, POST, OPTIONS
# Access-Control-Allow-Origin: https://myapp.com
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE
# Access-Control-Allow-Headers: Content-Type, Authorization
Method Purpose Safe Idempotent Has Body
GET Retrieve resource Yes Yes No
POST Create resource No No Yes
PUT Replace resource No Yes Yes
PATCH Partial update No Depends Yes
DELETE Remove resource No Yes Optional
HEAD Headers only Yes Yes No
OPTIONS Discover methods Yes Yes No

Test HTTP Methods Instantly

Use our HTTP Request Tester to send GET, POST, PUT, PATCH, and DELETE requests to any API. Set custom headers, authentication, and request bodies, then inspect the full response with status codes, headers, and timing.

4. Request Headers and Authentication

HTTP headers carry metadata about the request and response. Some headers control content negotiation, others handle caching, and several are critical for authentication and security.

Essential Request Headers

# Content negotiation
Content-Type: application/json          # What you're sending
Accept: application/json                # What you want back

# Caching
If-None-Match: "etag-value"            # Conditional GET
If-Modified-Since: Tue, 11 Feb 2026 10:00:00 GMT

# Client identification
User-Agent: MyApp/1.0
X-Request-ID: uuid-for-tracing

# CORS
Origin: https://myapp.com

Bearer Token Authentication

Bearer tokens are the most common authentication method for modern APIs. The client includes a token (usually a JWT) in the Authorization header. The server validates the token and extracts the user's identity from its claims.

# Bearer token in Authorization header
curl -X GET https://api.example.com/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

# Test: What happens with expired token?
# Expected: 401 Unauthorized with error message

# Test: What happens with malformed token?
# Expected: 401 Unauthorized

# Test: What happens with no token?
# Expected: 401 Unauthorized

When testing Bearer auth, decode the JWT to understand its claims. Use our JWT Decoder to inspect token payloads, expiration times, and signatures. Need to create test tokens? Try our JWT Generator.

API Key Authentication

API keys are simple tokens that identify the calling application. They are typically sent as a header or query parameter. API keys are best for server-to-server communication where OAuth would be overkill.

# API key in header (preferred)
curl -X GET https://api.example.com/data \
  -H "X-API-Key: sk_live_abc123def456"

# API key in query parameter (less secure -- visible in logs)
curl -X GET "https://api.example.com/data?api_key=sk_live_abc123def456"

Security tests for API keys: verify that invalid keys return 401 or 403, that keys cannot be used from unauthorized IP ranges, and that keys are not exposed in error messages or logs.

Basic Authentication

Basic Auth sends a base64-encoded username:password string in the Authorization header. It is simple but only secure over HTTPS, since base64 is encoding, not encryption.

# Basic Auth
curl -X GET https://api.example.com/data \
  -u "username:password"

# This is equivalent to:
curl -X GET https://api.example.com/data \
  -H "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ="

OAuth 2.0

OAuth 2.0 is a delegation framework that lets users grant third-party apps limited access to their accounts without sharing passwords. It uses access tokens obtained through authorization flows.

# Step 1: Redirect user to authorization endpoint
GET https://auth.example.com/authorize?
  response_type=code&
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https://yourapp.com/callback&
  scope=read:users write:users&
  state=random-csrf-token

# Step 2: Exchange authorization code for access token
curl -X POST https://auth.example.com/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=AUTH_CODE_FROM_CALLBACK" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "redirect_uri=https://yourapp.com/callback"

# Step 3: Use the access token
curl -X GET https://api.example.com/users/me \
  -H "Authorization: Bearer ACCESS_TOKEN_HERE"

# Step 4: Refresh when expired
curl -X POST https://auth.example.com/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=REFRESH_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID"

Testing OAuth flows requires verifying: state parameter validation (CSRF protection), scope enforcement, token expiration and refresh, token revocation, and that authorization codes are single-use.

5. Request/Response Body Formats

The body of an HTTP request or response carries the actual data. Different formats serve different purposes, and the Content-Type header tells the server and client how to parse the body.

JSON (application/json)

JSON is the dominant format for modern APIs. It is lightweight, human-readable, and natively supported by JavaScript. Nearly every programming language has built-in JSON parsing.

// Request
POST /api/users HTTP/1.1
Content-Type: application/json

{
    "name": "Alice",
    "email": "alice@example.com",
    "roles": ["admin", "editor"],
    "preferences": {
        "theme": "dark",
        "language": "en"
    }
}

// Response
HTTP/1.1 201 Created
Content-Type: application/json

{
    "id": 42,
    "name": "Alice",
    "email": "alice@example.com",
    "roles": ["admin", "editor"],
    "preferences": {
        "theme": "dark",
        "language": "en"
    },
    "createdAt": "2026-02-11T10:30:00Z"
}

When testing JSON APIs, validate the response structure using our JSON Validator and format complex responses with our JSON Formatter for easier reading.

XML (application/xml)

XML is common in legacy systems, SOAP APIs, and enterprise integrations. It is more verbose than JSON but supports schemas (XSD), namespaces, and attributes.

// Request
POST /api/users HTTP/1.1
Content-Type: application/xml

<user>
    <name>Alice</name>
    <email>alice@example.com</email>
    <roles>
        <role>admin</role>
        <role>editor</role>
    </roles>
</user>

Form Data (application/x-www-form-urlencoded)

URL-encoded form data is the format used by HTML forms. Keys and values are encoded into key=value pairs separated by ampersands. Special characters are percent-encoded.

# URL-encoded form data
curl -X POST https://api.example.com/login \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=alice&password=secret123&remember=true"

# The body is: username=alice&password=secret123&remember=true

Need to encode or decode URL parameters? Use our URL Encoder/Decoder to handle special characters correctly.

Multipart Form Data (multipart/form-data)

Multipart is used for file uploads and forms that combine text fields with binary data. Each part has its own Content-Type and content disposition.

# File upload with multipart
curl -X POST https://api.example.com/upload \
  -F "file=@document.pdf" \
  -F "description=Annual report" \
  -F "category=finance"

# Multiple files
curl -X POST https://api.example.com/upload \
  -F "files[]=@image1.png" \
  -F "files[]=@image2.png" \
  -F "album=vacation"

When testing multipart uploads, verify: file size limits, allowed MIME types, handling of malicious file names, and that the server correctly rejects files that exceed configured limits.

6. Status Codes and What They Mean

HTTP status codes tell the client whether a request succeeded, failed, or needs further action. They are grouped into five classes, each with a specific meaning.

2xx -- Success

Code Name When to Use
200 OK Standard success response for GET, PUT, PATCH
201 Created Resource successfully created (POST). Include Location header.
204 No Content Success with no response body (DELETE, PUT with no return)
206 Partial Content Range request fulfilled (file download resume)

3xx -- Redirection

Code Name When to Use
301 Moved Permanently Resource URL has changed permanently. Clients should update bookmarks.
302 Found Temporary redirect. Often used in OAuth flows.
304 Not Modified Resource hasn't changed since last request (caching).
307 Temporary Redirect Like 302, but preserves the HTTP method.
308 Permanent Redirect Like 301, but preserves the HTTP method.

4xx -- Client Errors

Code Name When to Use
400 Bad Request Malformed request syntax, invalid JSON, missing required fields
401 Unauthorized Missing or invalid authentication credentials
403 Forbidden Authenticated but not authorized (insufficient permissions)
404 Not Found Resource does not exist at this URL
405 Method Not Allowed HTTP method not supported for this resource (e.g., DELETE on /users)
409 Conflict Request conflicts with current state (duplicate email, version mismatch)
422 Unprocessable Entity Valid JSON but semantic errors (invalid email format, age is negative)
429 Too Many Requests Rate limit exceeded. Include Retry-After header.

5xx -- Server Errors

Code Name When to Use
500 Internal Server Error Unexpected server failure. Never expose stack traces to clients.
502 Bad Gateway Upstream server returned an invalid response
503 Service Unavailable Server is temporarily down (maintenance, overload). Include Retry-After.
504 Gateway Timeout Upstream server did not respond in time

When testing, always verify that your API returns the correct status code for each scenario. A common mistake is returning 200 for everything and embedding the error in the response body. Proper status codes enable clients to handle errors programmatically without parsing the body.

For a deeper reference on all HTTP status codes, read our HTTP Status Codes Explained blog post.

7. Tools for API Testing

The right tool depends on your workflow. Here is a comparison of the most popular options in 2026.

curl -- The Command-Line Standard

curl is installed on virtually every Unix system and is the universal language for describing HTTP requests. Every developer should know the basics.

# GET request
curl https://api.example.com/users

# POST with JSON body
curl -X POST https://api.example.com/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"name": "Alice", "email": "alice@example.com"}'

# See response headers
curl -i https://api.example.com/users

# Verbose output (debug TLS, redirects, timing)
curl -v https://api.example.com/users

# Follow redirects
curl -L https://api.example.com/old-endpoint

# Save response to file
curl -o response.json https://api.example.com/users

# Upload a file
curl -X POST https://api.example.com/upload \
  -F "file=@document.pdf"

# Measure response time
curl -o /dev/null -s -w "Time: %{time_total}s\n" https://api.example.com/users

Postman

Postman is the most popular GUI tool for API development and testing. It supports collections (groups of requests), environments (variable sets for dev/staging/prod), pre-request scripts, test assertions, and team collaboration. Its scripting capabilities make it powerful for complex testing workflows.

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

pm.test("Response has correct structure", function () {
    const json = pm.response.json();
    pm.expect(json).to.have.property('id');
    pm.expect(json).to.have.property('name');
    pm.expect(json.email).to.include('@');
});

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

// Save token from login response to environment
const json = pm.response.json();
pm.environment.set("auth_token", json.token);

Insomnia

Insomnia is a lightweight alternative to Postman with a clean, developer-focused interface. It supports REST, GraphQL, gRPC, and WebSocket testing. Its environment chaining and plugin system make it highly customizable.

Thunder Client (VS Code)

Thunder Client brings API testing directly into VS Code. It is perfect for developers who prefer staying in their editor and do not need the full feature set of Postman. It supports collections, environments, and basic scripting.

DevToolbox HTTP Tester

For quick, no-install API testing, our HTTP Request Tester runs entirely in your browser. It supports all HTTP methods, custom headers, authentication, request bodies, and displays the full response including status, headers, body, and timing. No accounts, no downloads, no data sent to third parties.

Tool Type Best For Price
curl CLI Scripting, CI/CD, quick checks Free
Postman GUI Team collaboration, complex workflows Free / Paid
Insomnia GUI Lightweight testing, GraphQL Free / Paid
Thunder Client VS Code In-editor testing, individual devs Free / Paid
HTTP Tester Browser Quick tests, no install, privacy Free

8. Writing API Tests

Automated API tests belong in your CI/CD pipeline. They run on every commit, catching regressions before they reach production. Here is how to write them in the three most popular testing ecosystems.

JavaScript: Jest + Supertest

Supertest provides a fluent API for making HTTP requests against an Express app without starting a real server.

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

describe('Users API', () => {
    let authToken;

    beforeAll(async () => {
        await db.migrate();
        await db.seed();

        // Get auth token
        const loginRes = await request(app)
            .post('/api/auth/login')
            .send({ email: 'admin@test.com', password: 'password' });
        authToken = loginRes.body.token;
    });

    afterAll(async () => {
        await db.cleanup();
    });

    describe('GET /api/users', () => {
        it('returns a list of users', async () => {
            const res = await request(app)
                .get('/api/users')
                .set('Authorization', `Bearer ${authToken}`)
                .expect('Content-Type', /json/)
                .expect(200);

            expect(res.body).toBeInstanceOf(Array);
            expect(res.body.length).toBeGreaterThan(0);
            expect(res.body[0]).toHaveProperty('id');
            expect(res.body[0]).toHaveProperty('name');
            expect(res.body[0]).toHaveProperty('email');
            // Sensitive fields should NOT be exposed
            expect(res.body[0]).not.toHaveProperty('password');
        });

        it('supports pagination', async () => {
            const res = await request(app)
                .get('/api/users?page=1&limit=5')
                .set('Authorization', `Bearer ${authToken}`)
                .expect(200);

            expect(res.body.length).toBeLessThanOrEqual(5);
        });

        it('returns 401 without authentication', async () => {
            await request(app)
                .get('/api/users')
                .expect(401);
        });
    });

    describe('POST /api/users', () => {
        it('creates a new user', async () => {
            const newUser = {
                name: 'Test User',
                email: 'test@example.com',
                password: 'SecurePass123!'
            };

            const res = await request(app)
                .post('/api/users')
                .set('Authorization', `Bearer ${authToken}`)
                .send(newUser)
                .expect(201);

            expect(res.body.name).toBe(newUser.name);
            expect(res.body.email).toBe(newUser.email);
            expect(res.body).not.toHaveProperty('password');
            expect(res.headers.location).toMatch(/\/api\/users\/\d+/);
        });

        it('returns 400 for invalid email', async () => {
            const res = await request(app)
                .post('/api/users')
                .set('Authorization', `Bearer ${authToken}`)
                .send({ name: 'Test', email: 'not-an-email', password: 'pass' })
                .expect(400);

            expect(res.body.errors).toBeDefined();
        });

        it('returns 409 for duplicate email', async () => {
            await request(app)
                .post('/api/users')
                .set('Authorization', `Bearer ${authToken}`)
                .send({ name: 'Dup', email: 'admin@test.com', password: 'pass' })
                .expect(409);
        });
    });
});

Python: Pytest + Requests

import pytest
import requests

BASE_URL = "http://localhost:8000/api"

@pytest.fixture(scope="session")
def auth_headers():
    """Get authentication token."""
    response = requests.post(f"{BASE_URL}/auth/login", json={
        "email": "admin@test.com",
        "password": "password"
    })
    token = response.json()["token"]
    return {"Authorization": f"Bearer {token}"}


class TestUsersAPI:
    def test_list_users(self, auth_headers):
        response = requests.get(f"{BASE_URL}/users", headers=auth_headers)

        assert response.status_code == 200
        data = response.json()
        assert isinstance(data, list)
        assert len(data) > 0
        assert "id" in data[0]
        assert "name" in data[0]
        assert "password" not in data[0]  # No sensitive fields

    def test_create_user(self, auth_headers):
        new_user = {
            "name": "Test User",
            "email": "pytest-user@example.com",
            "password": "SecurePass123!"
        }
        response = requests.post(
            f"{BASE_URL}/users",
            json=new_user,
            headers=auth_headers
        )

        assert response.status_code == 201
        data = response.json()
        assert data["name"] == new_user["name"]
        assert data["email"] == new_user["email"]
        assert "password" not in data

    def test_create_user_invalid_email(self, auth_headers):
        response = requests.post(
            f"{BASE_URL}/users",
            json={"name": "Test", "email": "invalid", "password": "pass"},
            headers=auth_headers
        )

        assert response.status_code == 400
        assert "errors" in response.json()

    def test_get_user_not_found(self, auth_headers):
        response = requests.get(
            f"{BASE_URL}/users/99999",
            headers=auth_headers
        )

        assert response.status_code == 404

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

        assert response.status_code == 401

    def test_response_time(self, auth_headers):
        response = requests.get(f"{BASE_URL}/users", headers=auth_headers)

        assert response.elapsed.total_seconds() < 1.0  # Under 1 second

Go: net/http/httptest

package api_test

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "myapp/api"
)

func TestGetUsers(t *testing.T) {
    router := api.NewRouter()

    req := httptest.NewRequest(http.MethodGet, "/api/users", nil)
    req.Header.Set("Authorization", "Bearer "+getTestToken())
    w := httptest.NewRecorder()

    router.ServeHTTP(w, req)

    if w.Code != http.StatusOK {
        t.Errorf("expected status 200, got %d", w.Code)
    }

    var users []api.User
    if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil {
        t.Fatalf("failed to parse response: %v", err)
    }

    if len(users) == 0 {
        t.Error("expected at least one user")
    }

    for _, u := range users {
        if u.Password != "" {
            t.Error("password should not be in response")
        }
    }
}

func TestCreateUser(t *testing.T) {
    router := api.NewRouter()

    body := map[string]string{
        "name":     "Test User",
        "email":    "gotest@example.com",
        "password": "SecurePass123!",
    }
    jsonBody, _ := json.Marshal(body)

    req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(jsonBody))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+getTestToken())
    w := httptest.NewRecorder()

    router.ServeHTTP(w, req)

    if w.Code != http.StatusCreated {
        t.Errorf("expected status 201, got %d", w.Code)
    }

    var user api.User
    json.Unmarshal(w.Body.Bytes(), &user)

    if user.Name != "Test User" {
        t.Errorf("expected name 'Test User', got '%s'", user.Name)
    }

    location := w.Header().Get("Location")
    if location == "" {
        t.Error("expected Location header")
    }
}

func TestUnauthorized(t *testing.T) {
    router := api.NewRouter()

    req := httptest.NewRequest(http.MethodGet, "/api/users", nil)
    w := httptest.NewRecorder()

    router.ServeHTTP(w, req)

    if w.Code != http.StatusUnauthorized {
        t.Errorf("expected status 401, got %d", w.Code)
    }
}

9. API Mocking and Stubbing

API mocking creates fake implementations of external APIs so your tests do not depend on real services. This eliminates network flakiness, reduces test execution time, and lets you test edge cases that are difficult to reproduce with real APIs (timeouts, 500 errors, rate limits).

Why Mock APIs?

JavaScript: nock

nock intercepts HTTP requests at the Node.js level, returning predefined responses without hitting the network.

const nock = require('nock');
const { fetchUserProfile } = require('./userService');

describe('User Service', () => {
    afterEach(() => {
        nock.cleanAll();
    });

    it('fetches user profile from external API', async () => {
        nock('https://external-api.com')
            .get('/users/42')
            .reply(200, {
                id: 42,
                name: 'Alice',
                email: 'alice@example.com'
            });

        const user = await fetchUserProfile(42);
        expect(user.name).toBe('Alice');
    });

    it('handles external API timeout', async () => {
        nock('https://external-api.com')
            .get('/users/42')
            .delayConnection(5000)  // Simulate 5s delay
            .reply(200, {});

        await expect(fetchUserProfile(42)).rejects.toThrow('timeout');
    });

    it('handles external API error', async () => {
        nock('https://external-api.com')
            .get('/users/42')
            .reply(500, { error: 'Internal Server Error' });

        await expect(fetchUserProfile(42)).rejects.toThrow('Server error');
    });

    it('handles rate limiting', async () => {
        nock('https://external-api.com')
            .get('/users/42')
            .reply(429, { error: 'Too Many Requests' }, {
                'Retry-After': '60'
            });

        await expect(fetchUserProfile(42)).rejects.toThrow('Rate limited');
    });
});

Python: responses

import responses
import requests

@responses.activate
def test_fetch_user():
    responses.add(
        responses.GET,
        "https://external-api.com/users/42",
        json={"id": 42, "name": "Alice"},
        status=200
    )

    response = requests.get("https://external-api.com/users/42")
    assert response.json()["name"] == "Alice"

@responses.activate
def test_external_api_error():
    responses.add(
        responses.GET,
        "https://external-api.com/users/42",
        json={"error": "Internal Server Error"},
        status=500
    )

    response = requests.get("https://external-api.com/users/42")
    assert response.status_code == 500

Mock Servers

For more complex scenarios, standalone mock servers simulate entire APIs. Tools like WireMock, MockServer, and json-server let you define endpoints, response delays, and conditional logic.

# json-server: instant REST API from a JSON file
# Install: npm install -g json-server

# db.json
{
    "users": [
        { "id": 1, "name": "Alice", "email": "alice@example.com" },
        { "id": 2, "name": "Bob", "email": "bob@example.com" }
    ],
    "posts": [
        { "id": 1, "userId": 1, "title": "Hello World", "body": "..." }
    ]
}

# Start mock server
json-server --watch db.json --port 3001

# Now you have a full REST API:
# GET    /users
# GET    /users/1
# POST   /users
# PUT    /users/1
# PATCH  /users/1
# DELETE /users/1

10. Rate Limiting and Retries

Rate limiting protects APIs from abuse and ensures fair usage across all clients. As a tester and consumer of APIs, you need to understand how rate limits work and how to handle them gracefully.

Common Rate Limiting Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 100          # Max requests per window
X-RateLimit-Remaining: 42       # Requests remaining
X-RateLimit-Reset: 1707648000   # Unix timestamp when limit resets

# When rate limited:
HTTP/1.1 429 Too Many Requests
Retry-After: 60                  # Wait 60 seconds before retrying
X-RateLimit-Remaining: 0

Implementing Exponential Backoff

When you hit a rate limit (or any transient error), retrying immediately will just fail again. Exponential backoff increases the delay between retries, reducing load on the server and improving your chances of success.

// JavaScript: Exponential backoff with jitter
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            const response = await fetch(url, options);

            if (response.status === 429) {
                const retryAfter = response.headers.get('Retry-After');
                const delay = retryAfter
                    ? parseInt(retryAfter) * 1000
                    : Math.pow(2, attempt) * 1000 + Math.random() * 1000;

                console.log(`Rate limited. Retrying in ${delay}ms...`);
                await new Promise(resolve => setTimeout(resolve, delay));
                continue;
            }

            if (response.status >= 500 && attempt < maxRetries) {
                const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
                console.log(`Server error. Retrying in ${delay}ms...`);
                await new Promise(resolve => setTimeout(resolve, delay));
                continue;
            }

            return response;
        } catch (error) {
            if (attempt === maxRetries) throw error;
            const delay = Math.pow(2, attempt) * 1000;
            await new Promise(resolve => setTimeout(resolve, delay));
        }
    }
}
# Python: Exponential backoff with tenacity
from tenacity import retry, stop_after_attempt, wait_exponential
import requests

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=60)
)
def call_api(url, headers=None):
    response = requests.get(url, headers=headers)
    if response.status_code == 429:
        raise Exception("Rate limited")
    if response.status_code >= 500:
        raise Exception(f"Server error: {response.status_code}")
    return response

Testing Rate Limit Handling

Your tests should verify that your application handles rate limits correctly:

11. API Documentation (OpenAPI/Swagger)

The OpenAPI Specification (formerly Swagger) is the industry standard for describing REST APIs. It defines your endpoints, request/response schemas, authentication methods, and examples in a machine-readable format (YAML or JSON) that powers auto-generated documentation, client SDKs, and test scaffolding.

OpenAPI 3.1 Example

openapi: 3.1.0
info:
  title: User Management API
  version: 1.0.0
  description: API for managing users and their profiles

servers:
  - url: https://api.example.com/v1

paths:
  /users:
    get:
      summary: List all users
      operationId: listUsers
      tags: [Users]
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        '200':
          description: List of users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      summary: Create a user
      operationId: createUser
      tags: [Users]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          headers:
            Location:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          description: Email already exists

components:
  schemas:
    User:
      type: object
      required: [id, name, email]
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email
        createdAt:
          type: string
          format: date-time

    CreateUserRequest:
      type: object
      required: [name, email, password]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        email:
          type: string
          format: email
        password:
          type: string
          minLength: 8

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  responses:
    Unauthorized:
      description: Missing or invalid authentication
    BadRequest:
      description: Invalid request body

Generating Tests from OpenAPI Specs

Tools like Schemathesis and Dredd can automatically generate API tests from your OpenAPI specification, testing every endpoint with valid and invalid data:

# Schemathesis: Fuzz test from OpenAPI spec
pip install schemathesis

# Run automated tests against your spec
schemathesis run https://api.example.com/openapi.json \
  --hypothesis-max-examples=100 \
  --checks all

# This will:
# - Test every endpoint with random valid data
# - Test with boundary values
# - Check for 500 errors
# - Verify response schemas match the spec
# - Check content-type headers

Swagger UI

Swagger UI renders your OpenAPI spec as interactive documentation where developers can read descriptions, see schemas, and send test requests directly from the browser. It is the fastest way to let consumers explore your API.

12. Common Mistakes and Debugging Tips

Years of building and testing APIs reveal the same mistakes recurring across teams. Avoiding these will save you hours of debugging.

Mistake 1: Only Testing the Happy Path

The most common sin in API testing. You test that POST /users works with valid data, but never test what happens with missing fields, invalid types, empty strings, extremely long strings, special characters, or SQL injection payloads.

Fix: For every endpoint, test at minimum: valid input, each required field missing, invalid data types, boundary values (empty string, max length, negative numbers, zero), and unauthorized access.

Mistake 2: Hardcoding URLs and Tokens

// BAD: Hardcoded values
const response = await fetch('http://localhost:3000/api/users', {
    headers: { 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9...' }
});

// GOOD: Environment variables
const response = await fetch(`${process.env.API_BASE_URL}/api/users`, {
    headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` }
});

Mistake 3: Not Validating Response Schemas

Checking that the status code is 200 is not enough. You need to verify the response body structure, data types, and required fields. A 200 response with missing or incorrect data is worse than a 500 because it silently corrupts downstream systems.

// Use JSON Schema validation
const Ajv = require('ajv');
const ajv = new Ajv();

const userSchema = {
    type: 'object',
    required: ['id', 'name', 'email'],
    properties: {
        id: { type: 'integer' },
        name: { type: 'string', minLength: 1 },
        email: { type: 'string', format: 'email' },
        createdAt: { type: 'string', format: 'date-time' }
    },
    additionalProperties: false  // Catch unexpected fields
};

test('response matches schema', async () => {
    const res = await request(app).get('/api/users/1').expect(200);
    const validate = ajv.compile(userSchema);
    const valid = validate(res.body);
    expect(valid).toBe(true);
    if (!valid) console.log(validate.errors);
});

Mistake 4: Ignoring Response Time

An API that returns correct data but takes 10 seconds is broken. Include performance assertions in your tests, and set up monitoring to alert when response times degrade.

Mistake 5: Testing Against Production

Running automated tests against production APIs can create real data, trigger real notifications, and consume real resources. Always use dedicated test environments with isolated databases.

Mistake 6: Not Cleaning Up Test Data

Tests that create data without cleaning up pollute the database and cause other tests to fail unpredictably. Use setup/teardown hooks to ensure each test starts with a known state.

Debugging Tips

Debug API Responses

When debugging, format messy JSON responses with our JSON Formatter, validate JSON structure with our JSON Validator, and decode JWTs with our JWT Decoder.

13. GraphQL Testing

GraphQL changes the testing paradigm. Instead of multiple endpoints with fixed response shapes, you have a single /graphql endpoint that accepts queries specifying exactly what data to return. This flexibility is powerful but introduces new testing challenges.

Query Testing

# Basic GraphQL query
curl -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "query": "query GetUser($id: ID!) { user(id: $id) { id name email posts { title createdAt } } }",
    "variables": { "id": "42" }
  }'

# Response
{
    "data": {
        "user": {
            "id": "42",
            "name": "Alice",
            "email": "alice@example.com",
            "posts": [
                { "title": "Hello World", "createdAt": "2026-02-11" }
            ]
        }
    }
}

Mutation Testing

# GraphQL mutation
curl -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email } }",
    "variables": {
        "input": {
            "name": "Alice",
            "email": "alice@example.com",
            "password": "SecurePass123!"
        }
    }
  }'

What to Test in GraphQL APIs

// Jest test for GraphQL
const request = require('supertest');
const app = require('./app');

describe('GraphQL API', () => {
    it('fetches user with selected fields', async () => {
        const query = `
            query {
                user(id: "1") {
                    id
                    name
                    email
                }
            }
        `;

        const res = await request(app)
            .post('/graphql')
            .send({ query })
            .expect(200);

        expect(res.body.data.user).toMatchObject({
            id: '1',
            name: expect.any(String),
            email: expect.any(String)
        });
        expect(res.body.errors).toBeUndefined();
    });

    it('rejects deeply nested queries', async () => {
        const query = `
            query {
                user(id: "1") {
                    posts {
                        author {
                            posts {
                                author {
                                    posts {
                                        title
                                    }
                                }
                            }
                        }
                    }
                }
            }
        `;

        const res = await request(app)
            .post('/graphql')
            .send({ query })
            .expect(200);

        expect(res.body.errors).toBeDefined();
        expect(res.body.errors[0].message).toContain('depth');
    });
});

14. WebSocket Testing Basics

WebSockets provide full-duplex communication between client and server over a single TCP connection. Unlike HTTP's request-response model, WebSockets allow the server to push data to the client at any time. They are used for real-time features like chat, live dashboards, multiplayer games, and collaborative editing.

WebSocket Lifecycle

  1. Handshake: Client sends an HTTP Upgrade request. Server responds with 101 Switching Protocols.
  2. Open: Connection established. Both sides can send messages.
  3. Message: Text or binary frames sent in either direction.
  4. Close: Either side sends a close frame. Connection terminates.

Testing WebSocket Connections

// JavaScript: Testing WebSocket with ws library
const WebSocket = require('ws');

describe('WebSocket API', () => {
    let ws;

    beforeEach((done) => {
        ws = new WebSocket('ws://localhost:8080/ws');
        ws.on('open', done);
    });

    afterEach(() => {
        if (ws.readyState === WebSocket.OPEN) {
            ws.close();
        }
    });

    it('receives welcome message on connect', (done) => {
        ws.on('message', (data) => {
            const message = JSON.parse(data);
            expect(message.type).toBe('welcome');
            expect(message.connectionId).toBeDefined();
            done();
        });
    });

    it('echoes messages back', (done) => {
        ws.send(JSON.stringify({ type: 'echo', content: 'Hello' }));

        ws.on('message', (data) => {
            const message = JSON.parse(data);
            if (message.type === 'echo_response') {
                expect(message.content).toBe('Hello');
                done();
            }
        });
    });

    it('handles invalid JSON gracefully', (done) => {
        ws.send('not valid json');

        ws.on('message', (data) => {
            const message = JSON.parse(data);
            expect(message.type).toBe('error');
            expect(message.message).toContain('Invalid JSON');
            done();
        });
    });

    it('disconnects after authentication timeout', (done) => {
        // Don't send auth message
        ws.on('close', (code, reason) => {
            expect(code).toBe(4001);
            expect(reason.toString()).toContain('Authentication timeout');
            done();
        });
    });
});

Python: websockets Library

import asyncio
import json
import pytest
import websockets

@pytest.mark.asyncio
async def test_websocket_connection():
    async with websockets.connect("ws://localhost:8080/ws") as ws:
        # Receive welcome message
        message = json.loads(await ws.recv())
        assert message["type"] == "welcome"

        # Send a message
        await ws.send(json.dumps({"type": "ping"}))

        # Receive response
        response = json.loads(await ws.recv())
        assert response["type"] == "pong"

@pytest.mark.asyncio
async def test_websocket_broadcast():
    async with websockets.connect("ws://localhost:8080/ws") as ws1, \
               websockets.connect("ws://localhost:8080/ws") as ws2:

        # Skip welcome messages
        await ws1.recv()
        await ws2.recv()

        # Client 1 sends a broadcast message
        await ws1.send(json.dumps({
            "type": "broadcast",
            "content": "Hello everyone"
        }))

        # Client 2 should receive it
        message = json.loads(await ws2.recv())
        assert message["type"] == "broadcast"
        assert message["content"] == "Hello everyone"

What to Test in WebSocket APIs

15. Frequently Asked Questions

What is API testing and why is it important?

API testing is the process of verifying that an application programming interface (API) works correctly, reliably, and securely. It is important because APIs are the backbone of modern software architecture, connecting frontends to backends, microservices to each other, and third-party integrations. Without thorough API testing, bugs in one service can cascade across an entire system.

What are the main types of API tests?

The main types of API tests are: unit tests (testing individual endpoints in isolation), integration tests (testing how multiple APIs work together), contract tests (verifying API schemas remain compatible), load/performance tests (measuring throughput and response times under stress), and security tests (checking for vulnerabilities like injection, broken authentication, and data exposure).

What is the difference between PUT and PATCH HTTP methods?

PUT replaces the entire resource with the data you send, meaning you must include all fields even if only one changed. PATCH applies a partial update, allowing you to send only the fields that need to change. PUT is idempotent (same request always produces same result), and PATCH can also be idempotent depending on implementation. Use PUT when replacing an entire document and PATCH for small field-level updates.

What tools can I use for API testing?

Popular API testing tools include curl (command-line), Postman (GUI with collections and environments), Insomnia (lightweight REST client), Thunder Client (VS Code extension), and browser-based tools like the DevToolbox HTTP Tester. For automated testing, frameworks like Jest with supertest (JavaScript), pytest with requests (Python), and net/http/httptest (Go) are widely used.

How do I authenticate API requests?

Common API authentication methods include Bearer tokens (sent in the Authorization header), API keys (sent as a header or query parameter), Basic Auth (base64-encoded username:password), and OAuth 2.0 (token-based flows for delegated authorization). The right method depends on your use case: API keys for server-to-server, OAuth for user-facing apps, and Bearer tokens for stateless APIs.

What is the difference between REST and GraphQL for API testing?

REST uses multiple endpoints with fixed data structures (one URL per resource), while GraphQL uses a single endpoint where clients specify exactly what data they need via queries. Testing REST involves verifying each endpoint with different HTTP methods, while testing GraphQL requires validating queries, mutations, variables, and schema introspection. GraphQL can reduce over-fetching but adds complexity in query validation and depth limiting.

How do I handle rate limiting in API tests?

To handle rate limiting in API tests: check response headers (X-RateLimit-Remaining, Retry-After), implement exponential backoff for retries, use separate test API keys with higher limits, mock rate-limited responses in unit tests, and add delays between requests in integration tests. Always test that your application gracefully handles 429 Too Many Requests responses.

What are common API testing mistakes to avoid?

Common API testing mistakes include: only testing happy paths (ignoring error cases), hardcoding test data instead of using environment variables, not validating response schemas, ignoring response times and performance, testing against production APIs, not cleaning up test data, skipping authentication and authorization tests, and not testing edge cases like empty arrays, null values, and special characters.

Conclusion

API testing is not a single activity but a layered discipline. Unit tests catch logic errors in individual handlers. Integration tests verify that your services work together with real databases and middleware. Contract tests protect consumers from breaking changes. Load tests reveal performance bottlenecks before your users do. Security tests guard against the OWASP API Top 10.

The key to success is building API testing into your development workflow from day one. Write tests as you build endpoints. Run them in CI on every commit. Monitor response times and error rates in production. Document your APIs with OpenAPI specs and keep them synchronized with the implementation.

Whether you are debugging a flaky 502 with curl, validating a JWT with our decoder, or load-testing a new endpoint with k6, the techniques in this guide give you the foundation to test APIs at every level of the stack. Start with the basics -- send a request, check the response -- and build from there.

Start Testing APIs Now

Put this guide into practice with our HTTP Request Tester. Send requests to any API, set custom headers and authentication, and inspect the full response -- all in your browser with zero setup. Pair it with our JSON Formatter, JSON Validator, JWT Decoder, and URL Encoder for a complete API debugging toolkit.