Testing the Server

Goals

Why Not Just Click?

What are the problems with checking every route by opening a browser and clicking around?

What do I need to install to test a Litestar application with pytest?

A First Test

Write a pytest test file called test_server_status.py that uses Litestar's TestClient to check that GET / returns a 200 status code and that the page contains the text "Sasquatch Sightings".

i
pytest -v
i
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.

A Testable Server

What is the risk of writing a test like assert "G. horribilus" in response.text when "G. horribilus" comes from the SIGHTINGS list in dataset.py?

Refactor server_detail.py into a new file server_testable.py by wrapping the route definitions in a function called make_app that takes the sightings list as a parameter.

i
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
                        ],
                    ],
                ],
            ]
        )
i
    @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.py that defines a two-row list called SMALL and uses make_app(SMALL) to test the index links, the detail page content, and the display of None values.

i
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.