API Testing
Validating HTTP APIs at the integration layer — below the UI, above the database.
Validating HTTP APIs at the integration layer. Below the UI, above the database. API tests are faster than E2E tests, more realistic than unit tests, and catch contract and data handling bugs that unit tests miss.
Why API Testing
The middle of the testing pyramid. APIs are:
- The contract between frontend and backend (or between microservices)
- The point where business logic is exercised without UI complexity
- The fastest way to test backend behaviour (no browser rendering)
- Stable interfaces — API tests survive UI redesigns
A suite of 200 API tests typically runs in under 60 seconds vs 20–30 minutes for equivalent E2E coverage.
What to Test in an API
For every endpoint, test:
Happy paths:
- Correct inputs → correct response (status, body, headers)
- All required fields present
- Optional fields with and without values
Negative paths:
- Missing required fields → 400 Bad Request
- Invalid types (string where int expected) → 400
- Authentication missing → 401 Unauthorized
- Valid auth but insufficient permissions → 403 Forbidden
- Non-existent resource → 404 Not Found
- Business rule violations → 422 Unprocessable Entity
Edge cases:
- Empty collections (
[]notnull) - Pagination with empty last page
- Maximum field lengths
- Special characters in string fields
- Concurrent requests to the same resource (if state mutation)
Response validation:
- HTTP status code correct
- Response body matches schema (not just spot-checking fields)
- Content-Type header is correct
- Sensitive fields absent (no password hash in GET /user response)
- IDs reference valid resources
REST Assured (Java)
The Java standard for API testing. Fluent DSL, integrates with JUnit/TestNG.
import io.restassured.RestAssured;
import io.restassured.response.Response;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
class ProductApiTest {
@BeforeEach
void setup() {
RestAssured.baseURI = "https://staging.api.example.com";
RestAssured.port = 443;
}
@Test
void getProduct_returnsCorrectStructure() {
given()
.header("Authorization", "Bearer " + getAuthToken())
.contentType("application/json")
.when()
.get("/products/123")
.then()
.statusCode(200)
.body("id", equalTo(123))
.body("name", notNullValue())
.body("price", greaterThan(0.0f))
.body("inStock", isA(Boolean.class))
.body("$", not(hasKey("internal_cost"))); // sensitive field absent
}
@Test
void createProduct_withMissingName_returns400() {
given()
.header("Authorization", "Bearer " + getAuthToken())
.contentType("application/json")
.body("""
{
"price": 29.99,
"category": "electronics"
}
""")
.when()
.post("/products")
.then()
.statusCode(400)
.body("error.code", equalTo("MISSING_REQUIRED_FIELD"))
.body("error.field", equalTo("name"));
}
@Test
void getProduct_withoutAuth_returns401() {
when()
.get("/products/123")
.then()
.statusCode(401);
}
}Response extraction for chaining:
// Create, then retrieve
String productId = given()
.header("Authorization", "Bearer " + token)
.contentType("application/json")
.body(createProductPayload())
.post("/products")
.then()
.statusCode(201)
.extract()
.path("id");
// Use the ID in a follow-up request
given()
.header("Authorization", "Bearer " + token)
.get("/products/" + productId)
.then()
.statusCode(200);Python (httpx + pytest)
import httpx
import pytest
BASE_URL = "https://staging.api.example.com"
@pytest.fixture(scope="session")
def auth_token():
response = httpx.post(f"{BASE_URL}/auth/token", json={
"email": "test@example.com",
"password": "testpassword"
})
return response.json()["access_token"]
@pytest.fixture
def client(auth_token):
return httpx.Client(
base_url=BASE_URL,
headers={"Authorization": f"Bearer {auth_token}"}
)
def test_get_product_returns_200(client):
response = client.get("/products/1")
assert response.status_code == 200
def test_get_product_response_schema(client):
response = client.get("/products/1")
body = response.json()
assert "id" in body
assert isinstance(body["price"], float)
assert "internal_cost" not in body
def test_create_product_missing_name_returns_400(client):
response = client.post("/products", json={"price": 9.99})
assert response.status_code == 400
error = response.json()["error"]
assert error["field"] == "name"
@pytest.mark.parametrize("price", [-1, 0, "free", None])
def test_create_product_invalid_price_returns_400(client, price):
response = client.post("/products", json={"name": "Widget", "price": price})
assert response.status_code == 400Schema Validation
Validate response shapes against a schema rather than spot-checking fields. Catches missing fields and wrong types that manual assertions miss.
JSON Schema (Python):
from jsonschema import validate
PRODUCT_SCHEMA = {
"type": "object",
"required": ["id", "name", "price", "inStock"],
"properties": {
"id": {"type": "integer"},
"name": {"type": "string", "minLength": 1},
"price": {"type": "number", "minimum": 0},
"inStock": {"type": "boolean"},
"description": {"type": "string"}
},
"additionalProperties": False # no extra fields allowed
}
def test_product_matches_schema(client):
response = client.get("/products/1")
validate(instance=response.json(), schema=PRODUCT_SCHEMA)Pydantic (Python):
from pydantic import BaseModel
from typing import Optional
class Product(BaseModel):
id: int
name: str
price: float
inStock: bool
description: Optional[str] = None
def test_product_schema_pydantic(client):
response = client.get("/products/1")
product = Product.model_validate(response.json()) # raises ValidationError if invalid
assert product.price >= 0GraphQL Testing
GRAPHQL_ENDPOINT = "https://api.example.com/graphql"
def test_get_user_graphql(client):
query = """
query GetUser($id: ID!) {
user(id: $id) {
id
email
profile {
displayName
}
}
}
"""
response = client.post(GRAPHQL_ENDPOINT, json={
"query": query,
"variables": {"id": "123"}
})
assert response.status_code == 200
data = response.json()
assert "errors" not in data
assert data["data"]["user"]["email"] is not NoneKey GraphQL test considerations:
- Always check
data.errors— GraphQL returns 200 even for errors - Test variables/arguments (required, optional, type coercion)
- Test nested resolvers
- Test N+1 query behaviour under multiple requests
Postman Collections for API Testing
For manual and exploratory API testing, Postman. For automated regression, prefer code (httpx/REST Assured). Code is version-controlled, more maintainable.
Postman is valuable for:
- Exploratory API testing (understanding undocumented behaviour)
- Sharing test collections across team members
- Newman CLI for CI where code-based tests don't exist yet
- API documentation (generate from collection)
API Testing in CI
# GitHub Actions — pytest API tests
- name: Run API tests
env:
BASE_URL: https://staging.api.example.com
API_KEY: ${{ secrets.STAGING_API_KEY }}
run: pytest tests/api/ -v --tb=short --junitxml=results/api-tests.xml
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: results/api-tests.xmlCommon Failure Cases
Test auth token expires mid-suite, causing a cascade of 401 failures
Why: the auth_token fixture is scoped to session but the token TTL is shorter than the suite runtime in CI.
Detect: tests pass in the first half of the run and fail with 401 in the second half; re-running from where it broke passes immediately.
Fix: either increase the test account's token TTL, or implement token refresh logic in the fixture using a yield-based approach that re-authenticates when a 401 is received.
Schema validation passes but the response contains a sensitive field
Why: additionalProperties: False in JSON Schema blocks unexpected fields, but only if the schema is applied — many teams use spot-check assertions that miss leaking fields entirely.
Detect: GET /user response includes password_hash or internal_cost but no test asserts their absence.
Fix: add explicit negative assertions (assert "internal_cost" not in body) or set additionalProperties: False in your Pydantic/JSON Schema model so unknown fields raise a validation error.
GraphQL test passes with status 200 but the operation actually failed
Why: GraphQL always returns HTTP 200; errors are in the response body under data.errors, not in the status code.
Detect: a query for a deleted resource returns {"data": null, "errors": [...]} with status 200 and the test doesn't check data.errors.
Fix: add assert "errors" not in response.json() (or assert data["errors"] is None) as a standard assertion in every GraphQL test, before checking the data field.
Parameterised negative tests all pass because the endpoint returns 400 for the wrong reason
Why: test_create_product_invalid_price_returns_400 confirms the status code is 400 but doesn't assert which field caused the error; the endpoint might be returning 400 due to a missing auth header rather than the invalid price.
Detect: remove the invalid-price constraint from the server and the test still passes.
Fix: assert both the status code and the specific error field in the response body, e.g., assert error["field"] == "price".
Connections
- technical-qa/contract-testing — Pact enforces API contracts between consumer and provider
- technical-qa/performance-testing — k6 uses HTTP requests; API testing patterns apply
- qa/qa-tools — Postman for exploratory API testing; REST Assured for automation
- qa/test-case-design — EP and BVA apply directly to API input validation
- web-frameworks/fastapi — FastAPI's TestClient for in-process API testing
Open Questions
- What is the most common failure mode when implementing this at scale?
- How does this testing approach need to adapt for distributed or microservice architectures?
Related reading