Testing the Server
Goals
- Understand why running automated tests is faster and more reliable than manually checking routes in a browser.
- Use Litestar's test client to send HTTP requests to the application without starting a real server.
- Write tests against the application's existing data to check status codes and page content.
- Refactor the server to accept a dataset as a parameter so tests can use small, controlled data.
Why Not Just Click?
What are the problems with checking every route by opening a browser and clicking around?
- Every change means repeating the same steps by hand:
- Start the server
- Open the browser
- Visit the index page
- Click a detail link
- Try a made-up ID to see the 404
- Stop the server
- Clicking is easy to rush or skip, so bugs slip through
- The more routes the application has, the more steps there are to forget
What do I need to install to test a Litestar application with pytest?
uv add pytestadds the pytest testing framework- Litestar's test client uses the httpx library internally, so
uv add httpxis needed too - Both packages are now available whenever you run pytest
A First Test
Write a pytest test file called
test_server_status.pythat uses Litestar's TestClient to check thatGET /returns a 200 status code and that the page contains the text "Sasquatch Sightings".
TestClientfromlitestar.testingis a test client: it wraps the application and lets you call routes directly in memory, with no real server or browserwith TestClient(app=make_app()) as client:sets up the client and tears it down cleanly at the end of thewithblockclient.get("/")sends a GET request and returns a response objectresponse.status_codeholds the three-digit status code: 200 means success, 404 means not foundresponse.textis the full body of the response as a plain string, which is all you need to check that the right content appeared- Run the tests from the
testserver/directory with:
pytest -v
from litestar.testing import TestClient
from server_testable import make_app
def test_index_returns_ok():
with TestClient(app=make_app()) as client:
response = client.get("/")
assert response.status_code == 200
def test_index_contains_title():
with TestClient(app=make_app()) as client:
response = client.get("/")
assert "Sasquatch Sightings" in response.text
def test_detail_returns_ok():
with TestClient(app=make_app()) as client:
response = client.get("/sighting/1")
assert response.status_code == 200
def test_missing_sighting_returns_404():
with TestClient(app=make_app()) as client:
response = client.get("/sighting/9999")
assert response.status_code == 404
Testing Error Responses
Add two more tests to
test_server_status.py: one that checks a valid sighting ID returns 200, and one that checks a missing ID returns 404.
- Testing both the success path and the error path builds confidence that the code handles both correctly
- A route that never returns 404 when it should is just as broken as one that returns 200 when it should not
- The 20 rows in
SIGHTINGSuse IDs 1 through 20, so ID 9999 is guaranteed to be missing and will triggerraise NotFoundException - pytest reports pass or fail for each function separately, so you can see at a glance which path broke
A Testable Server
What is the risk of writing a test like
assert "G. horribilus" in response.textwhen "G. horribilus" comes from theSIGHTINGSlist indataset.py?
- If someone updates
dataset.pyby correcting a species name, adding rows, or removing old ones, the test breaks even though the route is working correctly - A test that fails for the wrong reason wastes time and erodes trust in the whole test suite
- The fix is to control exactly what data the server uses during the test
Refactor
server_detail.pyinto a new fileserver_testable.pyby wrapping the route definitions in a function calledmake_appthat takes the sightings list as a parameter.
make_app(sightings=SIGHTINGS)is a function that defines the route handlers and returns the app- The default value means calling
make_app()with no arguments behaves exactly likeserver_detail.py - Calling
make_app(SMALL)returns an app that servesSMALLinstead of the full dataset
- The default value means calling
- The handlers inside
make_appusesightingsjust like any function uses a parameter: whereverserver_detail.pywrotefor s in SIGHTINGS,server_testable.pywritesfor s in sightings app = make_app()at the bottom keepslitestar run --app server_testable:appworking from the terminal
def make_app(sightings=SIGHTINGS):
@get("/", media_type=MediaType.HTML)
async def index() -> str:
return str(
html(lang="en")[
head[
title["Sasquatch Sightings"],
link(rel="stylesheet", href="/style.css"),
],
body[
table[
tr[[th[col] for col in HEADERS]],
[
tr[
td[a(href=f"/sighting/{s['id']}")[str(s["id"])]],
[td[fmt(s[k])] for k in KEYS[1:]],
]
for s in sightings
],
],
],
]
)
@get("/sighting/{sighting_id:int}", media_type=MediaType.HTML)
async def detail(sighting_id: int) -> str:
for s in sightings:
if s["id"] == sighting_id:
return str(
html(lang="en")[
head[
title[f"Sighting {sighting_id}"],
link(rel="stylesheet", href="/style.css"),
],
body[
table[
[
tr[
td(class_="label")[label],
td[fmt(s[key])],
]
for key, label in LABELS.items()
],
],
a(href="/")["Back to all sightings"],
],
]
)
raise NotFoundException(f"No sighting with ID {sighting_id}")
@get("/style.css", media_type="text/css")
async def styles() -> str:
return (LESSON_DIR / "style.css").read_text()
return Litestar([index, detail, styles])
app = make_app()
Testing with Controlled Data
Write a second test file called
test_server_data.pythat defines a two-row list calledSMALLand usesmake_app(SMALL)to test the index links, the detail page content, and the display ofNonevalues.
SMALLhas exactly two rows, so it is easy to see at a glance what each test is checkingmake_app(SMALL)creates a fresh app that serves only those two rows- Each test creates its own app, so there is no shared state between tests
test_none_displayed_as_emptyconfirms that a sighting withsex=Noneandweight=Nonedoes not show the word"None"anywhere in the page- This is the kind of edge case that is easy to miss when clicking manually
from litestar.testing import TestClient
from server_testable import make_app
from small import SMALL
def test_index_shows_both_sightings():
with TestClient(app=make_app(SMALL)) as client:
response = client.get("/")
assert "/sighting/1" in response.text
assert "/sighting/2" in response.text
def test_detail_shows_correct_species():
with TestClient(app=make_app(SMALL)) as client:
response = client.get("/sighting/1")
assert "G. canadensis" in response.text
def test_none_displayed_as_empty():
with TestClient(app=make_app(SMALL)) as client:
response = client.get("/sighting/2")
assert "None" not in response.text
Check Understanding
See [pytest2025] for the full pytest documentation and [litestar2025] for Litestar's testing reference.
What is the difference between running litestar run --app server_testable:app and calling client.get("/") in a test?
litestar run starts a real server process that listens on port 8000 and waits for browser requests
over the network.
client.get("/") sends a request directly to the application in memory without any network connection.
Tests therefore run without a browser and without needing a free port.
Why does server_testable.py still have the line app = make_app() at the bottom?
litestar run --app server_testable:app looks for a module-level name called app.
Without that line, running the server from the terminal would fail with an import error.
The factory function is for tests; the module-level app is for the command line.
The code below raises an error when it runs. What is wrong and how do you fix it?
client = TestClient(app=make_app())
response = client.get("/")
assert response.status_code == 200
TestClient must be used inside a with block.
The with TestClient(app=make_app()) as client: form ensures the client starts up and shuts down properly.
Calling client.get() before entering the with block raises a runtime error.
The fix is:
with TestClient(app=make_app()) as client:
response = client.get("/")
assert response.status_code == 200
A test uses make_app(SMALL) and checks assert "G. canadensis" in response.text.
A colleague adds a third row to SMALL with species "G. horribilus" for an unrelated test.
Does the first test break?
No.
The first test still passes because "G. canadensis" is still in the response.
Adding a row to SMALL only breaks a test if that test makes an assumption that breaks with more data,
such as asserting the page contains exactly two links.
This is another reason to keep assertions specific: check for what you expect to be there,
not for the absence of things you did not add.
The test below always fails, even when the route is working correctly. What is wrong?
def test_detail_ok():
with TestClient(app=make_app()) as client:
response = client.get("/sighting/1")
assert response.status_code == "200"
response.status_code is an integer, not a string.
Comparing it to the string "200" is always False in Python.
The fix is to drop the quotes: assert response.status_code == 200.
If make_app() is called with no arguments in a test, which dataset does the server use, and why?
It uses SIGHTINGS from dataset.py, because that is the default value of the sightings parameter.
The line def make_app(sightings=SIGHTINGS): means any call that omits the argument gets the full
twenty-row dataset, exactly as server_detail.py behaved.
Exercises
Test the CSS Route
Add a test to test_server_status.py that sends GET /style.css and checks that the response
status code is 200 and that the response text contains the word "table".
Test a Specific Detail Page
Add a test using make_app(SMALL) that visits /sighting/2 and confirms that the
text "G. horribilus" appears in the response.
Test an Invalid Path Parameter
Visit /sighting/abc in a test and assert that the response status code is 400.
Look up what HTTP status code 400 means and write a comment in the test explaining why 400 is the
right code here rather than 404.
Test Both Species in the Index
Write a test using make_app(SMALL) that visits the index page and asserts that both
"G. canadensis" and "G. horribilus" appear in the response text.
Combine Two Assertions
Rewrite test_index_returns_ok and test_index_contains_title as a single test function
that makes both assertions using one TestClient.
When would it be better to keep them as two separate tests?
Check the Back Link
Using make_app(SMALL), write a test that visits the detail page for sighting 1 and asserts
that the text "Back to all sightings" appears in the response.
Then visit the index page and assert that the same text does not appear there.
Shared App vs. Fresh App
Write two versions of test_index_ok and test_detail_ok: one where both tests share a single
app = make_app(SMALL) created at module level, and one where each test calls make_app(SMALL)
inside its own body.
Run both versions and confirm they pass.
Then add a route to server_testable.py that keeps a count of how many requests it has received
and returns it at /count.
Add a test that calls /count twice and checks whether the count resets between tests
depending on which version you use.
Add a Route for a Missing Page
Add a new route to server_db.py that handles any URL not matched by the existing routes
and returns a page with the text "Page not found" and HTTP status 404.
Look up Litestar's ExceptionHandler to see how to do this,
and then add a test for it.