Testing APIs by hand: curl and browser dev tools
Run a small Brew & Bean API on your laptop and test it by hand with curl and your browser's dev tools. Learn to read status codes, headers and JSON, and to check success and failure cases.
In this tutorial you will start a small practice API (the Brew & Bean ordering API) and test it by hand, first with curl in the terminal and then with the Network tab in your browser's dev tools. You will send requests, read the responses and decide whether the API behaves correctly.
Prerequisites: Python 3.10 or newer installed, and a terminal. You do not need to know FastAPI. We use it only to give you something real to test. If you are new to HTTP, the lesson *HTTP in depth: methods, status codes and headers* in Backend Development covers it fully; a short recap is below.
A 2-minute recap of HTTP
An API call has two halves. The request has a method (what to do), a URL (where), headers (extra information such as the content type) and sometimes a body (data). The response has a status code (how it went), headers and usually a body, often JSON.
Testing an API by hand means: send a request, look at the status code first, then the body, then the headers, and compare with what the API is supposed to do.
Status codes you will meet in this lesson
| Status | Meaning | When a tester expects it |
|---|---|---|
| 200 OK | Request worked | GET /menu |
| 201 Created | A new thing was created | POST /orders with valid data |
| 400 Bad Request | The request is malformed | Broken JSON |
| 401 / 403 | Not logged in / not allowed | Missing or wrong credentials |
| 404 Not Found | That thing does not exist | GET /orders/does-not-exist |
| 409 Conflict | The request clashes with current state | Moving an order from received straight to ready |
| 422 Unprocessable | The data is well-formed but invalid | Quantity 0, or a 9-digit phone number |
| 500 Server Error | The server crashed | Always a bug. A good API never returns this for bad input |
Step 1: start the practice API
Make a folder called brew_api and create two files in it. pricing.py holds the menu and the price logic. main.py is the API. You will test pricing.py directly in the pytest lesson later, so keep these files.
MENU = {
"espresso": {"name": "Espresso", "category": "coffee", "price": 120},
"cappuccino": {"name": "Cappuccino", "category": "coffee", "price": 180},
"cold-brew": {"name": "Cold Brew", "category": "coffee", "price": 220},
"filter-coffee": {"name": "Filter Coffee", "category": "coffee", "price": 80},
"masala-chai": {"name": "Masala Chai", "category": "tea", "price": 90},
"croissant": {"name": "Butter Croissant", "category": "bakery", "price": 150},
"banana-bread": {"name": "Banana Bread", "category": "bakery", "price": 130},
"veg-sandwich": {"name": "Veg Grilled Sandwich", "category": "food", "price": 160},
}
MAX_QTY = 10
def calculate_total(items):
"""items is a list of dicts like {"item_id": "espresso", "qty": 2}."""
if not items:
raise ValueError("An order needs at least one item")
total = 0
for line in items:
item_id = line["item_id"]
qty = line["qty"]
if item_id not in MENU:
raise ValueError(f"Unknown item: {item_id}")
if not 1 <= qty <= MAX_QTY:
raise ValueError(f"Quantity must be between 1 and {MAX_QTY}")
total += MENU[item_id]["price"] * qty
return total
def is_valid_phone(phone):
"""Indian mobile number: 10 digits, starting with 6, 7, 8 or 9."""
return len(phone) == 10 and phone.isdigit() and phone[0] in "6789"The menu, the maximum quantity per line (10), a total calculator and a phone validator.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from pricing import MENU, calculate_total, is_valid_phone
app = FastAPI(title="Brew & Bean API")
ORDERS = {}
NEXT_STATUS = {"received": "preparing", "preparing": "ready", "ready": "picked_up"}
class Line(BaseModel):
item_id: str
qty: int
class NewOrder(BaseModel):
customer_name: str = Field(min_length=1, max_length=50)
phone: str
items: list[Line]
class StatusChange(BaseModel):
status: str
@app.get("/menu")
def get_menu():
return [{"id": k, **v} for k, v in MENU.items()]
@app.post("/orders", status_code=201)
def create_order(order: NewOrder):
if not is_valid_phone(order.phone):
raise HTTPException(422, "Phone must be a 10-digit Indian mobile number")
items = [line.model_dump() for line in order.items]
try:
total = calculate_total(items)
except ValueError as err:
raise HTTPException(422, str(err))
order_id = f"ord-{1001 + len(ORDERS)}"
ORDERS[order_id] = {
"id": order_id,
"customer_name": order.customer_name,
"phone": order.phone,
"items": items,
"total": total,
"status": "received",
}
return ORDERS[order_id]
@app.get("/orders/{order_id}")
def get_order(order_id: str):
if order_id not in ORDERS:
raise HTTPException(404, "Order not found")
return ORDERS[order_id]
@app.patch("/orders/{order_id}/status")
def change_status(order_id: str, change: StatusChange):
order = ORDERS.get(order_id)
if order is None:
raise HTTPException(404, "Order not found")
allowed = NEXT_STATUS.get(order["status"])
if change.status == "cancelled" and order["status"] in ("received", "preparing"):
order["status"] = "cancelled"
elif change.status == allowed:
order["status"] = allowed
else:
raise HTTPException(409, f"Cannot move from {order['status']} to {change.status}")
return orderFour endpoints: GET /menu, POST /orders, GET /orders/{id}, PATCH /orders/{id}/status. Orders are kept in memory, so they vanish when you restart.
Now install the libraries and start the server from inside the brew_api folder.
python -m venv .venv
# Windows PowerShell: .venv\Scripts\Activate.ps1
# Windows cmd: .venv\Scripts\activate.bat
# macOS / Linux: source .venv/bin/activate
pip install fastapi uvicorn
uvicorn main:app --reloadCreates a virtual environment, installs FastAPI and the uvicorn server, and starts the API on port 8000.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process ...
INFO: Application startup complete.Leave this terminal running and open a second terminal for your tests. Also open http://127.0.0.1:8000/docs in a browser. FastAPI builds an interactive page there where you can try each endpoint. It is a handy map of the API, but in this lesson we use curl so you see what really goes over the wire.
Windows users
On Windows PowerShell, curl is an alias for a different command (Invoke-WebRequest) with different options. Type curl.exe instead of curl, or use Git Bash or WSL. JSON quoting also differs between shells, so for request bodies this lesson uses a file (-d @order.json), which works the same everywhere.
Step 2: your first request
curl -i http://127.0.0.1:8000/menu-i prints the response headers as well as the body.
HTTP/1.1 200 OK
server: uvicorn
content-type: application/json
[{"id":"espresso","name":"Espresso","category":"coffee","price":120}, ... ]Read it in the right order. The first line gives the status, 200. The content-type: application/json header says the body is JSON. Then check the body: there should be 8 items and every price should match the menu you were given. A tester checks the data, not just that something came back.
Useful curl options you will use all the time:
curl options worth remembering
| Option | What it does |
|---|---|
| -i | Show response headers and body |
| -s | Silent: hide the progress meter |
| -X POST | Set the method (GET is the default) |
| -H "Content-Type: application/json" | Add a request header |
| -d '{...}' or -d @file.json | Send a request body from text or a file |
| -o /dev/null -w "%{http_code}\n" | Throw away the body and print only the status code (use -o NUL on Windows cmd) |
| -w "%{time_total}\n" | Print how long the call took, in seconds |
Step 3: create an order (the happy path)
The happy path is the normal, correct use of a feature. Always test it first, because if it fails nothing else matters. Save this body as order.json:
{
"customer_name": "Asha",
"phone": "9876543210",
"items": [
{"item_id": "espresso", "qty": 2},
{"item_id": "croissant", "qty": 1}
]
}curl -i -X POST http://127.0.0.1:8000/orders -H "Content-Type: application/json" -d @order.jsonHTTP/1.1 201 Created
content-type: application/json
{"id":"ord-1001","customer_name":"Asha","phone":"9876543210","items":[{"item_id":"espresso","qty":2},{"item_id":"croissant","qty":1}],"total":390,"status":"received"}What to verify in this response
- Status is 201 (created), not 200.
totalis 390: 2 x 120 + 1 x 150. The server computed it; the client did not send it.statusstarts asreceived.idis returned so we can look the order up.- The phone number and name are stored exactly as sent.
Step 4: read it back
curl -i http://127.0.0.1:8000/orders/ord-1001Should return 200 and the same data. Creating something and reading it back proves it was really stored.
Now the opposite: ask for an order that does not exist. A good API answers with a clear 404, not a crash.
curl -i http://127.0.0.1:8000/orders/ord-9999HTTP/1.1 404 Not Found
content-type: application/json
{"detail":"Order not found"}Step 5: break it on purpose (negative tests)
Most real bugs live outside the happy path. Send bad input and check that the API refuses it politely: the right 4xx code, a helpful message, and nothing saved. Try each of these by editing order.json (or sending the body inline in Git Bash).
Negative tests for POST /orders
| Change in the request | Expected result |
|---|---|
| phone is "12345" | 422 with message about a 10-digit mobile number |
| phone is "5876543210" (starts with 5) | 422 |
| qty is 0 | 422 "Quantity must be between 1 and 10" |
| qty is 11 | 422 |
| item_id is "pizza" | 422 "Unknown item: pizza" |
| items is [] | 422 "An order needs at least one item" |
| customer_name is "" | 422 (string too short) |
field phone removed | 422 (field required) |
curl -i -X POST http://127.0.0.1:8000/orders -H "Content-Type: application/json" -d '{"customer_name":"Asha","phone":"12345","items":[{"item_id":"espresso","qty":2}]}'Inline body. On Windows PowerShell use a file instead.
HTTP/1.1 422 Unprocessable Content
{"detail":"Phone must be a 10-digit Indian mobile number"}Notice what you check: the status code, the message, and also that the bad order was not created. Call GET /orders/ord-1002 afterwards. It should be 404, because nothing was saved. The newer FastAPI versions label 422 as "Unprocessable Content" and older ones as "Unprocessable Entity". The number is what matters.
A 500 response to bad input is a bug, even if the 'right' thing seems to have happened. Try sending broken JSON such as {"customer_name": and confirm you get a 4xx error, not a 500.
Step 6: test a state change
Orders move through received, preparing, ready, picked_up. The API should only allow the next step, or cancellation while the order is still received or preparing. Try to skip a step:
curl -i -X PATCH http://127.0.0.1:8000/orders/ord-1001/status -H "Content-Type: application/json" -d '{"status":"ready"}'HTTP/1.1 409 Conflict
{"detail":"Cannot move from received to ready"}Now do it properly: send {"status":"preparing"} (expect 200 and status preparing), then ready, then picked_up. After picked_up, try cancelled. It should be refused with 409, because a picked-up order cannot be cancelled. These rules ('what is allowed in which state') are a rich source of bugs.
Step 7: use browser dev tools
Your browser is also an API testing tool. When you open a real website, every action sends API calls you can watch. You will use this constantly when testing web apps, or when a front-end developer says 'the API is returning garbage'.
- 1Open http://127.0.0.1:8000/docs in Chrome or Edge, then press F12 to open dev tools and click the Network tab.
- 2In the docs page, expand
GET /menu, click Try it out and Execute. - 3In the Network tab, click the
menurequest. Look at the Headers panel (method, URL, status code), the Payload panel (what was sent) and the Response or Preview panel (the JSON). - 4Right-click the request and choose Copy as cURL. Paste it into your terminal and run it. You have just turned any browser call into a repeatable test.
- 5Click the Timing panel and note how long the request took. Slow calls found here are often the first sign of a performance problem.
- 6Repeat with
POST /ordersusing a bad phone number, and compare the failed request's status (422) with the successful one (201).
In Network, the Fetch/XHR filter hides images and scripts so you only see API calls. Tick Preserve log so requests do not vanish when the page reloads.
Writing down what you tested
Manual testing is only useful if someone can repeat it. For each test, record what you sent, what you expected, and what you got. A simple table is enough to begin with. In the next lessons you will keep these as saved requests in Postman and Bruno, and later turn them into automated tests.
A simple test log
| ID | Request | Expected | Actual | Pass? |
|---|---|---|---|---|
| T1 | POST /orders valid body | 201, total 390, status received | 201, 390, received | Yes |
| T2 | POST /orders qty 0 | 422, nothing saved | 422, nothing saved | Yes |
| T3 | PATCH status received to ready | 409 | 409 | Yes |
I am learning API testing. Here is the request I sent and the response I got. Tell me in simple English what each part of the response means, whether anything looks wrong, and list 5 more tests I should try.
Request:
[paste your curl command]
Response:
[paste the status line, headers and body]What to expect: A good answer explains the status code, points out anything odd (a 500 for bad input, a missing field, a wrong total) and suggests boundary and negative cases. Treat the suggestions as ideas to try yourself, not as truth.
Try it: Test the menu and the totals
1. Check GET /menu returns 8 items and that Filter Coffee costs 80 and Masala Chai costs 90.
2. Create an order with 3 filter-coffee and 2 masala-chai. Work out the expected total by hand before you send it.
3. Create an order with qty 10 of espresso and another with qty 11. Which one is accepted?
4. Create an order, move it to preparing, then cancel it. Check the final status.
5. Write all four as rows in a test log.
Try it: Find the missing check
Look at main.py. Is there anything about the API that you could not test with the requests above and would want to ask the developer about? Think about what happens to the order list when the server restarts, or what GET /orders/{id} reveals to anyone who guesses an id.
Key takeaways
- Always check the status code first, then the body, then the headers.
- Test the happy path first, then bad input, missing fields, boundaries and wrong states.
- A good API answers bad input with a clear 4xx, never a 500, and saves nothing.
- Create then read back to prove data was stored; check the expected total by hand.
- curl is repeatable; on Windows use
curl.exe, and send bodies from a file with-d @file.json. - The browser Network tab shows real API calls, and Copy as cURL turns them into tests.
- Write down what you sent, what you expected and what you got.
Check yourself
Finishing the quiz marks the lesson as done.
1. You POST a valid new order. Which status code should a well-designed API return?
2. You send an order with qty 0 and the API returns 500. What does this mean?
3. Why do you call GET /orders/{id} right after a successful POST?
4. On Windows PowerShell, curl behaves oddly with these commands. What is the best fix?
5. What does 'Copy as cURL' in the browser Network tab give you?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.