How to use Pytest with Flask and its common extensions

If you plan to use Flask to build a JSON API (for instance a REST API), there are high chances you will end up using the following add-ons :

  • SQLAlchemy to work with SQL databases (with Flask-SQLAlchemy)
  • Flask-Migrate to work with database migrations
  • Marshmallow to validate incoming data, and serialize the outcomes of the request

This article will give you some insights into how to efficiently test a Flask application alongside these extensions. In particular, we will create a set of pytest fixtures to cover most of the possible test cases.

Prerequisites

I assume that you already have some knowledge of pytest and particularly the notion of fixtures. A fixture defines a resource that a test can use. If you are not familiar with that, I suggest you read pytest documentation

I also assume that your Flask project follows the application factory pattern. It means you should have a set of extensions declared in one (or multiple) module(s) :

from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow
from flask_migrate import Migrate

db = SQLAlchemy()
migrate = Migrate()
marsh = Marshmallow()

Then you should define an application factory in another module :

from flask import Flask
from .extensions import db, migrate, ma

def create_app(config):
    app = Flask(__name__)
    app.config.update(**config)
    db.init_app(app)
    migrate.init_app(app)
    marsh.init_app(app)
    # import sqlalchemy models
    from .models import *    
    return app

If this project structure sounds greek to you, you should check Flask’s doc about app factory patterns.

First fixtures: setup the environment

First, we need some fixtures for our Flask app:

import pytest
from .app import create_app

TEST_CONFIG = {
    'TESTING': True,
    'DEBUG': True
}

@pytest.fixture(scope="session")
def app():    
    app = create_app(config=TEST_CONFIG)    
    with app.app_context():
        yield app

@pytest.fixture(scope="function")
def test_client(app):
    return app.test_client()

This way, you can easily test your routes :

def test_hello_world(test_client):
    res = test_client.post("/hello_world", data={})
    assert res.status_code == 201
    assert "hello" in res.json

Moreover, if you need some special authentication, you can easily add other client fixtures by modifying headers in app.test_client, e.g a user_client fixture that will automatically log you as a user.

Setting up database fixtures

For the next section, you need to have a database for your tests. This database can be any local database that is safe to use for testing and is therefore assumed to be empty.

First, let’s change our test config to include our database

TEST_CONFIG = {
    'TESTING': True,
    'DEBUG': True,
    'SQLALCHEMY_DATABASE_URI': 'my_test_database_uri'
}

Then let’s add a db fixture:

from .extensions import db as _db
from flask_migrate import upgrade as flask_migrate_upgrade

@pytest.fixture(scope="session")
def db(app, request):
    """Session-wide test database."""

    def teardown():
        _db.drop_all()
    _db.app = app

    flask_migrate_upgrade(directory="migrations")
    request.addfinalizer(teardown)
    return _db

This fixture has a session-wide scope, which means it will be computed only once for all the tests. What it does is it binds our ‘db’ object to our app fixture, then executes all the migrations with flask_migrate_upgrade(). This line could have been replaced by a simple _db.create_all(), but this call to Flask-Migrate allows testing migrations on the fly.

The next step is to create a session fixture. This fixture will be used in each of our tests are ensure they are isolated from each other.

@pytest.fixture(scope="function")
def session(db, request):
    db.session.begin_nested()
    def commit():
        db.session.flush()    
    # patch commit method
    old_commit = db.session.commit
    db.session.commit = commit
    def teardown():
        db.session.rollback()
        db.session.close()
        db.session.commit = old_commit
    request.addfinalizer(teardown)
    return db.session

Each time this fixture is computed (i.e during each test), the following things happen :

  • Before the test, the database is empty
  • The call to begin_nested() creates a nested transaction that expires only when a session.rollback() or a session.commit() is called. However, we do not want to allow commits to be triggered, since it would actually push changes in the database and therefore impact other tests. In order to prevent this, we simply patch the session.commit method with session.flush, which is transaction-safe. This way we can call db.session.commit in our project without worrying about tests.
  • When the test ends, a rollback is issued, clearing the database for further tests.

Testing models and serializers with FactoryBoy

Now let’s be more practical with an example. Say we have a model we want to test:

from .extensions import db

class MyModel(db.Model):
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    name = db.Column(db.String(100), nullable=False)
    description = db.Column(db.Text, nullable=True)
    is_active = db.Boolean(db.Boolean, nullable=False, default=True)
    
    def save(self):
        db.session.add(self)
    
    def __str__(self):
        return self.name
        
 ### Usage: 
 # instance = MyModel(name="...", description="...", is_active=True)
 # db.session.add(instance)
 # db.session.commit()

And a corresponding serializer created with Marshmallow-SQLAlchemy:

from .extensions import marsh as ma

class MyModelSerializer(ma.ModelSchema):
    class Meta:
        model = MyModel
        
### Deserialization 
# db_instance = MyModelSerializer().load(dictionnary_data)
# <MyModel "foo">
### Serialization
# dictionnary_data = MyModelSerializer().dump(db_instance)
# {"name": "foo", "description": "bar", "is_active": True}

With the previous configuration, you could test your model and serializer this way :

from .models import MyModel
from .serializers import MyModelSerializer

def test_mymodel_str_method(session):
    instance = MyModel(name="Test", description="Ipso Lorim", is_active=True)
    assert str(instance) == instance.name

def test_mymodel_save_method(session):
    instance = MyModel(name="Test", description="Lorem Ipsum...", is_active=True)
    instance.save()
    session.flush()
    assert MyModel.query.filter_by(name="Test").first() == instance
    
def test_mymodel_serializer():
    # we do not need the session fixture since we don't need to save anything to the db
    form_data = {
        "name": "Test3",
        "description": "Ipsum Lorim",
        "is_active": False
    }
    serializer = MyModelSerializer().load(form_data)
    assert serializer.errors = {}
    assert serializer.data.name == form_data["name"]
    assert serializer.data.description == form_data["description"]

However, this method is not flawless. First, it would be nice to test our serializer with our model, so that if we need to add another attribute to the model, we don’t need to add it in ‘form_data’ too. Moreover, we always have to specify some dummy data when instantiating a model, such as a dummy ‘name’ attribute, whereas we just need to launch our tests with ‘some dummy instance of MyModel’, whatever its attributes are.

Better testing serializers with a Dictionnary Mixin

The first problem can be easily resolved with an ‘as_dict’ method to add to our models :

class DictMixin:
    def as_dict(self, exclude=None):
        if exclude is None:
            exclude = []
        table = self.__table__
        return {
            field: getattr(self, field)
            for field in table.columns.keys()
            if field not in exclude
        }

Let’s change our model to have it inherit from this class :

from .mixins import DictMixin
class MyModel(db.Model, DictMixin);
    # ...

Then we can rewrite our last test :

def test_mymodel_serializer():
    form_data = MyModel(name="Test", description="Test desc").as_dict()
    serializer = MyModelSerializer().load(form_data)
    assert serializer.errors = {}
    assert serializer.data.name == form_data["name"]
    assert serializer.data.description == form_data["description"]

Lightening our tests with FactoryBoy

Let’s summarize our second problem. Instead of writing :

instance = MyModel(name="Test", description="Lorem Ipsum...")

We would like something like :

instance = SomeInstanceOfMyModelWithDummyParams() 

With different attributes each time we call it. We could also want a batch of different instances to make some scenario tests, etc.

Fortunately, we have a solution for that, it’s called FactoryBoy tada let’s discover it with our example.

A Factory for MyModel would look like this:

import factory.fuzzy

class MyModelFactory(factory.alchemy.SQLAlchemyModelFactory):
    class Meta:
        model = MyModel
        sqlalchemy_session = db.session
        
    is_active = True # we want this attribute to be True by default
    name = factory.faker.Faker("name") # each call will generate a fake name
    description = factory.fuzzy.FuzzyText() # same with another method

And we can use it in our tests

from .factories import MyModelFactory

def test_mymodel_str_method(session):
    # this instance will have new attributes each time this test is executed
    instance = MyModelFactory()
    session.flush()
    assert str(instance) == instance.name

def test_mymodel_save_method(session):
    # calling build() will create the python object without saving it to the database
    instance = MyModelFactory.build()
    instance.save()
    session.flush()
    assert MyModel.query.filter_by(instance.name).first() == instance

def test_mymodel_serializer():
    form_data = MyModelFactory.build().as_dict()
    serializer = MyModelSerializer().load(form_data)
    assert serializer.errors = {}
    assert serializer.data.name == form_data["name"]
    assert serializer.data.description == form_data["description"]

And that’s it!

Leave a Comment

Your email address will not be published. Required fields are marked *