Skip to main content

Command Palette

Search for a command to run...

Testing Flask Applications with Pytest

Updated
9 min read
D
Content Strategist/Software developer

Flask is a Python web framework that is versatile, simple, and lightweight. Applications developed on Flask can be fragile and problematic if you fail to write effective tests. Flask has a built-in unittest  module that works well but pytest comes with additional features such as powerful fixtures, plugins, and better syntax. The process of testing Flask applications is easier and faster with these additional features.

In this tutorial, we discuss the process of testing Flask applications with pytest, including test routes, JSON APIs, authentication, database interactions, mocking, and background tasks with examples.

Prerequisites

You need to have the following installed and ready before you begin the process of testing Flask applications with pytest:

  • Python 3.8 or higher (pytest 7+ requires Python 3.7+, but 3.8+ recommended)

  • Creating a basic Flask application (routes, request/response, @app.route)

  • Using Flask’s request and jsonify objects

  • Basic understanding of HTTP methods (GET, POST, PUT, DELETE)

  • Writing simple Python functions and using assert statements

  • (Optional but helpful) Familiarity with SQLAlchemy or Celery

Required Libraries & Installation

After creating a virtual environment, install the necessary packages and libraries:

 -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Flask and testing tools
pip install Flask==2.3.3
pip install pytest==8.0.0
pip install pytest-cov==4.1.0          # Code coverage
pip install pytest-flask==1.2.0        # Flask-specific pytest fixtures
pip install requests-mock==1.11.0      # Mock external HTTP calls

# Optional – for database and task testing
pip install Flask-SQLAlchemy==3.1.1
pip install Flask-Migrate==4.0.5
pip install Celery==5.3.6
pip install redis==5.0.1

Setup Process

  1. A Flask project that is well setup with one or more routes. Example structure:
your_flask_app/
├── app.py
├── requirements.txt
└── tests/
    ├── __init__.py
    ├── conftest.py
    └── test_routes.py
  1. pytest installed and runnable from the command line (pytest --version).

  2. For database tests, SQLite in-memory is recommended (no separate installation needed).

  3. For Celery tests, Redis running locally or use celery[redis] with a test broker.

Setting Up Pytest for Flask

Basic Configuration

Create a tests/conftest.py file – this is where pytest looks for shared fixtures.

# tests/conftest.py
import pytest
from my_flask_app import create_app  # your app factory

@pytest.fixture
def app():
    """Create and configure a Flask app instance for testing."""
    app = create_app({
        'TESTING': True,
        'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',  # in-memory DB
        'WTF_CSRF_ENABLED': False,  # disable CSRF for forms
    })
    yield app

@pytest.fixture
def client(app):
    """A test client for the app."""
    return app.test_client()

@pytest.fixture
def runner(app):
    """A CLI runner for Click commands."""
    return app.test_cli_runner()

Minimal Flask App Example

Assume your app.py looks like this:

# app.py
from flask import Flask, jsonify

def create_app(test_config=None):
    app = Flask(__name__)
    app.config['SECRET_KEY'] = 'dev'

    if test_config:
        app.config.update(test_config)

    @app.route('/ping', methods=['GET'])
    def ping():
        return jsonify({'status': 'ok'})

    return app

Running Your First Test

# tests/test_routes.py
def test_ping(client):
    response = client.get('/ping')
    assert response.status_code == 200
    assert response.json == {'status': 'ok'}

Run with:

pytest -v

Expected output:

tests/test_routes.py::test_ping PASSED 

Writing Your First Test – A Simple Route

This is a route test that fetches a list of users.

# app.py (within create_app)
@app.route('/users', methods=['GET'])
def get_users():
    users = [
        {'id': 1, 'name': 'Alice'},
        {'id': 2, 'name': 'Bob'},
    ]
    return jsonify(users)

Test it:

# tests/test_routes.py
def test_get_users(client):
    response = client.get('/users')
    assert response.status_code == 200
    data = response.get_json()
    assert len(data) == 2
    assert data[0]['name'] == 'Alice'

What if the route needs query parameters?

@app.route('/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
    # simplified – normally from DB
    user = {'id': user_id, 'name': f'User{user_id}'}
    return jsonify(user)

Test it:

def test_get_single_user(client):
    response = client.get('/users/42')
    assert response.status_code == 200
    assert response.json['id'] == 42
    assert response.json['name'] == 'User42'

A common problem developers face at this stage is that forgetting to parse response.get_json() – Flask’s client.get() returns a Response object, not the JSON directly.

Testing POST Requests and JSON APIs

Since real applications need to accept data on the regular, let’s test a user registration endpoint.

# app.py
from flask import request

@app.route('/register', methods=['POST'])
def register():
    data = request.get_json()
    if not data or 'email' not in data or 'password' not in data:
        return jsonify({'error': 'Missing email or password'}), 400
    # Simulate user creation
    return jsonify({'message': f'User {data["email"]} created'}), 201

Testing POST with JSON

def test_register_success(client):
    payload = {'email': 'test@example.com', 'password': 'secret123'}
    response = client.post('/register', json=payload)  # note: json=, not data=
    assert response.status_code == 201
    assert response.json['message'] == 'User test@example.com created'

def test_register_missing_fields(client):
    payload = {'email': 'test@example.com'}  # missing password
    response = client.post('/register', json=payload)
    assert response.status_code == 400
    assert 'error' in response.json

Flask developers prefer using json= over data= because it automatically sets Content-Type: application/json and serializes dict to JSON. Also data= sends form-encoded data which is not what most APIs expect.

Using Fixtures for Database and App Context

Since most Flask applications utilize a database, pytest fixtures allows you to setup a clean and well-structured database for each test.

Example using Flask-SQLAlchemy

# tests/conftest.py (extended)
from my
_flask_app import db

@pytest.fixture
def app():
    app = create_app({'TESTING': True, 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:'})
    with app.app_context():
        db.create_all()  # create tables
        yield app
        db.drop_all()    # clean up

@pytest.fixture
def db_session(app):
    """Return a database session that rolls back after each test."""
    connection = db.engine.connect()
    transaction = connection.begin()
    session = db.create_scoped_session(options={'bind': connection})
    db.session = session
    yield session
    transaction.rollback()
    connection.close()
    session.remove()

Test that uses database

# models.py
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True)

# test
def test_create_user(db_session):
    user = User(email='alice@example.com')
    db_session.add(user)
    db_session.commit()
    assert user.id is not None
    assert User.query.count() == 1

The main benefit here is that every test runs in isolation which reduces the chances of leftover data interfering.

Testing Authentication and Protected Routes

In a situation where you have a login endpoint which is expected to return a JWT token and protected routes require it:

# app.py
import jwt
from functools import wraps

def token_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        token = request.headers.get('Authorization')
        if not token:
            return jsonify({'error': 'Token missing'}), 401
        try:
            jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
        except:
            return jsonify({'error': 'Invalid token'}), 401
        return f(*args, **kwargs)
    return decorated

@app.route('/protected', methods=['GET'])
@token_required
def protected():
    return jsonify({'message': 'You are authorized'})

Testing with mock token:

def test_protected_without_token(client):
    response = client.get('/protected')
    assert response.status_code == 401

def test_protected_with_valid_token(client, app):
    # Create a valid token
    token = jwt.encode({'user_id': 1}, app.config['SECRET_KEY'], algorithm='HS256')
    headers = {'Authorization': token}
    response = client.get('/protected', headers=headers)
    assert response.status_code == 200
    assert response.json['message'] == 'You are authorized'

To generate token for many tests, use a fixture.

@pytest.fixture
def auth_token(app):
    def _make_token(user_id=1):
        return jwt.encode({'user_id': user_id}, app.config['SECRET_KEY'], algorithm='HS256')
    return _make_token

Mocking External Services (Requests, APIs)

When a Flask application needs to call external APIs, you should conduct mock tests for them to avoid real network calls.

Using requests-mock:

# app.py
import requests

@app.route('/weather/<city>')
def weather(city):
    api_key = 'fake_key'
    url = f'https://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}'
    resp = requests.get(url)
    if resp.status_code != 200:
        return jsonify({'error': 'City not found'}), 404
    return jsonify(resp.json())

Test with mocking:

import requests_mock

def test_weather_success(client):
    with requests_mock.Mocker() as m:
        fake_response = {'main': {'temp': 20}, 'name': 'London'}
        m.get('https://api.openweathermap.org/data/2.5/weather?q=London&appid=fake_key',
               json=fake_response, status_code=200)
        response = client.get('/weather/London')
        assert response.status_code == 200
        assert response.json['name'] == 'London'

def test_weather_not_found(client):
    with requests_mock.Mocker() as m:
        m.get('https://api.openweathermap.org/data/2.5/weather?q=UnknownCity&appid=fake_key',
               status_code=404)
        response = client.get('/weather/UnknownCity')
        assert response.status_code == 404
        assert response.json['error'] == 'City not found'

Mocking is recommended because tests are fast, it can be done offline, and it is deterministic.

Testing Error Handlers and Edge Cases

Flask allows custom error handlers. Test them.

@app.errorhandler(404)
def not_found(error):
    return jsonify({'error': 'Resource not found'}), 404

@app.errorhandler(500)
def internal_error(error):
    return jsonify({'error': 'Server error'}), 500

Test 404:

def test_404_handler(client):
    response = client.get('/non-existent-route')
    assert response.status_code == 404
    assert response.json['error'] == 'Resource not found'

Test 500 (force an exception inside a route):

@app.route('/crash')
def crash():
    raise ValueError('Something broke')
python
def test_500_handler(client):
    response = client.get('/crash')
    assert response.status_code == 500
    assert response.json['error'] == 'Server error'

A properly setup API will return error formats consistently instead of HTML traces.

Parameterized Tests for Multiple Scenarios

Instead of writing five similar tests, use @pytest.mark.parametrize.

Example: Testing a math endpoint

@app.route('/add/<int:a>/<int:b>')
def add(a, b):
    return jsonify({'result': a + b})
Parameterized test:
python
import pytest

@pytest.mark.parametrize('a,b,expected', [
    (1, 1, 2),
    (0, 0, 0),
    (-1, 5, 4),
    (100, -50, 50),
])
def test_add_endpoint(client, a, b, expected):
    response = client.get(f'/add/{a}/{b}')
    assert response.status_code == 200
    assert response.json['result'] == expected

This runs four separate test cases, all reported individually.

Testing Background Tasks (Celery with pytest)

If your Flask app uses Celery for async tasks, test them without running a real worker.

Approach 1: Mock the task```

# app.py
from tasks import send_email_task

@app.route('/send-notification', methods=['POST'])
def send_notification():
    email = request.json['email']
    send_email_task.delay(email)
    return jsonify({'status': 'queued'}), 202

Test:

from unittest.mock import patch

@patch('your_app.tasks.send_email_task.delay')
def test_send_notification(mock_delay, client):
    payload = {'email': 'test@example.com'}
    response = client.post('/send-notification', json=payload)
    assert response.status_code == 202
    mock_delay.assert_called_once_with('test@example.com')

Approach 2: Run Celery in "eager" mode (synchronous)

Set Celery config for tests:

# conftest.py
@pytest.fixture
def app():
    app = create_app({
        'TESTING': True,
        'CELERY_TASK_ALWAYS_EAGER': True,   # runs tasks immediately
        'CELERY_TASK_EAGER_PROPAGATES': True,  # raises exceptions
    })
    yield app

Now task.delay() executes synchronously – easy to test side effects.

Code Coverage with pytest-cov

With code coverage, developers can easily determine which lines of code a test is using.

Run with coverage:

bash
pytest --cov=my_flask_app --cov-report=term-missing

Example output:

text
Name                     Stmts   Miss  Cover   Missing
------------------------------------------------------
my_flask_app/app.py        120     12    90%   45-48, 67, 89-92
my_flask_app/models.py      45      5    89%   23-27
------------------------------------------------------
TOTAL                      165     17    90%

Fail if coverage is too low:

pytest --cov=my_flask_app --cov-fail-under=85

Add to tox.ini or .coveragerc for CI/CD pipelines.

Conclusion

As a Flask developer, writing tests with pytest is essential for caching bugs and having complete confidence in your application. A reliable test suite should be able to notify you immediately something breaks whenever you are upgrading dependencies, adding features, or refactoring. The first step when testing Flask applications is to begin with the most important routes such as login, checkout, and data export. After these are handled, you can move to writing tests for edge cases and error handlers.