Testing the System: Swagger UI, curl and a Test Script in Python
- Test every Endpoint in Swagger UI — Try it out, Body, Execute and reading the response
- Send POST, PUT and DELETE requests with curl
- Write a Python script that runs a sequence of requests and checks that every status code is the expected one
- Build a manual test list (a Test Plan) for all the CRUD actions and the edge cases
What will we learn?
A server that was not tested is a server you cannot be sure works. We will learn three ways, from the manual to the automatic:
| Tool | When |
|---|---|
Swagger UI (/docs) | Convenient manual testing, while developing |
| curl | Quick testing from the Terminal, and a habit of Backend and DevOps people |
| A Python script | A series of tests that runs with one click, and checks everything, every time |
And a Test Plan: what you test, and what you expect to get.
Why do we need this?
Every change in the code may break something that worked. If the test is “I opened the browser and it looks fine”, bugs will reach the users. A fixed list of tests, and especially a script that runs it, lets you change code with confidence. It is also practice for the integration project, where a whole day is dedicated to Debugging and to manual testing of all the CRUD actions.
Explanation from the basics
Swagger UI
Swagger UI is a page that FastAPI builds automatically from the code: from the Routes, from the Type Hints and from the Pydantic models. It shows every Endpoint, and lets you send real requests from the browser.
http://127.0.0.1:8000/docsThe process for every Endpoint:
- Clicking the Endpoint (for example
POST /students) opens it. - Try it out → the fields open for editing.
- Fill in parameters, and/or edit the Request body (an example JSON already appears, built from the model!).
- Execute → a real request is sent to the server.
- Below: Curl (the equivalent command!), the Request URL, the Response body, the Response code and the Response headers.
A bonus: http://127.0.0.1:8000/openapi.json also exists. It is the description of the API in a standard format (OpenAPI), from which Swagger builds the page. Other tools know how to read it.
curl — a reminder
curl -i http://127.0.0.1:8000/students
curl -i -X POST http://127.0.0.1:8000/students -H "Content-Type: application/json" -d '{"name": "Tom", "age": 27, "email": "tom@mail.com"}'
curl -i -X DELETE http://127.0.0.1:8000/students/3-i also shows the status line, and that is exactly what you test.
In PowerShell, the quotes inside JSON are complicated. Two convenient ways: (1) save the JSON in a file (new_student.json) and send it with curl.exe -i -X POST URL -H "Content-Type: application/json" -d "@new_student.json". (2) Copy the curl command that Swagger shows and run it in Git Bash.
A test script in Python
A script that sends requests to a running server, and checks for each one: is the code what we expected? At the end it prints how many tests passed. A test like this, which runs the system from outside like a real Client, is called an End-to-End test. (Later in the track you will learn professional testing tools, like pytest. The idea is the same.)
Test Plan — a list of tests
For every Endpoint: the happy path + every failure case in the mapping table. If every row in the table has a test, you know that the API’s contract holds.
Syntax
import json
import urllib.error
import urllib.request
BASE_URL = "http://127.0.0.1:8000"
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(BASE_URL + path, data=data, method=method,
headers={"Content-Type": "application/json"})
try:
response = urllib.request.urlopen(request)
status, raw = response.status, response.read()
except urllib.error.HTTPError as error:
status, raw = error.code, error.read()
return status, (json.loads(raw) if raw else None)call returns a tuple of (the code, the response body), both on success and on error (HTTPError). An empty body (204) → None.
First code example
Swagger — creating a student step by step:
- Run the server (
uvicorn main:app --reload) and open/docs. - POST /students → Try it out. The Request body shows:
{
"name": "string",
"age": 0,
"email": "string"
}- Replace it with:
{
"name": "Tom",
"age": 27,
"email": "tom@mail.com"
}- Execute. The result:
Code: 201
Response body:
{
"id": 3,
"name": "Tom",
"age": 27,
"email": "tom@mail.com"
}- Click Execute again, with the same Body. Now: 409,
"a student with email tom@mail.com already exists".
Breaking down the code line by line
| What you see in Swagger | Where it comes from in the code |
|---|---|
| The list of all the Endpoints | The Decorators @app.get/post/put/delete |
| The API’s name at the top (“Students API”) | FastAPI(title="Students API") |
| The example JSON in the Body | The model StudentIn (fields and types) |
The field student_id with integer | The Type Hint student_id: int |
| Code 201 as the “Successful Response” in POST | status_code=201 |
| The id 3 in the response | db.create_student → AUTO_INCREMENT |
Everything is derived from the code. When you change a Route, the documentation updates by itself. That is why it is always accurate.
Another example
An end-to-end test script. smoke_test.py (the server needs to run in another Terminal, on an empty table: in Database Client run DROP TABLE students;, and restart the server — init_db creates the table again):
import json
import urllib.error
import urllib.request
BASE_URL = "http://127.0.0.1:8000"
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(BASE_URL + path, data=data, method=method,
headers={"Content-Type": "application/json"})
try:
response = urllib.request.urlopen(request)
status, raw = response.status, response.read()
except urllib.error.HTTPError as error:
status, raw = error.code, error.read()
return status, (json.loads(raw) if raw else None)
CHECKS = [
("create Dana", "POST", "/students", {"name": "Dana", "age": 24, "email": "dana@mail.com"}, 201),
("create Avi", "POST", "/students", {"name": "Avi", "age": 31, "email": "avi@mail.com"}, 201),
("duplicate email", "POST", "/students", {"name": "Dana Copy", "age": 20, "email": "dana@mail.com"}, 409),
("too young", "POST", "/students", {"name": "Kid", "age": 7, "email": "kid@mail.com"}, 400),
("missing fields", "POST", "/students", {"name": "Noa"}, 422),
("list", "GET", "/students", None, 200),
("get existing", "GET", "/students/1", None, 200),
("get missing", "GET", "/students/99", None, 404),
("bad id", "GET", "/students/abc", None, 422),
("update", "PUT", "/students/2", {"name": "Avi Levi", "age": 32, "email": "avi@mail.com"}, 200),
("update missing", "PUT", "/students/99", {"name": "Ghost", "age": 30, "email": "g@mail.com"}, 404),
("update clash", "PUT", "/students/2", {"name": "Avi", "age": 32, "email": "dana@mail.com"}, 409),
("delete", "DELETE", "/students/1", None, 204),
("delete again", "DELETE", "/students/1", None, 404),
]
def main():
passed = 0
for name, method, path, body, expected in CHECKS:
status, data = call(method, path, body)
ok = status == expected
passed = passed + (1 if ok else 0)
mark = "PASS" if ok else "FAIL"
print(mark, "|", name, "|", method, path, "| expected", expected, "got", status)
print(passed, "/", len(CHECKS), "checks passed")
if __name__ == "__main__":
main()Output:
PASS | create Dana | POST /students | expected 201 got 201
PASS | create Avi | POST /students | expected 201 got 201
PASS | duplicate email | POST /students | expected 409 got 409
PASS | too young | POST /students | expected 400 got 400
PASS | missing fields | POST /students | expected 422 got 422
PASS | list | GET /students | expected 200 got 200
PASS | get existing | GET /students/1 | expected 200 got 200
PASS | get missing | GET /students/99 | expected 404 got 404
PASS | bad id | GET /students/abc | expected 422 got 422
PASS | update | PUT /students/2 | expected 200 got 200
PASS | update missing | PUT /students/99 | expected 404 got 404
PASS | update clash | PUT /students/2 | expected 409 got 409
PASS | delete | DELETE /students/1 | expected 204 got 204
PASS | delete again | DELETE /students/1 | expected 404 got 404
14 / 14 checks passedBreakdown:
CHECKS— a list of tuples: the test’s name, the Method, the Path, the Body and the expected code. The data is separated from the logic (Separation of Concerns!). Adding a test = adding a line.- The order matters: the tests depend on each other (first you create, and then you delete). That is why you start from an empty database.
- The loop — sends, compares with
expected, and prints PASS/FAIL. - The magic: you change something in the code (Refactoring), run it, and within a second you see whether something broke. A safety net.
Try to break it: in main.py, change status_code=204 to 200 in DELETE, and save (--reload). Delete the table (DROP TABLE students; in Database Client), restart, and run the script:
FAIL | delete | DELETE /students/1 | expected 204 got 200
13 / 14 checks passedThe script caught the change. Put the code back.
Common mistake
Mistake 1: testing only the happy path. “I created a student, and it worked” ≠ “the server works”. Most bugs in server code are in the failure cases: a 404 that comes back as 200, a 409 that comes back as 500. Test Plan = the happy path + every failure row in the mapping table.
Mistake 2: running the test script on a database with previous data. “create Dana” will fail with 409, because Dana already exists from the previous run. An empty database before every run (or, later in the track, a separate test database).
Mistake 3: forgetting that the server needs to be running.
urllib.error.URLError: <urlopen error [WinError 10061] No connection could be made because the target machine actively refused it>“Connection refused” = no server is listening on the port (remember the networking topic!). Start uvicorn in a separate Terminal.
Mistake 4: checking only the code, and not the body. A 200 code with a wrong body (a name that was not updated) is still a bug. In important tests, also check values: data["name"] == "Avi Levi". (See the independent practice.)
Practice
The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.
Exercise 1: Test Plan
Write a Test Plan for the Students API: at least 8 tests, with the Method, the path, what you send, and the code you expect to get. Include the happy path and also failure cases.
Show solution
| # | Test | Request | Expected code |
|---|---|---|---|
| 1 | Creating Dana | POST /students with a valid body | 201 |
| 2 | A duplicate email | POST /students with Dana’s email | 409 |
| 3 | Too young | POST /students with age 7 | 400 |
| 4 | Missing fields | POST /students with only a name | 422 |
| 5 | An existing student | GET /students/1 | 200 |
| 6 | A student that does not exist | GET /students/99 | 404 |
| 7 | A non-numeric id | GET /students/abc | 422 |
| 8 | Deleting | DELETE /students/1 | 204 |
| 9 | Deleting again | DELETE /students/1 | 404 |
The first test always passes — most bugs are in the failure cases: a 404 that comes back as 200, or a 409 that comes back as 500.
Exercise 2: curl
Write curl commands for a server running on http://127.0.0.1:8000:
- Creating a student (Dana, 24, dana@mail.com) — showing the code.
- Reading student 1.
- Deleting student 1 — showing the code.
Show solution
curl -i -X POST http://127.0.0.1:8000/students -H "Content-Type: application/json" -d '{"name": "Dana", "age": 24, "email": "dana@mail.com"}'
curl http://127.0.0.1:8000/students/1
curl -i -X DELETE http://127.0.0.1:8000/students/1-i shows the Status Code — in testing it is the most important thing to see. In Swagger (/docs) you can perform the same requests from the browser.
Exercise 3: The call function
Write a function call(method, path, body=None) that sends a request to the server with urllib.request, and returns a tuple of (the code, the body as a dictionary or None). HTTP errors (4xx, 5xx) should not crash — they also return a code.
Show solution
import json
import urllib.error
import urllib.request
BASE_URL = "http://127.0.0.1:8000"
def call(method, path, body=None):
data = None
if body is not None:
data = json.dumps(body).encode()
request = urllib.request.Request(BASE_URL + path, data=data, method=method,
headers={"Content-Type": "application/json"})
try:
response = urllib.request.urlopen(request)
status, raw = response.status, response.read()
except urllib.error.HTTPError as error:
status, raw = error.code, error.read()
if raw:
return status, json.loads(raw)
return status, Noneurlopenthrows anHTTPErroron 4xx and 5xx — and in testing these are exactly the responses that interest us, so you catch them and return the code.- A 204 response has no body — so
None.
Exercise 4: The script that prints PASS / FAIL
Write the part that runs the list of tests and prints a result. To practice without a server, the results are already in the list: the test’s name, the expected code and the code that was received.
results = [
("create Dana", 201, 201),
("duplicate email", 409, 409),
("get missing", 404, 200),
("delete", 204, 204),
]Expected output:
PASS | create Dana | expected 201 got 201
PASS | duplicate email | expected 409 got 409
FAIL | get missing | expected 404 got 200
PASS | delete | expected 204 got 204
3 / 4 checks passedShow solution
results = [
("create Dana", 201, 201),
("duplicate email", 409, 409),
("get missing", 404, 200),
("delete", 204, 204),
]
passed = 0
for name, expected, got in results:
if expected == got:
status = "PASS"
passed = passed + 1
else:
status = "FAIL"
print(status, "|", name, "| expected", expected, "got", got)
print(passed, "/", len(results), "checks passed")PASS | create Dana | expected 201 got 201
PASS | duplicate email | expected 409 got 409
FAIL | get missing | expected 404 got 200
PASS | delete | expected 204 got 204
3 / 4 checks passedWith a real server, got comes from call(...). The FAIL here exposes a real bug: a student that does not exist comes back as 200 instead of 404.
Exercise 5: The server is not running
You run the test script, and get:
urllib.error.URLError: <urlopen error [WinError 10061] No connection could be made
because the target machine actively refused it>- What does it mean?
- How do you run the tests correctly?
- Why is it worth starting from an empty database?
Show solution
- No server is listening at the address and the port — the connection was refused. The script is a Client, and it needs a running server.
- Two Terminals: in one run
uvicorn main:app, and in the other run the test script. - So that the results are predictable: the id of the first student is 1, and the list starts empty. On a database with old data, “creating Dana” may return 409 because she already exists — and the test will fail without there being a bug.
Summary
- Swagger UI (
/docs): Try it out → Body → Execute. Built automatically from the code, and also shows the equivalent curl command. - curl -i: a quick test from the Terminal. In PowerShell, JSON from a file (
-d "@file.json") or Git Bash. - A Python script (End-to-End): a list of tests (data) + a loop (logic) → PASS/FAIL. A safety net for every change.
- Test Plan = the happy path + every failure case in the mapping table. Check the codes and also the body.
- Run tests on an empty database, and with a running server.