← Back to DevBytes

Testing Falcon Applications: Unit Tests to Integration

Testing Falcon Applications: From Unit Tests to Integration

Testing is a critical part of building robust web APIs. Falcon, with its minimalist and clean design, makes testing straightforward — but knowing how to structure your tests from isolated unit tests all the way to full integration tests can be the difference between a maintainable codebase and a fragile one. This tutorial walks you through everything you need to know to test Falcon applications effectively.

What Is Testing in the Context of Falcon?

Falcon is a high-performance Python web framework designed for building RESTful APIs. Testing a Falcon application involves verifying that your resources, middleware, hooks, and routes behave as expected under various conditions. There are generally three levels of testing you should care about:

Why Testing Matters

APIs are often the backbone of larger systems. A bug in your Falcon application can cascade into failures across multiple consumers. Here is why a solid testing strategy is essential:

Setting Up the Test Environment

Before writing tests, you need to set up your dependencies. Falcon works seamlessly with pytest, which is the recommended testing framework. You will also want pytest-cov for coverage reports and responses or requests-mock if your app calls external HTTP services.

Create a requirements-test.txt file:

pytest>=7.0
pytest-cov>=4.0
responses>=0.23

Install the dependencies:

pip install -r requirements-test.txt

Here is a simple Falcon application that we will use throughout this tutorial. Save it as app.py:

import falcon
import json


class QuoteResource:
    def __init__(self, db):
        self._db = db

    def on_get(self, req, resp, quote_id):
        quote = self._db.get_quote(quote_id)
        if quote is None:
            raise falcon.HTTPNotFound(title="Quote not found")
        resp.media = quote

    def on_post(self, req, resp):
        data = req.get_media()
        if not data or "text" not in data:
            raise falcon.HTTPBadRequest(title="Invalid input", description="text is required")
        quote_id = self._db.add_quote(data["text"], data.get("author", "Unknown"))
        resp.status = falcon.HTTP_201
        resp.media = {"id": quote_id, "message": "Quote created"}


class HealthCheckResource:
    def on_get(self, req, resp):
        resp.media = {"status": "ok"}


def create_app(db):
    app = falcon.App()
    app.add_route("/health", HealthCheckResource())
    app.add_route("/quotes/{quote_id}", QuoteResource(db))
    app.add_route("/quotes", QuoteResource(db))
    return app

Notice how create_app accepts a db dependency. This dependency injection pattern is key to making your application testable.

Unit Testing Falcon Resources

Unit tests focus on testing a single resource or method in isolation. The idea is to mock or fake any external dependencies so you are only testing the logic of the resource itself.

Let us write unit tests for the QuoteResource class. We will use a fake database object to avoid hitting a real database. Save this as tests/test_unit.py:

import falcon
import falcon.testing
import pytest
from unittest.mock import MagicMock

from app import QuoteResource


@pytest.fixture
def mock_db():
    db = MagicMock()
    db.get_quote.return_value = {"id": 1, "text": "Hello", "author": "World"}
    db.add_quote.return_value = 42
    return db


@pytest.fixture
def resource(mock_db):
    return QuoteResource(mock_db)


@pytest.fixture
def client(resource):
    return falcon.testing.TestClient(falcon.App())
    # We will add the route manually for unit-level tests


class TestQuoteResourceUnit:

    def test_get_quote_success(self, resource, mock_db):
        req = falcon.testing.create_req()
        resp = falcon.testing.create_resp()
        
        resource.on_get(req, resp, quote_id=1)
        
        mock_db.get_quote.assert_called_once_with(1)
        assert resp.media == {"id": 1, "text": "Hello", "author": "World"}

    def test_get_quote_not_found(self, resource, mock_db):
        mock_db.get_quote.return_value = None
        req = falcon.testing.create_req()
        resp = falcon.testing.create_resp()
        
        with pytest.raises(falcon.HTTPNotFound):
            resource.on_get(req, resp, quote_id=999)

    def test_post_quote_success(self, resource, mock_db):
        req = falcon.testing.create_req(
            method="POST",
            body='{"text": "Be excellent", "author": "Bill"}'
        )
        resp = falcon.testing.create_resp()
        
        resource.on_post(req, resp)
        
        mock_db.add_quote.assert_called_once_with("Be excellent", "Bill")
        assert resp.status == falcon.HTTP_201
        assert resp.media == {"id": 42, "message": "Quote created"}

    def test_post_quote_missing_text(self, resource, mock_db):
        req = falcon.testing.create_req(
            method="POST",
            body='{"author": "Nobody"}'
        )
        resp = falcon.testing.create_resp()
        
        with pytest.raises(falcon.HTTPBadRequest):
            resource.on_post(req, resp)

In these tests, we directly call the resource methods (on_get, on_post) and use falcon.testing.create_req and falcon.testing.create_resp to construct request and response objects. The mock_db fixture uses unittest.mock.MagicMock to simulate database behavior without needing a real database connection.

Testing Middleware

Falcon middleware allows you to intercept requests and responses. Testing middleware is similar to testing resources — you call the middleware methods directly with mock objects. Here is an example of an auth middleware and its tests:

# auth_middleware.py
import falcon


class AuthMiddleware:
    def __init__(self, token_validator):
        self._validate = token_validator

    def process_request(self, req, resp):
        token = req.get_header("Authorization")
        if not token:
            raise falcon.HTTPUnauthorized(title="Missing token")
        if not self._validate(token):
            raise falcon.HTTPUnauthorized(title="Invalid token")
        req.context.user = "authenticated_user"
# tests/test_middleware.py
import falcon
import pytest
from unittest.mock import MagicMock

from auth_middleware import AuthMiddleware


@pytest.fixture
def validator():
    return MagicMock()


@pytest.fixture
def middleware(validator):
    return AuthMiddleware(validator)


class TestAuthMiddleware:

    def test_valid_token(self, middleware, validator):
        validator.return_value = True
        req = falcon.testing.create_req(headers={"Authorization": "Bearer valid"})
        resp = falcon.testing.create_resp()
        
        middleware.process_request(req, resp)
        
        validator.assert_called_once_with("Bearer valid")
        assert req.context.user == "authenticated_user"

    def test_missing_token(self, middleware):
        req = falcon.testing.create_req()
        resp = falcon.testing.create_resp()
        
        with pytest.raises(falcon.HTTPUnauthorized):
            middleware.process_request(req, resp)

    def test_invalid_token(self, middleware, validator):
        validator.return_value = False
        req = falcon.testing.create_req(headers={"Authorization": "Bearer bad"})
        resp = falcon.testing.create_resp()
        
        with pytest.raises(falcon.HTTPUnauthorized):
            middleware.process_request(req, resp)

Integration Testing with Falcon's TestClient

While unit tests verify individual components, integration tests verify that all the pieces work together correctly. Falcon provides falcon.testing.TestClient, which simulates HTTP requests against your application without starting an actual server. This is fast, reliable, and perfect for CI/CD pipelines.

Here is how to write integration tests for our application. Save this as tests/test_integration.py:

import falcon
import falcon.testing
import pytest
from unittest.mock import MagicMock

from app import create_app


@pytest.fixture
def mock_db():
    db = MagicMock()
    db.get_quote.return_value = {"id": 1, "text": "Stay hungry", "author": "Steve"}
    db.add_quote.return_value = 99
    return db


@pytest.fixture
def client(mock_db):
    app = create_app(mock_db)
    return falcon.testing.TestClient(app)


class TestHealthEndpoint:

    def test_health_check(self, client):
        result = client.simulate_get("/health")
        
        assert result.status_code == 200
        assert result.json == {"status": "ok"}


class TestQuotesEndpoint:

    def test_get_quote_by_id(self, client, mock_db):
        result = client.simulate_get("/quotes/1")
        
        assert result.status_code == 200
        assert result.json["text"] == "Stay hungry"
        mock_db.get_quote.assert_called_once_with("1")

    def test_get_quote_not_found(self, client, mock_db):
        mock_db.get_quote.return_value = None
        
        result = client.simulate_get("/quotes/999")
        
        assert result.status_code == 404
        assert "not found" in result.json["title"].lower()

    def test_create_quote(self, client, mock_db):
        payload = {"text": "Keep it simple", "author": "Anonymous"}
        
        result = client.simulate_post("/quotes", json=payload)
        
        assert result.status_code == 201
        assert result.json["id"] == 99
        mock_db.add_quote.assert_called_once_with("Keep it simple", "Anonymous")

    def test_create_quote_missing_text(self, client):
        payload = {"author": "Nobody"}
        
        result = client.simulate_post("/quotes", json=payload)
        
        assert result.status_code == 400
        assert "text is required" in result.json["description"]

    def test_create_quote_empty_body(self, client):
        result = client.simulate_post("/quotes")
        
        assert result.status_code == 400

The TestClient exposes methods like simulate_get, simulate_post, simulate_put, simulate_delete, and more. Each returns a Result object with attributes like status_code, json, headers, and text. This makes assertions clean and readable.

Using Fixtures for Test Organization

Pytest fixtures are powerful for organizing test setup. You can create a conftest.py file to share fixtures across multiple test files. Here is an example tests/conftest.py:

import pytest
import falcon.testing
from unittest.mock import MagicMock

from app import create_app


@pytest.fixture
def mock_db():
    db = MagicMock()
    return db


@pytest.fixture
def client(mock_db):
    app = create_app(mock_db)
    return falcon.testing.TestClient(app)


@pytest.fixture
def sample_quote():
    return {"id": 1, "text": "Test quote", "author": "Tester"}

With this conftest.py in place, any test file inside the tests/ directory can use the client, mock_db, and sample_quote fixtures without importing them explicitly.

Mocking External Dependencies

Real-world applications often call external APIs. You should mock these in tests to keep them fast and deterministic. The responses library is excellent for mocking HTTP calls made with the requests library. Here is an example:

# weather_resource.py
import falcon
import requests


class WeatherResource:
    def __init__(self, api_url, api_key):
        self._api_url = api_url
        self._api_key = api_key

    def on_get(self, req, resp, city):
        r = requests.get(
            f"{self._api_url}/weather",
            params={"q": city, "appid": self._api_key}
        )
        if r.status_code == 404:
            raise falcon.HTTPNotFound(title="City not found")
        r.raise_for_status()
        data = r.json()
        resp.media = {"city": city, "temp": data["main"]["temp"]}
# tests/test_weather.py
import falcon
import falcon.testing
import pytest
import responses

from weather_resource import WeatherResource


@pytest.fixture
def app():
    application = falcon.App()
    application.add_route("/weather/{city}", WeatherResource(
        api_url="https://api.example.com",
        api_key="test-key"
    ))
    return application


@pytest.fixture
def client(app):
    return falcon.testing.TestClient(app)


class TestWeatherResource:

    @responses.activate
    def test_get_weather_success(self, client):
        responses.add(
            responses.GET,
            "https://api.example.com/weather",
            json={"main": {"temp": 22.5}},
            status=200
        )
        
        result = client.simulate_get("/weather/London")
        
        assert result.status_code == 200
        assert result.json == {"city": "London", "temp": 22.5}

    @responses.activate
    def test_get_weather_city_not_found(self, client):
        responses.add(
            responses.GET,
            "https://api.example.com/weather",
            json={"message": "city not found"},
            status=404
        )
        
        result = client.simulate_get("/weather/Atlantis")
        
        assert result.status_code == 404

    @responses.activate
    def test_get_weather_server_error(self, client):
        responses.add(
            responses.GET,
            "https://api.example.com/weather",
            json={"error": "internal"},
            status=500
        )
        
        result = client.simulate_get("/weather/London")
        
        assert result.status_code == 500

The @responses.activate decorator intercepts all HTTP requests made during the test and returns the mocked responses instead. This ensures your tests never hit real external services.

Testing Error Handlers

Falcon allows you to register custom error handlers. You should test that these handlers produce the expected response format. Here is an example:

# error_handlers.py
import falcon
import json


def handle_generic_error(req, resp, ex, params):
    resp.status = falcon.HTTP_500
    resp.text = json.dumps({"error": "internal_server_error", "detail": str(ex)})
    resp.content_type = "application/json"
# tests/test_error_handlers.py
import falcon
import falcon.testing
import pytest

from error_handlers import handle_generic_error


class FailingResource:
    def on_get(self, req, resp):
        raise ValueError("Something went wrong")


@pytest.fixture
def client():
    app = falcon.App()
    app.add_route("/fail", FailingResource())
    app.add_error_handler(ValueError, handle_generic_error)
    return falcon.testing.TestClient(app)


class TestErrorHandlers:

    def test_generic_error_handler(self, client):
        result = client.simulate_get("/fail")
        
        assert result.status_code == 500
        assert result.json["error"] == "internal_server_error"
        assert "Something went wrong" in result.json["detail"]

Testing with a Real Database

For true integration tests, you may want to test against a real database. A common pattern is to use a separate test database that is reset between test runs. Here is an example using SQLite:

# tests/test_db_integration.py
import falcon
import falcon.testing
import pytest
import sqlite3

from app import create_app


class SQLiteDB:
    def __init__(self, conn):
        self._conn = conn
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS quotes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                text TEXT NOT NULL,
                author TEXT
            )
        """)
        self._conn.commit()

    def get_quote(self, quote_id):
        cursor = self._conn.execute(
            "SELECT id, text, author FROM quotes WHERE id = ?", (quote_id,)
        )
        row = cursor.fetchone()
        if row is None:
            return None
        return {"id": row[0], "text": row[1], "author": row[2]}

    def add_quote(self, text, author):
        cursor = self._conn.execute(
            "INSERT INTO quotes (text, author) VALUES (?, ?)", (text, author)
        )
        self._conn.commit()
        return cursor.lastrowid


@pytest.fixture
def db():
    conn = sqlite3.connect(":memory:")
    db = SQLiteDB(conn)
    yield db
    conn.close()


@pytest.fixture
def client(db):
    app = create_app(db)
    return falcon.testing.TestClient(app)


class TestQuoteDBIntegration:

    def test_create_and_retrieve_quote(self, client):
        create_result = client.simulate_post(
            "/quotes",
            json={"text": "Integration test quote", "author": "Pytest"}
        )
        
        assert create_result.status_code == 201
        quote_id = create_result.json["id"]
        
        get_result = client.simulate_get(f"/quotes/{quote_id}")
        
        assert get_result.status_code == 200
        assert get_result.json["text"] == "Integration test quote"
        assert get_result.json["author"] == "Pytest"

    def test_retrieve_nonexistent_quote(self, client):
        result = client.simulate_get("/quotes/9999")
        
        assert result.status_code == 404

Using an in-memory SQLite database ensures tests are fast and isolated. Each test gets a fresh database through the fixture, so there are no side effects between tests.

Best Practices for Testing Falcon Applications

To get the most out of your test suite, follow these best practices:

Running Your Tests

Run all tests with coverage using the following command:

pytest tests/ --cov=app --cov-report=term-missing --cov-report=html

This will run all tests in the tests/ directory, print a coverage summary to the terminal, and generate an HTML coverage report in htmlcov/. You can open htmlcov/index.html in a browser to see a detailed line-by-line coverage view.

To run only unit tests or only integration tests, you can use pytest markers or simply point to specific files:

pytest tests/test_unit.py -v
pytest tests/test_integration.py -v

Conclusion

Testing Falcon applications does not have to be complicated. By leveraging Falcon's built-in testing module, pytest fixtures, and mocking libraries, you can build a comprehensive test suite that covers everything from individual resource methods to full request-response cycles. Start with unit tests for your resource logic, add integration tests using TestClient to verify routing and middleware behavior, and mock external dependencies to keep your tests fast and deterministic. With a well-structured test suite in place, you can develop and refactor your Falcon APIs with confidence, knowing that regressions will be caught before they reach your users.

— Ad —

Google AdSense will appear here after approval

← Back to all articles