Testing Flask Apps
Flask's test_client(), and a complete test for a route backed by a database.
test_client()
Flask apps are tested with pytest (the de facto standard for Python testing) plus Flask's own test_client(), which simulates real HTTP requests against your app in-process — no running server needed.
# app.py
from flask import Flask, jsonify
def create_app():
app = Flask(__name__)
@app.route("/tasks/<int:task_id>")
def get_task(task_id):
tasks = {1: "Buy milk", 2: "Write tests"}
title = tasks.get(task_id)
if title is None:
return jsonify({"error": "not found"}), 404
return jsonify({"id": task_id, "title": title})
return app
# test_app.py
import pytest
from app import create_app
@pytest.fixture
def client():
app = create_app()
app.config.update(TESTING=True)
return app.test_client()
def test_get_existing_task(client):
response = client.get("/tasks/1")
assert response.status_code == 200
assert response.get_json() == {"id": 1, "title": "Buy milk"}
def test_get_missing_task_returns_404(client):
response = client.get("/tasks/999")
assert response.status_code == 404
pytest
TESTING=True disables error catching during request handling and makes exceptions propagate normally, which makes debugging a failing test easier — a raised exception shows up as a normal Python traceback in the test output instead of Flask's own error page. The create_app() factory pattern (covered in this track's introduction and interview questions) is what makes this fixture possible at all — each test gets a fresh, independently configured app instance instead of importing one shared global app object.
A complete test for a route, including the database
Testing a route backed by Flask-SQLAlchemy typically points the test app at a throwaway in-memory SQLite database instead of the real one, created fresh for each test:
# app.py
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(120), nullable=False)
done = db.Column(db.Boolean, default=False)
def create_app(config=None):
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.db"
if config:
app.config.update(config)
db.init_app(app)
@app.route("/tasks", methods=["POST"])
def create_task():
task = Task(title=request.json["title"])
db.session.add(task)
db.session.commit()
return jsonify({"id": task.id, "title": task.title}), 201
return app
# test_app.py
import pytest
from app import create_app, db
@pytest.fixture
def client():
app = create_app({
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
})
with app.app_context():
db.create_all()
yield app.test_client()
db.drop_all()
def test_create_task(client):
response = client.post("/tasks", json={"title": "Write more tests"})
assert response.status_code == 201
body = response.get_json()
assert body["title"] == "Write more tests"
assert "id" in body
Overriding SQLALCHEMY_DATABASE_URI to sqlite:///:memory: inside the fixture keeps every test run fully isolated from the real development database — db.create_all() builds a brand-new schema before the test runs, and db.drop_all() tears it down after, so nothing persists between test functions.
Common mistakes
- Running tests against the real development or production database instead of a dedicated (often in-memory) test database — a bug in a test can silently corrupt real data, and tests become slower and less repeatable.
- Forgetting
TESTING=True— some Flask/extension behavior (like error handling) differs subtly between normal and testing mode, occasionally masking the real cause of a failing test. - Sharing one global
appobject (created at import time) across all tests instead of using an app factory — makes it much harder to give each test (or the whole suite) an independent, differently configured instance.
Interview questions
Q: Why does testing a Flask route typically involve test_client() rather than calling the view function directly?
test_client() sends a simulated but real HTTP request through Flask's actual routing, request/response cycle, and any registered middleware or before/after-request hooks — the same code path a real deployed request goes through. Calling a view function directly skips all of that, testing only the function's own body in isolation, which misses bugs in routing, decorators like @login_required, or request parsing.
Q: Why is the application factory pattern (create_app()) particularly useful for testing?
It lets each test (or the whole test session) construct a fresh, independently configured Flask app instance — pointed at a test database, with TESTING=True, isolated from whatever configuration a real running instance uses. A single module-level app = Flask(__name__) object created at import time can't be reconfigured per test the same way, since every test importing that module would share the exact same instance and configuration.