9

I'm testing a Flask application that have some SQLAlchemy models using Flask-SQLAlchemy and I'm having some problems trying to mock a few models to some methods that receive some models as parameters.

A toy version of what I'm trying to do is like this. Suppose I have a model given by:

// file: database.py
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()  

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True)
    birthday = db.Column(db.Date)

That is imported in an app that is built with the app factory pattern:

// file: app.py
from flask import Flask
from database import db

def create_app():
    app = Flask(__name__)
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db'
    db.init_app(app)

And some function that needs a User as parameter:

// file: actions.py
import datetime

SECONDS_IN_A_YEAR = 31556926

def get_user_age(user):
    return (datetime.date.today() - user.birthday).total_seconds() //  SECONDS_IN_A_YEAR

Moreover there should be a couple of views and blueprints that are imported in app.py and registered in the app that latter call the function get_user_age somewhere.

My problem is: I want to test the function get_user_age without having to create an app, registering with a fake database, etc, etc. That shouldn't be necessary, the function is totally independent from the fact that it is used in a Flask app.

So I tried:

import unittest

import datetime
import mock

from database import User
from actions import get_user_age

class TestModels(unittest.TestCase):
    def test_get_user_age(self):
        user = mock.create_autospec(User, instance=True)
        user.birthday = datetime.date(year=1987, month=12, day=1)
        print get_user_age(user)

That raises me a RuntimeError: application not registered on db instance and no application bound to current context exception. So I thought "yeah, obviously I must patch some object to prevent it from checking if the app is registered with the database and etc". So I tried decorating it with @mock.patch("database.SQLAlchemy") and other things to no avail.

Do anyone know what should I patch to prevent this behavior, or even if my test strategy is all wrong?

Rafael S. Calsaverini
  • 13,582
  • 19
  • 75
  • 132

2 Answers2

11

So, I found a solution after banging my head on the keyboard for a few hours. The problem seems to be the following (if anyone knows better, please correct me).

When I run mock.create_autospec(User), the mock module tries to inspect all attributes of User to create the adequate spec for the Mock object it will spit out. When this happens, it tries to inspect the attribute User.query, which can only be evaluated when you are inside the scope of a Flask app.

This happens because when User.query is evaluated, an object is created that needs a valid session. This session is created by the create_scope_session method on the SQLAlchemy class inside Flask-SQLAlchemy.

This method instantiates a class called SignallingSession whose __init__ method calls the SQLAlchemy.get_app method. This is the method that raises the RuntimeError when there's no app registered in the db.

By patching the SignallingSession method everything works nicely. Since I don't want to interact with the database this is ok:

import unittest
import datetime

import mock

from actions import age


@mock.patch("flask_sqlalchemy.SignallingSession", autospec=True)
class TestModels(unittest.TestCase):

    def test_age(self, session):
        import database

        user = mock.create_autospec(database.User)
        user.birthday = datetime.date(year=1987, month=12, day=1)
        print age(user)
Rafael S. Calsaverini
  • 13,582
  • 19
  • 75
  • 132
4

I found another way around this problem. The basic idea is to control the access to static attributes. I used pytest and mocker, but the code could be adapted to use unittest.

Let's look at a working code example and than explain it:

import pytest

import datetime

import database

from actions import get_user_age


@pytest.fixture
def mock_user_class(mocker):
    class MockedUserMeta(type):
        static_instance = mocker.MagicMock(spec=database.User)

        def __getattr__(cls, key):
            return MockedUserMeta.static_instance.__getattr__(key)

    class MockedUser(metaclass=MockedUserMeta):
        original_cls = database.User
        instances = []

        def __new__(cls, *args, **kwargs):
            MockedUser.instances.append(
                mocker.MagicMock(spec=MockedUser.original_cls))
            MockedUser.instances[-1].__class__ = MockedUser
            return MockedUser.instances[-1]

    mocker.patch('database.User', new=MockedUser)


class TestModels:
    def test_test_get_user_age(self, mock_user_class):
        user = database.User()
        user.birthday = datetime.date(year=1987, month=12, day=1)
        print(get_user_age(user))

The test is pretty clear and to the point. The fixture does all the heavy lifting:

  • MockedUser would replace the original User class - it would create a new mock object with the right spec every time it's needed
  • The purpose of MockedUserMeta has to be explained a bit further: SQLAlchemy has a nasty syntax which involves static functions. Imagine your tested code has a line similar to this from_db = User.query.filter(User.id == 20).one(), you should have a way to mock the response: MockedUserMeta.static_instance.query.filter.return_value.one.return_value.username = 'mocked_username'

This is the best method that I found which allows to have tests without any db access and without any flask app, while allowing to mock SQLAlchemy query results.

Since I don't like writing this boilerplate over and over, I have created a helper library to do it for me. Here is the code I wrote to generate the needed stuff for your example:

from mock_autogen.pytest_mocker import PytestMocker
print(PytestMocker(database).mock_classes().mock_classes_static().generate())

The output is:

class MockedUserMeta(type):
    static_instance = mocker.MagicMock(spec=database.User)

    def __getattr__(cls, key):
        return MockedUserMeta.static_instance.__getattr__(key)

class MockedUser(metaclass=MockedUserMeta):
    original_cls = database.User
    instances = []

    def __new__(cls, *args, **kwargs):
        MockedUser.instances.append(mocker.MagicMock(spec=MockedUser.original_cls))
        MockedUser.instances[-1].__class__ = MockedUser
        return MockedUser.instances[-1]

mocker.patch('database.User', new=MockedUser)

Which is exactly what I needed to place in my fixture.

Peter K
  • 1,959
  • 1
  • 16
  • 22