EthicsComputerCourses
Join training +91 90532 10052

API Testing

Software Testing · Lesson 19 of 30 Advanced 7 min read Updated

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:

HTTP
POST /posts HTTP/1.1
Host: jsonplaceholder.typicode.com
Content-Type: application/json

{"title": "Exam timetable", "body": "Starts 4 March", "userId": 1}
HTTP
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:

CodeMeaningWhen to expect it
200 OKSuccessA normal GET or update
201 CreatedA new resource was createdA successful POST
204 No ContentSuccess with no bodyOften after DELETE
400 Bad RequestThe request is invalidMissing or malformed fields
401 UnauthorizedNot logged in, or a bad tokenCalling without credentials
403 ForbiddenLogged in but not allowedA customer calling an admin endpoint
404 Not FoundThe resource does not existA wrong ID
409 ConflictClashes with current stateRegistering an email twice
422 Unprocessable ContentWell-formed but fails validationAge of -5
500 Internal Server ErrorThe server crashedNever 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.

JavaScriptRun it yourself
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);
Output
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:

PythonRun it yourself
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)
Output
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:

JavaScript
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):

Python
# 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"]
Output
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 requests with 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
JavaScript
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");
Output
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?

2. A user sends an order with age -5 and the server answers 500. What is this?

3. In Postman, where do you write tests that check the response?

4. Why pass timeout=10 to requests.get in a test?

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