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:
- Unit tests: Test individual functions, resource classes, or methods in isolation.
- Component tests: Test how a resource interacts with its dependencies (databases, external services) using mocks or fakes.
- Integration tests: Test the entire application end-to-end through Falcon's WSGI/ASGI interface using a test client.
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:
- Regression protection: Catch breaking changes before they reach production.
- Design feedback: Writing tests first (TDD) forces you to design clean, decoupled code.
- Documentation: Tests serve as living documentation of how your API should behave.
- Confidence in refactoring: A good test suite lets you change code without fear.
- Faster development cycles: Automated tests catch issues faster than manual testing.
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:
- Use dependency injection: Pass dependencies (databases, external clients) into your app factory function. This makes it trivial to swap in mocks or fakes during testing.
- Prefer
TestClientfor integration tests: It is faster and more reliable than starting a real HTTP server. Reserve real server tests for rare end-to-end scenarios. - Test both success and failure paths: Do not only test the happy path. Test invalid inputs, missing resources, permission errors, and edge cases.
- Keep unit tests fast: Unit tests should run in milliseconds. If they are slow, you probably have too many real dependencies in them.
- Use
conftest.pyfor shared fixtures: Avoid duplicating fixture code across test files. Centralize common setup inconftest.py. - Mock external services: Never let your tests depend on live external APIs. Use
responses,requests-mock, or similar libraries. - Test status codes and response bodies: Assert on both the HTTP status code and the response payload. This catches subtle bugs where the status is correct but the body is wrong.
- Measure coverage: Use
pytest --cov=app --cov-report=term-missingto identify untested code paths. Aim for high coverage on business logic, not necessarily on boilerplate. - Name tests descriptively: Use names like
test_get_quote_returns_404_when_not_foundinstead oftest_get_1. Clear names make failures easier to diagnose. - Organize tests by feature: Group related tests in classes or files. This makes it easier to run a subset of tests and understand what is being tested.
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.