Flaxon 0.3.0 and Teloce-Py 0.3.0 are available on PyPI. Release instructions are maintained on this site; source links identify the framework snapshot. Check version and upgrade guidance.
07 | Backend tests
Goal
Run realistic HTTP requests against disposable ORM databases before writing the UI.
What you are building
conftest.py creates a test app and owns its event loop and ORM lifecycle. generate_schemas is used ONLY to prepare disposable test databases; production and development use migrations. TestClient exercises the ASGI app with HTTPX. Account carries its cookie and rotated CSRF token. Tests cover valid workflows, rejected input, expired sessions and cross-user access. Read an ownership test aloud: arrange two accounts, create a project, attempt another user's request and assert it is denied.
Build this chapter
Stop the server. Work in project_manager. Follow each CREATE, whole-file replacement, or marked-block edit below in order.
Step 1: CREATE - make parent folders, then create this file - tests/conftest.py
Own the test database lifecycle and event loop. Never use development files in this fixture.
Create the parent directories, then save this complete file.
import asyncio
import pytest
from app import create_app
import httpx
class TestClient:
def __init__(self, app):
self.app = app
self.loop = getattr(app, "test_loop", None)
def __getattr__(self, method):
def request(path, json_data=None, **kwargs):
async def send():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app),
base_url="http://testserver",
) as client:
return await client.request(
method.upper(), path, json=json_data, **kwargs
)
return (
self.loop.run_until_complete(send())
if self.loop
else asyncio.run(send())
)
return request
from settings import ROOT
@pytest.fixture
def application(tmp_path):
path = tmp_path / "app.sqlite3"
app = create_app(path, tmp_path / "admin.sqlite3", debug=True)
loop = asyncio.new_event_loop()
app.test_loop = loop
loop.run_until_complete(app.db.initialize())
with app.db.bind():
loop.run_until_complete(app.db.context.generate_schemas())
yield app
loop.run_until_complete(app.db.close())
loop.close()
@pytest.fixture
def client(application):
return TestClient(application)
class Account:
def __init__(self, client, email="learner@example.test"):
self.client = client
response = client.get("/api/auth/session")
self.accept_session(response)
response = self.call(
"/api/auth/register",
"post",
{"name": "Learner", "email": email, "password": "LearningPython123!"},
)
assert response.status_code == 201, response.content
self.accept_session(response)
def accept_session(self, response):
self.cookie_header = next(
value
for value in response.headers.get_list("set-cookie")
if value.startswith("project_session=")
)
self.cookie = self.cookie_header.split(";")[0]
self.csrf = response.json()["data"]["csrf"]
@property
def headers(self):
return {
"cookie": self.cookie,
"x-csrf-token": self.csrf,
"content-type": "application/json",
}
def call(self, path, method="get", data=None, headers=None):
options = {"headers": self.headers if headers is None else headers}
if data is not None:
options["json_data"] = data
return getattr(self.client, method)(path, **options)
@pytest.fixture
def account(client):
return Account(client)
def orm(application, query):
"""Run a direct ORM assertion in the same database context as this test app."""
with application.db.bind():
return application.test_loop.run_until_complete(query())
Say: "Own the test database lifecycle and event loop. Never use development files in this fixture."
Type: Follow the action above in tests/conftest.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 2: CREATE - make parent folders, then create this file - tests/test_api.py
This file owns the feature named by its module or component. Read the route/form flow before continuing.
Create the parent directories, then save this complete file.
import asyncio
import time
import pytest
from conftest import Account, orm
from models import Task, Session
def project(account):
response = account.call(
"/api/projects/",
"post",
{"name": "Course project", "description": "Build the app"},
)
assert response.status_code == 201
return response.json()["data"]
def test_registration_rotates_session_and_never_returns_password_hash(client, account):
response = account.call("/api/auth/session")
assert response.status_code == 200
assert response.json()["data"]["user"]["email"] == "learner@example.test"
assert "password" not in str(response.json())
assert "httponly" in account.cookie_header.lower()
assert "samesite=lax" in account.cookie_header.lower()
def test_login_logout_and_old_session_revocation(account):
old_headers = account.headers
response = account.call("/api/auth/logout", "post", {})
assert response.status_code == 200
account.accept_session(response)
assert account.call("/api/projects/", headers=old_headers).status_code == 401
response = account.call(
"/api/auth/login",
"post",
{"email": "learner@example.test", "password": "LearningPython123!"},
)
assert response.status_code == 200
account.accept_session(response)
assert account.call("/api/projects/").status_code == 200
@pytest.mark.parametrize(
"payload",
[
[],
{"name": "", "email": "bad", "password": "short"},
{"name": "A", "email": "a@test.test", "password": "short"},
],
)
def test_invalid_registration(account, payload):
assert account.call("/api/auth/register", "post", payload).status_code == 400
def test_wrong_password_and_duplicate_registration(account):
assert (
account.call(
"/api/auth/login",
"post",
{"email": "learner@example.test", "password": "IncorrectPassword123!"},
).status_code
== 401
)
assert (
account.call(
"/api/auth/register",
"post",
{
"name": "Other",
"email": "LEARNER@example.test",
"password": "LearningPython123!",
},
).status_code
== 409
)
def test_authentication_and_csrf_boundaries(client, account):
assert client.get("/api/projects/").status_code == 401
assert (
account.call(
"/api/projects/",
"post",
{"name": "Blocked"},
headers={"cookie": account.cookie},
).status_code
== 403
)
other = Account(client, "other@example.test")
headers = {**account.headers, "x-csrf-token": other.csrf}
assert (
account.call(
"/api/projects/", "post", {"name": "Blocked"}, headers=headers
).status_code
== 403
)
headers = {**account.headers, "origin": "https://evil.example"}
assert (
account.call(
"/api/projects/", "post", {"name": "Blocked"}, headers=headers
).status_code
== 403
)
def test_project_crud_and_ownership(client, account):
item = project(account)
other = Account(client, "other@example.test")
path = f"/api/projects/{item['id']}"
for method in ["get", "put", "delete"]:
assert (
other.call(
path, method, {"name": "Stolen"} if method != "get" else None
).status_code
== 404
)
assert other.call("/api/projects/").json()["data"] == []
assert (
account.call(path, "put", {"name": "Renamed", "description": "Updated"}).json()[
"data"
]["name"]
== "Renamed"
)
assert account.call(path, "delete", {}).status_code == 200
assert account.call(path).status_code == 404
def test_task_workflow_filter_and_cascade(account, application):
item = project(account)
path = f"/api/tasks/project/{item['id']}"
response = account.call(
path, "post", {"title": "Record lesson", "due_date": "2026-12-01"}
)
assert response.status_code == 201
task_id = response.json()["data"]["id"]
assert (
account.call(f"/api/tasks/{task_id}", "patch", {"status": "done"}).status_code
== 200
)
assert len(account.call(path + "?status=done").json()["data"]) == 1
assert account.call(path + "?status=todo").json()["data"] == []
assert account.call(path + "?status=unknown").status_code == 400
account.call(f"/api/projects/{item['id']}", "delete", {})
assert orm(application, lambda: Task.filter(id=task_id).first()) is None
@pytest.mark.parametrize(
"data",
[
{"title": ""},
{"title": "Task", "status": "invalid"},
{"title": "Task", "due_date": "2026-02-30"},
{"title": 123},
],
)
def test_task_validation(account, data):
item = project(account)
assert (
account.call(f"/api/tasks/project/{item['id']}", "post", data).status_code
== 400
)
def test_task_ownership_and_deletion(client, account):
item = project(account)
path = f"/api/tasks/project/{item['id']}"
task = account.call(path, "post", {"title": "Private task"}).json()["data"]
other = Account(client, "other@example.test")
assert other.call(path).status_code == 404
assert other.call(path, "post", {"title": "Wrong owner"}).status_code == 404
assert (
other.call(f"/api/tasks/{task['id']}", "patch", {"status": "done"}).status_code
== 404
)
assert other.call(f"/api/tasks/{task['id']}", "delete", {}).status_code == 404
assert account.call(f"/api/tasks/{task['id']}", "delete", {}).status_code == 200
def test_expired_session_is_rejected(account, application):
orm(application, lambda: Session.all().update(expires_at=int(time.time()) - 1))
assert account.call("/api/projects/").status_code == 401
def test_sql_values_are_not_executed(account):
response = account.call(
"/api/projects/", "post", {"name": "Robert'); DROP TABLE users;--"}
)
assert response.status_code == 201
assert account.call("/api/auth/session").json()["data"]["user"] is not None
def test_auth_rate_limit(account):
codes = [
account.call(
"/api/auth/login",
"post",
{"email": "learner@example.test", "password": "WrongPassword123!"},
).status_code
for _ in range(11)
]
assert 429 in codes
def test_unknown_api_is_404(client):
assert client.get("/api/not-a-route").status_code == 404
def test_request_body_limit(client):
response = client.post("/api/auth/register", json_data={"name": "x" * (300 * 1024)})
assert response.status_code == 413
def test_production_requires_secret(monkeypatch, tmp_path):
from app import create_app
monkeypatch.delenv("FLAXON_SECRET_KEY", raising=False)
with pytest.raises(ValueError, match="FLAXON_SECRET_KEY"):
create_app(tmp_path / "app.sqlite3", tmp_path / "admin.sqlite3", debug=False)
Say: "This file owns the feature named by its module or component. Read the route/form flow before continuing."
Type: Follow the action above in tests/test_api.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Run and check
python -m pytest -q tests/test_api.py
Expected result
tests/test_api.py passes. Each test uses temporary files and does not modify your learner database.
Common errors
Do not run later Admin tests yet. Keep the ORM context and event loop alive for the whole fixture. Sharing one account's cookie with another makes an ownership test invalid.
Short exercise
Add a regression test that sends an extra owner_id in project JSON and proves it cannot choose a different owner.
What to say and show
Say: "Run realistic HTTP requests against disposable ORM databases before writing the UI. The server remains responsible for persistence and authorization; the browser presents the result."
Show the expected result above, then point to the file responsible for it. Run the chapter check before the next lesson.
Save your checkpoint
git add .
git commit -m "Complete chapter 07: Backend tests"