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.

Python
# 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
Python
# 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
Bash
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:

Python
# 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
Python
# 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 app object (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.