Testing¶
This guide covers strategies and tools for testing Cello applications, from simple route tests to full integration suites.
Setup¶
Install the testing dependencies:
Project Layout¶
myproject/
├── app.py
├── tests/
│ ├── conftest.py # Shared fixtures
│ ├── test_users.py # Route tests
│ └── test_services.py # Unit tests
Starting the Server for Tests¶
Cello applications run their own HTTP server, so integration tests send real HTTP requests. Use a fixture to start the server in a background thread.
conftest.py¶
import pytest
import threading
import time
from app import app
BASE_URL = "http://127.0.0.1:9000"
@pytest.fixture(scope="session", autouse=True)
def start_server():
"""Start the Cello server in a background thread."""
server_thread = threading.Thread(
target=lambda: app.run(host="127.0.0.1", port=9000, logs=False),
daemon=True,
)
server_thread.start()
time.sleep(1) # Wait for the server to be ready
yield
# Server thread is a daemon; it stops when the process exits
Tip
Use a dedicated port (e.g., 9000) for tests to avoid collisions with a development server running on 8000.
Testing Routes¶
Basic GET¶
import requests
BASE_URL = "http://127.0.0.1:9000"
def test_list_books():
resp = requests.get(f"{BASE_URL}/books")
assert resp.status_code == 200
data = resp.json()
assert "books" in data
assert isinstance(data["books"], list)
POST with JSON Body¶
def test_create_book():
payload = {"title": "Dune", "author": "Frank Herbert"}
resp = requests.post(f"{BASE_URL}/books", json=payload)
assert resp.status_code == 201
data = resp.json()
assert data["title"] == "Dune"
assert "id" in data
Path Parameters¶
def test_get_book_not_found():
resp = requests.get(f"{BASE_URL}/books/99999")
assert resp.status_code == 404
Testing with Headers¶
def test_protected_route():
# Without auth
resp = requests.get(f"{BASE_URL}/me")
assert resp.status_code == 401
# With auth
token = get_test_token()
resp = requests.get(
f"{BASE_URL}/me",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
Mocking Dependencies¶
When testing handlers that rely on external services, replace the dependency with a mock.
Using monkeypatch¶
def test_create_order_user_not_found(monkeypatch):
"""Order creation should fail if User Service is unavailable."""
import order_service
def mock_fetch_user(user_id):
return None
monkeypatch.setattr(order_service, "fetch_user", mock_fetch_user)
resp = requests.post(
f"{BASE_URL}/orders",
json={"user_id": 1, "items": ["Book"]},
)
assert resp.status_code == 400
Using unittest.mock¶
from unittest.mock import patch, MagicMock
def test_service_called():
with patch("services.user_service.get_user") as mock_get:
mock_get.return_value = {"id": 1, "name": "Alice"}
resp = requests.get(f"{BASE_URL}/users/1")
assert resp.status_code == 200
mock_get.assert_called_once_with("1")
Async Tests¶
If you use pytest-asyncio, you can test async service functions directly without going through HTTP.
import pytest
@pytest.mark.asyncio
async def test_fetch_users():
from services.user_service import fetch_users
users = await fetch_users()
assert isinstance(users, list)
Async Fixtures¶
@pytest.fixture
async def db_connection():
conn = await create_test_connection()
yield conn
await conn.close()
@pytest.mark.asyncio
async def test_query(db_connection):
result = await db_connection.fetch("SELECT 1 as n")
assert result[0]["n"] == 1
Test Fixtures¶
Resetting State Between Tests¶
If your application uses in-memory stores, reset them between tests.
@pytest.fixture(autouse=True)
def reset_state():
"""Clear the in-memory store before each test."""
import app as app_module
app_module.books.clear()
app_module.next_id = 1
yield
Factory Fixtures¶
Create test data using factory functions.
@pytest.fixture
def sample_book():
resp = requests.post(
f"{BASE_URL}/books",
json={"title": "Test Book", "author": "Test Author"},
)
return resp.json()
def test_update_book(sample_book):
book_id = sample_book["id"]
resp = requests.put(
f"{BASE_URL}/books/{book_id}",
json={"title": "Updated Title"},
)
assert resp.status_code == 200
assert resp.json()["title"] == "Updated Title"
Integration Tests¶
Integration tests exercise the full stack: HTTP layer, middleware, routing, and handler logic.
class TestUserWorkflow:
"""Test the complete user lifecycle."""
def test_register_login_profile(self):
# Register
resp = requests.post(f"{BASE_URL}/auth/register", json={
"email": "test@example.com",
"password": "password123",
"name": "Test",
})
assert resp.status_code == 201
# Login
resp = requests.post(f"{BASE_URL}/auth/login", json={
"email": "test@example.com",
"password": "password123",
})
assert resp.status_code == 200
token = resp.json()["access_token"]
# Profile
resp = requests.get(
f"{BASE_URL}/me",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "test@example.com"
Running Tests¶
# Run all tests
pytest tests/ -v
# Run a specific file
pytest tests/test_users.py -v
# Run with coverage
pip install pytest-cov
pytest tests/ --cov=app --cov-report=term-missing
# Stop on first failure
pytest tests/ -x
Tips¶
| Tip | Details |
|---|---|
Use scope="session" for server fixtures | Avoids restarting the server for every test |
| Assign a unique test port | Prevents conflicts with development servers |
| Test error cases | Always verify 400, 401, 404, and 500 responses |
Use pytest -x during development | Stops at the first failure for faster feedback |
Add --tb=short for cleaner output | Reduces traceback noise in CI logs |