Testing Flask Applications with Pytest
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
- 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
pytest installed and runnable from the command line (pytest --version).
For database tests, SQLite in-memory is recommended (no separate installation needed).
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.
