Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Problems trying to mock a Model within Flask-SQLAlchemy

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?

like image 515
Rafael S. Calsaverini Avatar asked Jan 28 '15 12:01

Rafael S. Calsaverini


1 Answers

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.

like image 108
Peter K Avatar answered Sep 20 '22 13:09

Peter K