API Testing
Behind almost every app screen sits an API (application programming interface): the server endpoints the app calls to log in, load products or place an order. Testing the API directly is faster and more stable than testing through the screens, and it finds problems before any UI exists. That is why API tests sit in the middle of the test pyramid.
In this lesson you will learn what an HTTP request and response contain, what to check in an API test, and how to test with JavaScript fetch, Postman and Python requests.
Requests and responses
A REST API works over HTTP. The client sends a request with a method, a URL, headers and sometimes a body. The server answers with a response containing a status code, headers and usually a JSON body. Here is a raw exchange that creates a post:
POST /posts HTTP/1.1
Host: jsonplaceholder.typicode.com
Content-Type: application/json
{"title": "Exam timetable", "body": "Starts 4 March", "userId": 1}HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{"title": "Exam timetable", "body": "Starts 4 March", "userId": 1, "id": 101}The common methods are GET (read), POST (create), PUT (replace), PATCH (change some fields) and DELETE (remove). The status code tells you the outcome:
| Code | Meaning | When to expect it |
|---|---|---|
| 200 OK | Success | A normal GET or update |
| 201 Created | A new resource was created | A successful POST |
| 204 No Content | Success with no body | Often after DELETE |
| 400 Bad Request | The request is invalid | Missing or malformed fields |
| 401 Unauthorized | Not logged in, or a bad token | Calling without credentials |
| 403 Forbidden | Logged in but not allowed | A customer calling an admin endpoint |
| 404 Not Found | The resource does not exist | A wrong ID |
| 409 Conflict | Clashes with current state | Registering an email twice |
| 422 Unprocessable Content | Well-formed but fails validation | Age of -5 |
| 500 Internal Server Error | The server crashed | Never acceptable for bad input |
A 500 response to invalid input is always a bug: the server should answer with a 4xx code and a helpful message.
What to check in an API test
- Status code: the exact expected code, for success and for each error case.
- Headers: content type, caching, security headers.
- Body structure (schema): required fields exist and have the right types.
- Body values: the data is correct (IDs, totals, statuses).
- Business rules: totals match items, a cancelled order cannot be shipped.
- Negative cases: missing fields, wrong types, unknown IDs, no token, someone else's ID.
- Response time: within an agreed limit.
Testing an API from the browser
This example calls JSONPlaceholder, a free public API for practice, with fetch and checks the response. It needs an internet connection.
const BASE = "https://jsonplaceholder.typicode.com";
function check(name, condition) {
console.log(`${condition ? "PASS" : "FAIL"} ${name}`);
}
const response = await fetch(`${BASE}/users/1`);
const user = await response.json();
check("status is 200", response.status === 200);
check("content type is JSON", response.headers.get("content-type").includes("application/json"));
check("id matches the request", user.id === 1);
check("email looks valid", /^[^@\s]+@[^@\s]+$/.test(user.email));
check("address has a city", typeof user.address?.city === "string");
const missing = await fetch(`${BASE}/users/9999`);
check("unknown user gives 404", missing.status === 404);
const created = await fetch(`${BASE}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Exam timetable", body: "Starts 4 March", userId: 1 }),
});
const post = await created.json();
check("POST returns 201 Created", created.status === 201);
check("new post echoes the title", post.title === "Exam timetable");
console.log("New post:", post);PASS status is 200
PASS content type is JSON
PASS id matches the request
PASS email looks valid
PASS address has a city
PASS unknown user gives 404
PASS POST returns 201 Created
PASS new post echoes the title
New post: { title: 'Exam timetable', body: 'Starts 4 March', userId: 1, id: 101 }JSONPlaceholder pretends to save the post and always returns ID 101; nothing is really stored. On a real project you would test against a test or staging server, never production.
Validating the response body
Checking one field at a time does not scale to large responses. Teams describe the expected shape once and validate every response against it. Real projects use JSON Schema with libraries such as jsonschema (Python) or Ajv (JavaScript), or Pydantic models. The idea fits in a few lines of standard Python:
import json
response_text = """
{
"order_id": "ORD-1042",
"status": "SHIPPED",
"total": 1299.0,
"items": [
{"sku": "BAT-KW-01", "qty": 1, "price": 1299.0}
],
"customer": {"name": "Rohan", "phone": "98XXXXXX10"}
}
"""
SCHEMA = {
"order_id": str,
"status": str,
"total": (int, float),
"items": list,
"customer": dict,
}
ALLOWED_STATUS = {"PLACED", "PAID", "SHIPPED", "DELIVERED", "CANCELLED"}
def validate(body):
errors = []
for field, kind in SCHEMA.items():
if field not in body:
errors.append(f"missing field: {field}")
elif not isinstance(body[field], kind):
errors.append(f"wrong type for {field}: {type(body[field]).__name__}")
if body.get("status") not in ALLOWED_STATUS:
errors.append(f"unknown status: {body.get('status')}")
item_sum = sum(i["qty"] * i["price"] for i in body.get("items", []))
total = body.get("total")
if isinstance(total, (int, float)) and abs(item_sum - total) > 0.001:
errors.append(f"total {body.get('total')} does not match items {item_sum}")
return errors
body = json.loads(response_text)
print("Valid order:", validate(body) or "no problems")
broken = dict(body, total="1299", status="SENT")
del broken["customer"]
for problem in validate(broken):
print("Problem:", problem)Valid order: no problems Problem: wrong type for total: str Problem: missing field: customer Problem: unknown status: SENT
A total sent as the text "1299" instead of a number is a classic API defect that breaks mobile apps while the website looks fine.
Postman
Postman is a popular desktop and web tool for sending requests by hand, saving them in collections, and switching between servers with environments (variables such as {{baseUrl}}). Tests are short JavaScript snippets in a request's Scripts > Post-response tab:
pm.test("status is 200", function () {
pm.response.to.have.status(200);
});
pm.test("user has the requested id", function () {
const user = pm.response.json();
pm.expect(user.id).to.equal(1);
});pm.expect uses the Chai assertion style. To run a whole collection from the command line or a CI server, Postman now recommends its Postman CLI (postman collection run). Older pipelines use Newman (newman run collection.json -e environment.json), which Postman says will not receive further updates.
API tests in Python with requests
For automated suites, many teams write API tests in code. With the requests library and pytest (install both with python -m pip install requests pytest):
# test_users_api.py
import requests
BASE = "https://jsonplaceholder.typicode.com"
def test_get_user_returns_200_and_json():
response = requests.get(f"{BASE}/users/1", timeout=10)
assert response.status_code == 200
assert response.headers["Content-Type"].startswith("application/json")
assert response.json()["id"] == 1
def test_unknown_user_returns_404():
response = requests.get(f"{BASE}/users/9999", timeout=10)
assert response.status_code == 404
def test_create_post():
payload = {"title": "Exam timetable", "body": "Starts 4 March", "userId": 1}
response = requests.post(f"{BASE}/posts", json=payload, timeout=10)
assert response.status_code == 201
assert response.json()["title"] == payload["title"]test_users_api.py::test_get_user_returns_200_and_json PASSED [ 33%] test_users_api.py::test_unknown_user_returns_404 PASSED [ 66%] test_users_api.py::test_create_post PASSED [100%] ============================== 3 passed in 3.65s ==============================
Always pass a timeout, or a hung server will hang your test run. Keep tokens and passwords in environment variables, never in test files that go into Git.
Common mistakes
- Checking only the status code. A 200 response with the wrong total is still a bug.
- Testing only the happy path. Missing fields, wrong types and other users' IDs find the serious bugs, including security ones.
- Hard-coding IDs that change. Create the data your test needs, then use the ID the API returns.
- Running tests against production. Use a test or staging environment.
Key points
- An API request has a method, URL, headers and body; a response has a status code, headers and body.
- Check status codes, headers, schema, values, business rules, negative cases and response time.
- A 500 error for bad input is always a defect; invalid requests deserve a 4xx code and a clear message.
- Postman is good for exploring and sharing requests; Postman CLI or Newman run collections in pipelines.
- Python
requestswith pytest gives fast, version-controlled API tests.
Exercise
Using fetch and JSONPlaceholder, write checks for GET /posts?userId=1: the status is 200, the response is an array of 10 posts, and every post has userId equal to 1. Print PASS or FAIL for each check.
Show solution
const response = await fetch("https://jsonplaceholder.typicode.com/posts?userId=1");
const posts = await response.json();
console.log(response.status === 200 ? "PASS" : "FAIL", "status is 200");
console.log(Array.isArray(posts) && posts.length === 10 ? "PASS" : "FAIL", "10 posts returned");
console.log(posts.every((p) => p.userId === 1) ? "PASS" : "FAIL", "all posts belong to user 1");PASS status is 200 PASS 10 posts returned PASS all posts belong to user 1
every() checks the rule for each item, which is how you test a filter: one wrong post in the list would make that check fail.
Quick Quiz
1. Which status code should a successful POST that creates a resource return?
201 Created tells the client a new resource exists.
2. A user sends an order with age -5 and the server answers 500. What is this?
Server errors (5xx) mean the server failed; invalid input should be rejected cleanly.
3. In Postman, where do you write tests that check the response?
Post-response scripts run after the response arrives and use pm.test.
4. Why pass timeout=10 to requests.get in a test?
Without a timeout, a request can wait forever.
Learn Software Testing with a mentor
Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
