Testing Your Ravyn Application¶
Testing ensures your API works correctly and prevents bugs from reaching production. Ravyn includes a built-in test client that makes testing fast and easy.
What You'll Learn¶
- How to install and use the Ravyn test client
- Writing tests for routes and handlers
- Testing with pytest and async functions
- Using fixtures and context managers
- Overriding settings for tests
Quick Start¶
Installation¶
pip install ravyn[test]
This installs RavynTestClient and testing dependencies.
Your First Test¶
from ravyn import Ravyn, get
from ravyn.testclient import RavynTestClient
app = Ravyn()
@app.get("/hello")
def hello() -> dict:
return {"message": "Hello, World!"}
# Test it
def test_hello():
client = RavynTestClient(app)
response = client.get("/hello")
assert response.status_code == 200
assert response.json() == {"message": "Hello, World!"}
Run with pytest:
pytest test_app.py
That's it! You've tested your first endpoint.
Why Test Your API?¶
Testing gives you:
- Confidence - Know your code works before deploying
- Regression Prevention - Catch bugs when changing code
- Documentation - Tests show how your API should work
- Faster Development - Fix bugs early, not in production
The RavynTestClient¶
RavynTestClient is built on top of httpx.Client with Ravyn-specific features.
Basic Usage¶
from ravyn import Ravyn, get, post
from ravyn.testclient import RavynTestClient
app = Ravyn()
@app.get("/users")
def list_users() -> dict:
return {"users": ["Alice", "Bob"]}
@app.post("/users")
def create_user(name: str) -> dict:
return {"created": name}
def test_list_users():
client = RavynTestClient(app)
response = client.get("/users")
assert response.status_code == 200
assert response.json() == {"users": ["Alice", "Bob"]}
def test_create_user():
client = RavynTestClient(app)
response = client.post("/users", json={"name": "Charlie"})
assert response.status_code == 200
assert response.json() == {"created": "Charlie"}
Available Methods¶
All standard HTTP methods are supported:
client.get("/path")
client.post("/path", json={...})
client.put("/path", json={...})
client.patch("/path", json={...})
client.delete("/path")
client.head("/path")
client.options("/path")
Reference: See the Test Client API Reference.
Testing with Authentication¶
Use headers, cookies, and authentication like you would with httpx:
Headers¶
def test_with_auth_header():
client = RavynTestClient(app)
response = client.get(
"/protected",
headers={"Authorization": "Bearer token123"}
)
assert response.status_code == 200
Cookies¶
def test_with_cookies():
client = RavynTestClient(app)
response = client.get(
"/profile",
cookies={"session_id": "abc123"}
)
assert response.status_code == 200
Session Cookies¶
def test_login_flow():
client = RavynTestClient(app)
# Login sets a cookie
login_response = client.post("/login", json={
"username": "alice",
"password": "secret"
})
# Cookie is automatically included in next request
profile_response = client.get("/profile")
assert profile_response.status_code == 200
Testing File Uploads¶
from ravyn import Ravyn, post, UploadFile
from ravyn.testclient import RavynTestClient
app = Ravyn()
@app.post("/upload")
async def upload_file(file: UploadFile) -> dict:
content = await file.read()
return {"filename": file.filename, "size": len(content)}
def test_file_upload():
client = RavynTestClient(app)
response = client.post(
"/upload",
files={"file": ("test.txt", b"Hello, World!", "text/plain")}
)
assert response.status_code == 200
assert response.json()["filename"] == "test.txt"
assert response.json()["size"] == 13
Testing Async Handlers¶
Tests can be sync or async. both work with RavynTestClient:
Sync Test (Recommended)¶
from ravyn import Ravyn, get
from ravyn.testclient import RavynTestClient
app = Ravyn()
@app.get("/async-endpoint")
async def async_handler() -> dict:
return {"message": "Async works!"}
# Test is sync, handler is async - works fine!
def test_async_handler():
client = RavynTestClient(app)
response = client.get("/async-endpoint")
assert response.status_code == 200
Async Test¶
import pytest
@pytest.mark.asyncio
async def test_async_handler_async():
client = RavynTestClient(app)
response = client.get("/async-endpoint")
assert response.status_code == 200
Tip
Use sync tests unless you specifically need async features. They're simpler and faster.
Testing with Pytest Fixtures¶
Fixtures make tests cleaner by reusing setup code:
Basic Fixture¶
import pytest
from ravyn import Ravyn, get
from ravyn.testclient import RavynTestClient
@pytest.fixture
def app():
app = Ravyn()
@app.get("/users")
def list_users() -> dict:
return {"users": ["Alice", "Bob"]}
return app
@pytest.fixture
def client(app):
return RavynTestClient(app)
def test_list_users(client):
response = client.get("/users")
assert response.status_code == 200
assert len(response.json()["users"]) == 2
Fixture with Dependency Injection¶
import pytest
from ravyn import Ravyn, Gateway, Inject, Injects, get
def get_test_database():
return {"db": "test_db"}
@pytest.fixture
def app():
@get()
def users(db: dict = Injects()) -> dict:
return {"users": [], "db": db}
return Ravyn(
routes=[Gateway("/users", handler=users)],
dependencies={"db": Inject(get_test_database)}
)
@pytest.fixture
def client(app):
return RavynTestClient(app)
def test_users_with_test_db(client):
response = client.get("/users")
assert response.json()["db"] == {"db": "test_db"}
Testing Lifespan Events¶
Lifespan events (on_startup, on_shutdown, lifespan) only run when using RavynTestClient as a context manager.
Using create_client Context Manager¶
from ravyn import Ravyn, get
from ravyn.testclient import create_client
app = Ravyn()
startup_called = False
shutdown_called = False
@app.on_event("startup")
async def startup():
global startup_called
startup_called = True
@app.on_event("shutdown")
async def shutdown():
global shutdown_called
shutdown_called = True
@app.get("/test")
def test_endpoint() -> dict:
return {"message": "test"}
def test_with_lifespan():
global startup_called, shutdown_called
with create_client(routes=[Gateway("/test", handler=test_endpoint)]) as client:
# Startup event has run
assert startup_called is True
response = client.get("/test")
assert response.status_code == 200
# Shutdown event has run
assert shutdown_called is True
Manual Context Manager¶
def test_manual_context_manager():
with RavynTestClient(app) as client:
# Startup runs here
response = client.get("/test")
assert response.status_code == 200
# Shutdown runs here
Warning
If you instantiate RavynTestClient without a context manager, lifespan events will not run.
Overriding Settings for Tests¶
Use override_settings to temporarily change settings during tests.
As a Decorator¶
from ravyn import Ravyn, Gateway, get
from ravyn.testclient import RavynTestClient, override_settings
from ravyn.responses import PlainText
@get()
def homepage() -> PlainText:
return PlainText("Ok", status_code=200)
app = Ravyn(routes=[Gateway("/", handler=homepage)])
@override_settings(debug=True, app_name="Test App")
def test_with_custom_settings():
client = RavynTestClient(app)
response = client.get("/")
assert response.status_code == 200
# Settings are overridden only in this test
As a Context Manager¶
from ravyn.testclient import override_settings
def test_with_settings_context():
with override_settings(debug=True):
client = RavynTestClient(app)
response = client.get("/")
assert response.status_code == 200
# Settings restored after 'with' block
Real-World Example¶
from lilya.middleware import DefineMiddleware
from ravyn import Ravyn, Gateway, get
from ravyn.middleware.clickjacking import XFrameOptionsMiddleware
from ravyn.responses import PlainText
from ravyn.testclient import RavynTestClient, override_settings
@get()
def homepage() -> PlainText:
return PlainText("Ok", status_code=200)
@override_settings(x_frame_options="SAMEORIGIN")
def test_xframe_options():
app = Ravyn(
routes=[Gateway("/", handler=homepage)],
middleware=[DefineMiddleware(XFrameOptionsMiddleware)]
)
client = RavynTestClient(app)
response = client.get("/")
assert response.headers["x-frame-options"] == "SAMEORIGIN"
Handling Server Exceptions¶
By default, RavynTestClient raises exceptions that occur in your application.
Default Behavior (Raises Exceptions)¶
from ravyn import Ravyn, get
from ravyn.testclient import RavynTestClient
app = Ravyn()
@app.get("/error")
def error_endpoint() -> dict:
raise ValueError("Something went wrong!")
def test_error_raises():
client = RavynTestClient(app)
# This raises ValueError
try:
response = client.get("/error")
except ValueError as e:
assert str(e) == "Something went wrong!"
Testing Error Responses (Don't Raise)¶
def test_error_response():
client = RavynTestClient(app, raise_server_exceptions=False)
response = client.get("/error")
# Now we get a 500 response instead of an exception
assert response.status_code == 500
Tip
Use raise_server_exceptions=False when testing error handling and 500 responses.
Common Pitfalls & Fixes¶
Pitfall 1: Lifespan Events Not Running¶
Problem: Startup/shutdown events don't execute.
# Wrong - events don't run
def test_startup():
client = RavynTestClient(app) # No context manager
response = client.get("/test")
Solution: Use a context manager:
# Correct
def test_startup():
with RavynTestClient(app) as client: # Events run!
response = client.get("/test")
Or use create_client:
# Also correct
from ravyn.testclient import create_client
def test_startup():
with create_client(app=app) as client:
response = client.get("/test")
Pitfall 2: Forgetting to Install Test Dependencies¶
Problem: ModuleNotFoundError: No module named 'ravyn.testclient'
Solution: Install test extras:
pip install ravyn[test]
Pitfall 3: Testing Async Code Without pytest-asyncio¶
Problem: Async tests fail with RuntimeError: no running event loop
# Wrong - missing pytest mark
async def test_async():
client = RavynTestClient(app)
response = client.get("/test")
Solution: Add pytest.mark.asyncio or use sync tests:
# Option 1: Add decorator
import pytest
@pytest.mark.asyncio
async def test_async():
client = RavynTestClient(app)
response = client.get("/test")
# Option 2: Use sync test (recommended)
def test_sync():
client = RavynTestClient(app)
response = client.get("/test")
Pitfall 4: Not Resetting Global State Between Tests¶
Problem: Tests affect each other through shared state.
# Wrong - shared state
users = []
@app.post("/users")
def create_user(name: str) -> dict:
users.append(name) # Modifies global list
return {"created": name}
def test_create_user_1():
client = RavynTestClient(app)
client.post("/users", json={"name": "Alice"})
assert len(users) == 1 # Passes
def test_create_user_2():
client = RavynTestClient(app)
client.post("/users", json={"name": "Bob"})
assert len(users) == 1 # Fails! users has 2 items
Solution: Reset state in fixtures or use dependency injection:
# Correct - reset in fixture
import pytest
@pytest.fixture(autouse=True)
def reset_users():
global users
users = []
yield
users = []
Testing Patterns¶
Pattern 1: Arrange-Act-Assert¶
def test_create_and_get_user():
# Arrange
client = RavynTestClient(app)
user_data = {"name": "Alice", "email": "alice@example.com"}
# Act
create_response = client.post("/users", json=user_data)
user_id = create_response.json()["id"]
get_response = client.get(f"/users/{user_id}")
# Assert
assert create_response.status_code == 201
assert get_response.status_code == 200
assert get_response.json()["name"] == "Alice"
Pattern 2: Parametrized Tests¶
import pytest
@pytest.mark.parametrize("path,expected_status", [
("/users", 200),
("/users/123", 200),
("/users/invalid", 404),
("/admin", 403),
])
def test_endpoints(path, expected_status):
client = RavynTestClient(app)
response = client.get(path)
assert response.status_code == expected_status
Pattern 3: Shared Client Fixture¶
import pytest
@pytest.fixture(scope="module")
def client():
"""Shared client for all tests in module"""
return RavynTestClient(app)
def test_endpoint_1(client):
response = client.get("/test1")
assert response.status_code == 200
def test_endpoint_2(client):
response = client.get("/test2")
assert response.status_code == 200
Next Steps¶
Now that you can test your application, explore:
- Dependencies - Test with dependency injection
- Middleware - Test middleware behavior
- Permissions - Test authorization
- Database Testing - Test with databases
- Guides - Full testing examples