← Back to DevBytes

Testing Sanic Applications: Unit Tests to Integration

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles