Testing Sanic Applications: From Unit Tests to Integration
Sanic is a fast, asynchronous Python web framework built on top of asyncio. Like any modern web framework, it demands a robust testing strategy to ensure reliability, maintainability, and confidence in production deployments. This tutorial walks you through the full spectrum of testing Sanic applications — starting from isolated unit tests and progressing to full integration tests that exercise the HTTP stack end-to-end.
Why Testing Sanic Applications Matters
Sanic's asynchronous nature introduces unique challenges: coroutines, event loops, background tasks, and middleware all behave differently from synchronous Flask-style code. A solid test suite helps you:
- Catch regressions early before they reach production
- Validate route handlers, middleware, and error handlers in isolation
- Verify that async background tasks and websockets behave correctly
- Document expected behavior through executable examples
- Refactor with confidence as your application grows
Setting Up Your Test Environment
Sanic provides a built-in test client via sanic_testing. In Sanic 21.9+, the testing package is bundled, but you may need to install it explicitly in older versions. We will also use pytest and pytest-asyncio for managing async tests.
pip install sanic pytest pytest-asyncio httpx
Create a conftest.py at the root of your project to configure pytest:
# conftest.py
import pytest
def pytest_collection_modifyitems(items):
pytest_asyncio_tests = []
for item in items:
if "asyncio" in item.keywords:
pytest_asyncio_tests.append(item)
For most projects, a simpler pytest.ini configuration is enough:
# pytest.ini
[pytest]
asyncio_mode = auto
testpaths = tests
A Sample Sanic Application
Let's build a small but realistic Sanic application that we will test throughout this tutorial. Create app.py:
# app.py
from sanic import Sanic, json
from sanic.exceptions import NotFound
app = Sanic("MyApp")
# In-memory data store
db = {}
@app.before_server_start
async def setup_db(app):
app.ctx.db = {}
@app.get("/items")
async def list_items(request):
items = list(request.app.ctx.db.values())
return json({"items": items})
@app.post("/items")
async def create_item(request):
data = request.json
if not data or "name" not in data:
return json({"error": "name is required"}, status=400)
item_id = str(len(request.app.ctx.db) + 1)
request.app.ctx.db[item_id] = {"id": item_id, "name": data["name"]}
return json(request.app.ctx.db[item_id], status=201)
@app.get("/items/<item_id>")
async def get_item(request, item_id):
item = request.app.ctx.db.get(item_id)
if item is None:
raise NotFound(f"Item {item_id} not found")
return json(item)
@app.delete("/items/<item_id>")
async def delete_item(request, item_id):
if item_id not in request.app.ctx.db:
raise NotFound(f"Item {item_id} not found")
del request.app.ctx.db[item_id]
return json({"deleted": item_id})
@app.exception(NotFound)
async def not_found(request, exception):
return json({"error": str(exception)}, status=404)
Unit Testing Individual Components
Unit tests focus on small, isolated pieces of logic. In a Sanic app, this often means testing helper functions, validators, business logic, and serializers without spinning up the HTTP server. Let's extract some logic into a separate module to make it testable.
# services.py
def validate_item(data):
"""Validate incoming item payload."""
if not isinstance(data, dict):
return False, "Payload must be a JSON object"
if "name" not in data:
return False, "name is required"
if not isinstance(data["name"], str) or not data["name"].strip():
return False, "name must be a non-empty string"
return True, None
def serialize_item(item_id, name):
"""Serialize an item for the response."""
return {"id": item_id, "name": name}
Now write unit tests for these functions:
# tests/test_services.py
from services import validate_item, serialize_item
def test_validate_item_valid():
valid, error = validate_item({"name": "Widget"})
assert valid is True
assert error is None
def test_validate_item_missing_name():
valid, error = validate_item({"price": 10})
assert valid is False
assert "name is required" in error
def test_validate_item_empty_name():
valid, error = validate_item({"name": " "})
assert valid is False
assert "non-empty" in error
def test_validate_item_not_dict():
valid, error = validate_item("not a dict")
assert valid is False
assert "JSON object" in error
def test_serialize_item():
result = serialize_item("1", "Widget")
assert result == {"id": "1", "name": "Widget"}
These tests run fast because they do not touch the network or the event loop. They verify pure logic, which is the essence of unit testing.
Testing Async Functions
Many Sanic components are async. Suppose we add an async helper that simulates a database lookup:
# services.py (continued)
import asyncio
async def fetch_item_async(db, item_id):
await asyncio.sleep(0.01) # simulate I/O
return db.get(item_id)
With pytest-asyncio in auto mode, you can write async tests directly:
# tests/test_async_services.py
import pytest
from services import fetch_item_async
@pytest.mark.asyncio
async def test_fetch_item_async_found():
db = {"1": {"id": "1", "name": "Widget"}}
result = await fetch_item_async(db, "1")
assert result == {"id": "1", "name": "Widget"}
@pytest.mark.asyncio
async def test_fetch_item_async_not_found():
db = {}
result = await fetch_item_async(db, "999")
assert result is None
Integration Testing with the Sanic Test Client
Integration tests exercise the full request-response cycle, including routing, middleware, serialization, and error handling. Sanic provides a test client that lets you make requests against your app without manually starting a server.
# tests/test_app_integration.py
import pytest
from app import app
@pytest.fixture
def test_app():
# Sanic's test_client manages the lifecycle of the app
app.ctx.db = {}
return app.test_client
def test_list_items_empty(test_app):
request, response = test_app.get("/items")
assert response.status == 200
assert response.json == {"items": []}
def test_create_item_success(test_app):
request, response = test_app.post("/items", json={"name": "Widget"})
assert response.status == 201
assert response.json["name"] == "Widget"
assert "id" in response.json
def test_create_item_validation_error(test_app):
request, response = test_app.post("/items", json={"price": 10})
assert response.status == 400
assert "error" in response.json
def test_get_item_found(test_app):
# First create an item
test_app.post("/items", json={"name": "Gadget"})
request, response = test_app.get("/items/1")
assert response.status == 200
assert response.json["name"] == "Gadget"
def test_get_item_not_found(test_app):
request, response = test_app.get("/items/999")
assert response.status == 404
assert "error" in response.json
def test_delete_item(test_app):
test_app.post("/items", json={"name": "ToDelete"})
request, response = test_app.delete("/items/1")
assert response.status == 200
assert response.json == {"deleted": "1"}
# Verify it's gone
request, response = test_app.get("/items/1")
assert response.status == 404
Using ASGI and HTTPX for Realistic Tests
For even more realistic integration tests, you can run Sanic as an ASGI app and use httpx as the client. This approach closely mirrors production behavior.
# tests/test_asgi.py
import pytest
from app import app
@pytest.fixture
async def http_client():
from httpx import AsyncClient, ASGITransport
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_asgi_create_and_list(http_client):
response = await http_client.post("/items", json={"name": "ASGI Item"})
assert response.status_code == 201
response = await http_client.get("/items")
assert response.status_code == 200
assert len(response.json()["items"]) >= 1
Testing Middleware
Middleware is a common place for bugs. Let's add an authentication middleware and test it:
# app.py (additions)
@app.middleware("request")
async def authenticate(request):
# Skip auth for health check
if request.path == "/health":
return None
token = request.headers.get("Authorization")
if not token or token != "Bearer secret-token":
return json({"error": "Unauthorized"}, status=401)
@app.get("/health")
async def health(request):
return json({"status": "ok"})
# tests/test_middleware.py
import pytest
from app import app
@pytest.fixture
def test_app():
app.ctx.db = {}
return app.test_client
def test_health_endpoint_no_auth_required(test_app):
request, response = test_app.get("/health")
assert response.status == 200
assert response.json == {"status": "ok"}
def test_protected_endpoint_without_token(test_app):
request, response = test_app.get("/items")
assert response.status == 401
assert response.json == {"error": "Unauthorized"}
def test_protected_endpoint_with_valid_token(test_app):
headers = {"Authorization": "Bearer secret-token"}
request, response = test_app.get("/items", headers=headers)
assert response.status == 200
def test_protected_endpoint_with_invalid_token(test_app):
headers = {"Authorization": "Bearer wrong-token"}
request, response = test_app.get("/items", headers=headers)
assert response.status == 401
Testing Background Tasks
Sanic supports background tasks via app.add_task. Testing them requires care because they run asynchronously. Here is an example:
# app.py (additions)
import asyncio
processed_events = []
@app.post("/events")
async def create_event(request):
data = request.json
app.add_task(process_event(app, data))
return json({"queued": True}, status=202)
async def process_event(app, data):
await asyncio.sleep(0.05)
processed_events.append(data)
# tests/test_background_tasks.py
import pytest
import asyncio
from app import app, processed_events
@pytest.fixture
def test_app():
processed_events.clear()
app.ctx.db = {}
return app.test_client
def test_background_task_processes_event(test_app):
request, response = test_app.post("/events", json={"type": "click"})
assert response.status == 202
# Allow background task to complete
# The test client waits for pending tasks in many versions,
# but we add a small loop to be safe
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.sleep(0.1))
assert len(processed_events) == 1
assert processed_events[0]["type"] == "click"
Best Practices for Testing Sanic Applications
- Separate unit and integration tests: Keep pure logic tests fast and isolated. Reserve integration tests for verifying the wiring between components.
- Use fixtures for app state: Reset
app.ctxor any in-memory stores between tests to avoid cross-test contamination. - Avoid shared global state: If your app uses global variables, refactor to use
app.ctxso tests can reset state cleanly. - Test edge cases: Cover empty inputs, missing fields, invalid types, and boundary conditions, not just the happy path.
- Mock external dependencies: Use
unittest.mockorpytest-mockto stub database calls, third-party APIs, and file I/O in unit tests. - Test error handlers: Verify that custom exceptions return the correct status codes and JSON payloads.
- Run tests in CI: Integrate your test suite into GitHub Actions, GitLab CI, or similar pipelines to catch regressions automatically.
- Measure coverage: Use
pytest-covto identify untested code paths and aim for meaningful coverage, not just a high percentage. - Prefer ASGI testing for production parity: When behavior depends on the server runtime, use
httpxwith the ASGI transport for the most realistic results. - Keep tests deterministic: Avoid relying on timing, random data, or external services unless explicitly mocked.
Mocking External Dependencies
Real applications depend on databases, caches, and external APIs. In unit tests, mock these dependencies to keep tests fast and reliable:
# tests/test_mocking.py
import pytest
from unittest.mock import AsyncMock, patch
from app import app
@pytest.fixture
def test_app():
app.ctx.db = {}
return app.test_client
@pytest.mark.asyncio
async def test_create_item_with_mocked_db(test_app):
fake_save = AsyncMock(return_value={"id": "mock-1", "name": "Mocked"})
with patch("app.process_event", fake_save):
request, response = test_app.post("/items", json={"name": "Mocked"})
assert response.status == 201
Organizing Your Test Suite
A clean directory structure helps maintain a growing test suite:
my_sanic_app/
├── app.py
├── services.py
├── conftest.py
├── pytest.ini
└── tests/
├── __init__.py
├── test_services.py
├── test_async_services.py
├── test_app_integration.py
├── test_asgi.py
├── test_middleware.py
└── test_background_tasks.py
Run the entire suite with a single command:
pytest -v
To generate a coverage report:
pytest --cov=app --cov=services --cov-report=term-missing
Conclusion
Testing Sanic applications effectively requires a layered approach. Start with fast unit tests that validate pure business logic and async helpers in isolation. Then build integration tests using Sanic's built-in test client or an ASGI transport with HTTPX to verify routing, middleware, error handling, and background tasks. By combining these strategies with disciplined mocking, clean fixtures, and continuous integration, you can ship Sanic applications with confidence that they behave correctly under real-world conditions. A well-structured test suite is not just a safety net — it is a living specification that documents how your application is meant to work.