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
| Method | Purpose | Has Body | Idempotent |
|---|---|---|---|
| GET | Read data | No | Yes |
| POST | Create resource | Yes | No |
| PUT | Replace resource | Yes | Yes |
| PATCH | Partial update | Yes | No |
| DELETE | Remove resource | Optional | Yes |
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
- Open Postman
- Enter URL:
https://jsonplaceholder.typicode.com/posts/1 - Method: GET
- 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?
- Send login request with credentials
- Extract token from response
- Include token in Authorization header for subsequent requests
- Store token in environment variable for reuse
- Implement token refresh for expiring tokens
- Test both authenticated and unauthenticated scenarios
- 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
- Postman Tutorial - Complete guide to API testing with Postman
- REST Assured Tutorial - Java API testing with REST Assured
- GraphQL Testing Guide - Testing GraphQL APIs
- API Performance Testing - Load and stress testing APIs
- Contract Testing with Pact - Consumer-driven contract testing
- API Security Testing - OWASP API Security Top 10
- Karate API Testing - BDD-style API automation
- API Tools Comparison - Postman vs REST Assured vs alternatives
- gRPC API Testing - Testing gRPC services
- Test Automation Tutorial - Fundamentals of test automation
