9 min read

Testing Flask SQLAlchemy database with pytest

Introduction

I have been studying the amazing TestDriven.io tutorial to learn full stack development with Python and React. Early in the tutorial, the author explains how to set up your app to use unittest and the Flask-Testing extension for its test framework. Since I wanted to use pytest, this was a good opportunity to explore the test setup a bit more in depth.

Running pytest from a script

Firstly, add pytest to your requirements.txt and place a dummy test in the project/tests.

def test_dummy():
    assert True

Secondly, add a manager.command to manage.py that run the pytest tests.

# manage.py
import pytest
@manager.command
def test():
    """Runs the tests."""
    pytest.main(["-s", "project/tests"])

Some notes:

  • To find info on calling pytest directly from python code, go here
  • I first tried to just run pytest.main() and ended up with some “File not found” error. You need to provide an empty list [] at the very least. The error is discussed more in depth here.
  • Running pytest.main([]) with an empty list will look for test everywhere in your project. If you have placed the env dir in /project, with all the sources of your virtual environment packages, you might find tons of tests that you did not want to touch. Therefore, it’s better to target specifically the project/tests dir with the last argument project/tests.
  • Adding -s to the pytest command lets pytest print to the console any print statements that you use in your tests, not just the ones from failing tests.

We can now run the test on the running containers with:

$ docker-compose run users-service python manage.py test 

== test session starts ==
platform linux -- Python 3.6.1, pytest-3.1.2, py-1.4.34, pluggy-0.4.0
rootdir: /usr/src/app, inifile:
collected 1 items 

project/tests/test_ping.py .

== 1 passed in 0.01 seconds ==

Creating an app fixture

In the tutorial, the author creates the class BaseTestCase in project/tests/base.py, which imports the app from project.

# project/tests/base.py 
from project import app, db
class BaseTestCase(TestCase):
    def create_app(self):
        app.config.from_object('project.config.TestingConfig')
        return app

This app instance can then be used in our different tests, without needing to reimport the app for each test. At the beginning of its project/tests/test_users.py, you can see that the class TestUserService inherits from BaseTestCase.

# project/tests/test_users.py 
from project.tests.base import BaseTestCase
class TestUserService(BaseTestCase):
    # some code...
    pass

Reusable objects for tests are called fixtures. In unittest, fixtures are defined as classes with two special functions setUp and tearDown, that are executed before/after each test. We will cover their pytest implementations later.

Rather than importing the app, we will first move the code to the Application Factories pattern (also used in the tutorial soon after). Rewrite your project/__init__.py as below:

# project/__init__.py
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class User(db.Model):
    __tablename__ = "users"
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    username = db.Column(db.String(128), nullable=False)
    email = db.Column(db.String(128), nullable=False)
    active = db.Column(db.Boolean(), default=False, nullable=False)
    created_at = db.Column(db.DateTime, nullable=False)
    def __init__(self, username, email):
        self.username = username
        self.email = email
        self.created_at = datetime.datetime.now()
def create_app():
    app = Flask(__name__)
    app_settings = os.getenv('APP_SETTINGS')
    app.config.from_object(app_settings)
    db.init_app(app)
    @app.route('/ping', methods=['GET'])
    def ping_pong():
        return jsonify({
            'status': 'Epic success',
            'message': 'pong!'
        })
    return app

We now have a create_app() function, that can return an app instance. In pytest, fixture are defined as function, with the @pytest.fixture decorator.

# project/tests/conftest.py
import pytest
from project import create_app
@pytest.fixture
def app():
    app = create_app()
    app.config.from_object('project.config.TestingConfig')
    return app

Tests in pytest are also just function, named with the prefix test_. To use a fixture in a test, just add it as an argument. Note that assertions are simpler than in unittest, you only need the assert function.

Test the different configurations:

# project/tests/test_config.py
import os
def test_development_config(app):
    app.config.from_object('project.config.DevelopmentConfig')
    assert app.config['DEBUG']
    assert not app.config['TESTING']
    assert app.config['SQLALCHEMY_DATABASE_URI'] == os.environ.get(
        'DATABASE_URL')
def test_testing_config(app):
    app.config.from_object('project.config.TestingConfig')
    assert app.config['DEBUG']
    assert app.config['TESTING']
    assert not app.config['PRESERVE_CONTEXT_ON_EXCEPTION']
    assert app.config['SQLALCHEMY_DATABASE_URI'] == os.environ.get(
        'DATABASE_TEST_URL')
def test_production_config(app):
    app.config.from_object('project.config.ProductionConfig')
    assert not app.config['DEBUG']
    assert not app.config['TESTING']
    assert app.config['SQLALCHEMY_DATABASE_URI'] == os.environ.get(
        'DATABASE_URL')

Testing the ping route (using test_client() to get a client that can test routes):

# project/tests/test_ping.py
import json
def test_ping(app):
    client = app.test_client()
    resp = client.get('/ping')
    data = json.loads(resp.data.decode())
    assert resp.status_code == 200
    assert 'pong' in data['message']
    assert 'success' in data['status']

Accessing the database from the tests

Making tables accessible with create_all()

So far we haven’t put any code related to the database in our app fixture. The tutorial has done so in BaseTestCase. If we rewrite the test_add_user for pytest and try to run it, we get an error.

# project/tests/test_users.py
def test_add_user(app):
    """Ensure a new user can be added to the database."""
    with app.test_client() as client:
        response = client.post(
            '/users',
            data=json.dumps(dict(
                username='michael',
                email='michael@realpython.com'
            )),
            content_type='application/json',
        )
        data = json.loads(response.data.decode())
        assert response.status_code == 201
        assert 'michael@realpython.com was added!' in data['message']
        assert 'success' in data['status']
$ docker-compose run users-service python manage.py test_app
[...]
======= FAILURES ======
____ test_add_user ____
[...]
sqlalchemy.exc.ProgrammingError: (psycopg2.ProgrammingError) relation "users" does not exist

So our app fixture knows about sql alchemy but hasn’t created the tables needed for our user model. From the Flask-SQLAlchemy documentation:

What it (init_db) does is prepare the application to work with SQLAlchemy. However that does not now bind the SQLAlchemy object to your application.

Let’s jump in an python shell and try to see how we can bind our db object to our app.

$ docker exec -ti users-service bash
root@910656bc5f75:/usr/src/app# python3
>>> from project import create_app, db
>>> app = create_app()
>>> app
<Flask 'project'>
>>> db
<SQLAlchemy engine=None>

So create_app() gives us an app object, but the imported db isn’t connected to it out-of-the-box (it has no engine). If we jump into the app context, the engine gets populated.

>>> app_ctx = app.app_context()
>>> app_ctx.push()  # jump into the app context
>>> db
<SQLAlchemy engine='postgres://postgres:postgres@users-db:5432/users_dev'>

However, db still doesn’t have tables and running tests at this stage would keep saying that relation users does not exist. That’s why we need create_table().

>>> db.engine.table_names()  # Check the tables currently on the engine
[]                           # no table found
>>> db.create_all()          # Create the tables according to defined models
>>> db.engine.table_names()
['users']                    # Now table 'users' is found

We can now update our app fixture:

# project/tests/conftest.py
import pytest
from project import create_app, db
@pytest.fixture
def app():
    app = create_app()
    app.config.from_object('project.config.TestingConfig')
    with app.app_context():   
        # alternative pattern to app.app_context().push()
        # all commands indented under 'with' are run in the app context 
        db.create_all()
        return app

Cleaning database with drop_all()

Let’s check the impact of our tests on the database. Before running tests:

$ docker exec -ti $(docker ps -aqf "name=users-db") psql -U postgres

psql (9.6.3)
postgres=# \c users_dev
You are now connected to database "users_dev" as user "postgres".
users_dev=# \dt
No relations found.
users_dev=# \c users_test
You are now connected to database "users_test" as user "postgres".
users_test=# \dt
No relations found.

After running tests:

# Running pytest
$ docker-compose run users-service python manage.py test_app
[... Output of pytest ...]
========= 5 passed in 0.17 seconds =========

# Checking the database
$ docker exec -ti $(docker ps -aqf "name=users-db") psql -U postgres

psql (9.6.3)
postgres=# \c users_dev
You are now connected to database "users_dev" as user "postgres".
users_dev=# \dt
No relations found.
users_dev=# \c users_test
You are now connected to database "users_test" as user "postgres".
users_test=# \dt
users_test=# \dt
         List of relations
 Schema | Name  | Type  |  Owner   
--------+-------+-------+----------
 public | users | table | postgres
(1 row)

users_test=# SELECT * FROM users;
 id | username |         email          | active |         created_at         
----+----------+------------------------+--------+----------------------------
  1 | michael  | michael@realpython.com | f      | 2017-07-04 09:23:43.34457

We can see that the users table was successfully created in the users_test database, which we selected in conftest.py. A user was also successfully inserted. However, if we run the tests again and recheck the table, we can see how trouble is starting to creep in:

# Running test 
[...]

# Checking the database
[...]
users_test=# SELECT * FROM users;
 id | username |         email          | active |         created_at         
----+----------+------------------------+--------+----------------------------
  1 | michael  | michael@realpython.com | f      | 2017-07-04 09:23:43.34457
  2 | michael  | michael@realpython.com | f      | 2017-07-04 09:24:55.972571
(2 rows)

We are not cleaning up the database after our tests. To do so, we need to add drop_all() as a tear-down action for our app fixture (after yield). This will not only empty the table rows, but also delete the table itself:

# project/tests/conftest.py
import pytest
from project import create_app, db
@pytest.fixture
def app():
    app = create_app()
    app.config.from_object('project.config.TestingConfig')
    with app.app_context():   
        db.create_all()
        yield app   # Note that we changed return for yield, see below for why
        db.drop_all()

For the first time, we added a command (db.drop_all()) that needed to be executed after the test using the fixture. Previously we used return to get the app out of the fixture. But using return means ending the function. That’s where yield comes to the rescue. Unlike unittest, pytest does not put setup and teardown code in dedicated function. Everything that comes before return/yield is setup code, everything that comes after yield is teardown code. Documentation is here:

By using a yield statement instead of return, all the code after the yield statement serves as the teardown code.

If you try to run tests again, you will see that the database is left clean.

Avoid locking postgres with db.session.remove()

This is the part I still have trouble understanding. Using the fixture above, pytest started hanging indefinitely at random test (usually at tests that touched the database several times, but not always). When it happened, I could not even stop pytest and had to restart the container.

$ docker-compose run users-service python manage.py test_app
===== test session starts =====
[...]
project/tests/test_configs.py ...
project/tests/test_users.py ..

hanging... hanging... hanging...

From this SO question, I got the confirmation that postgres might be locked. Using the commands listed on devopsderek.com, it is possible to look at the sessions active while pytest is locked. Below we can see that one is blocked with the state “idle in transaction”.

# Checking the database while pytest is hanging
$ docker exec -ti $(docker ps -aqf "name=users-db") psql -U postgres
postgres=# SELECT * FROM pg_stat_activity;

datid |  datname   | pid | ... | state               | ... | query
------+------------+-----+-----+---------------------+-----+-
[... lots of sessions ...]
16386 | users_test | 200 | ... | idle in transaction | ... | SELECT users.id...

Killing the session manually in postgres did also kill pytest without needing to restart the container.

# Still in psql
postgres=# SELECT pg_terminate_backend(200);
 pg_terminate_backend 
----------------------
 t
(1 row)
# In the terminal running pytest
psycopg2.OperationalError: terminating connection due to administrator command
server closed the connection unexpectedly
    This probably means the server terminated abnormally
    before or while processing the request.
$ # free to type again

Still following SO advice, you can explicitly close the connection after each test, which solves the issue.

# project/tests/conftest.py
import pytest
from project import create_app, db
@pytest.fixture
def app():
    app = create_app()
    app.config.from_object('project.config.TestingConfig')
    with app.app_context():   
        db.create_all()
        yield app  
        db.session.remove()  # looks like db.session.close() would work as well
        db.drop_all()

We end up very close to the testdriven.io example for unittest.

# project/tests/base.py on http://testdriven.io/part-one-test-setup/
from flask_testing import TestCase
from project import app, db
class BaseTestCase(TestCase):
    def create_app(self):  # done in our pytest fixture before yield
        app.config.from_object('project.config.TestingConfig')
        return app
        
    def setUp(self):  # done in our pytest fixture before yield
        db.create_all()
        db.session.commit()
    def tearDown(self): # done in our pytest fixture after yield
        db.session.remove()
        db.drop_all()

I just haven’t found the reason why db.session.commit() would be necessary (although I suspect it might be an alternative to explicitely declaring the app context like we did in with). I will update the post when I understand it better.