Run flask tests within a transaction - python

I have a Flask application setup with Flask-SQLAlchemy, and I'm running the tests with factory-boy. I'm trying to wrap the tests within transactions, so as not to fill up the db with test data (and avoid having to drop/recreate the db between tests, since it's pretty expensive).
However, since the db session is removed after each request, the objects created for the test are lost unless I commit the session before making the request.
Is there a way of sharing the session between the test context and the request context?
Here's a test example:
class TestUserViews(unittest.TestCase):
def setUp(self):
self.client = app.test_client()
def test_user_detail(self):
with app.test_request_context():
# Make sure requesting an unknown user returns a 404
response = self.client.get('/users/123/')
assert response.status_code == 404
# Create a user
user = UserFactory()
db.session.commit()
response = self.client.get('/users/{}/'.format(user.id))
assert response.status_code == 200 # This works since the write was committed
def test_user_uncommitted(self):
with app.test_request_context():
# Create a user
uncommitted_user = UserFactory()
assert uncommitted_user in db.session
response = self.client.get('/users/{}/'.format(uncommitted_user.id))
assert response.status_code == 200 # This doesn't work, the session wasn't reused
I built a dummy project on github to show a more complete example if necessary. Any idea what I'm missing here? Thanks!

Related

Flask, flask_login, pytest: How do I set flask_login's current_user?

I am trying to use pytest to unit test my Flask app. I have the following test case for an endpoint that requires information from flask_login's current_user:
def test_approval_logic():
with app.test_client() as test_client:
app_url_put = '/requests/process/2222'
with app.app_context():
user = User.query.filter_by(uid='xxxxxxx').first()
with app.test_request_context():
login_user(user)
user.authenticated = True
db.session.add(user)
data = dict(
state='EXAMPLE_STATE_NAME',
action='approve'
)
resp = test_client.put(app_url_put, data=data)
assert resp.status_code == 200
Inside the test_request_context, I am able to set current_user correctly. However, this test fails because in the requests view where the PUT is handled, there is no logged in user and 500 error results. The error message is, AttributeError: 'AnonymousUserMixin' object has no attribute 'email'. Can someone explain why current_user goes away and how I can set it correctly?
My guess is that no session cookie is passed in your PUT request.
Here is an example of how I log a user during my tests (I personally user unittest instead of pytest, so I reduced the code to the strict minimum, but let me know if you want a complete example with unittest)
from whereyourappisdefined import application
from models import User
from flask_login import login_user
# Specific route to log an user during tests
#application.route('/auto_login/<user_id>')
def auto_login(user_id):
user = User.query.filter(User.id == user_id).first()
login_user(user)
return "ok"
def yourtest():
application.config['TESTING'] = True # see my side note
test_client = application.test_client()
response = test_client.get(f"/auto_login/1")
app_url_put = '/requests/process/2222'
data = dict(
state='EXAMPLE_STATE_NAME',
action='approve'
)
r = test_client.put(app_url_put, data=data)
In the documentation we can read:
https://werkzeug.palletsprojects.com/en/2.0.x/test/#werkzeug.test.Client
The use_cookies parameter indicates whether cookies should be stored
and sent for subsequent requests. This is True by default but passing
False will disable this behavior.
So during the first request GET /auto_login/1 the application will receive a session cookie and keep it for further HTTP requests.
Side note:
Enable TESTING in your application (https://flask.palletsprojects.com/en/2.0.x/testing/)
During setup, the TESTING config flag is activated. What this does is
disable error catching during request handling so that you get better
error reports when performing test requests against the application.
Using test client to dispatch a request
The current session is not bound to test_client, so the request uses a new session.
Set the session cookie on the client, so Flask can load the same session for the request:
from flask import session
def set_session_cookie(client):
val = app.session_interface.get_signing_serializer(app).dumps(dict(session))
client.set_cookie('localhost', app.session_cookie_name, val)
Usage:
# with app.test_client() as test_client: # Change these
# with app.app_context(): #
# with app.test_request_context(): #
with app.test_request_context(), app.test_client() as test_client: # to this
login_user(user)
user.authenticated = True
db.session.add(user)
data = dict(
state='EXAMPLE_STATE_NAME',
action='approve'
)
set_session_cookie(test_client) # Add this
resp = test_client.put(app_url_put, data=data)
About the compatibility of with app.test_request_context()
i. with app.test_client()
with app.test_client() preserves the context of requests (Flask doc: Keeping the Context Around), so you would get this error when exiting an inner with app.test_request_context():
AssertionError: Popped wrong request context. (<RequestContext 'http://localhost/requests/process/2222' [PUT] of app> instead of <RequestContext 'http://localhost/' [GET] of app>)
Instead, enter app.test_request_context() before app.test_client() as shown above.
ii. with app.app_context()
with app.test_request_context() already pushes an app context, so with app.app_context() is unnecessary.
Using test request context without dispatching a request
From https://flask.palletsprojects.com/en/2.0.x/api/#flask.Flask.test_request_context:
This is mostly useful during testing, where you may want to run a function that uses request data without dispatching a full request.
Usage:
data = dict(
state='EXAMPLE_STATE_NAME',
action='approve'
)
with app.test_request_context(data=data): # Pass data here
login_user(user)
user.authenticated = True
db.session.add(user)
requests_process(2222) # Call function for '/requests/process/2222' directly
Here's how I do it on my sites:
user = User.query.filter_by(user_id='xxxxxxx').one_or_none()
if user:
user.authenticated = True
db.session.add(user)
db.session.commit()
login_user(user)
else:
# here I redirect to an unauthorized page, as the user wasn't found
I don't know the order is the issue or just the absence of db.session.commit(), but I think you need to have done both in order for your put request to work.
Note, also, that I am using a one_or_none() because there shouldn't be a possibility of multiple users with the same user_id, just a True or False depending on whether a user was found or not.

struggling to test flask-dance / flask-security / flask-sqlalchemy / pytest

My application requires login to google for later use of google apis. I have flask-dance, flask-security, flask-sqlalchemy working to the point where I can do the log in and log out in my development system.
What I've been struggling with is testing of the login using pytest. I am trying force the login with the call to flask_security.login_user, but test_login fails as if nobody is logged in. I suspect this is a problem because of context setting, but I have tried a lot of different things and haven't found the magic elixir.
Unfortunately, while I have a lot of experience in software development in general and python in particular, I don't have the pytest / flask-dance / flask-security background needed to solve this.
in settings.py
class Testing():
# default database
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
TESTING = True
WTF_CSRF_ENABLED = False
# need to set SERVER_NAME to something, else get a RuntimeError about not able to create URL adapter
SERVER_NAME = 'dev.localhost'
# need a default secret key - in production replace by config file
SECRET_KEY = "<test secret key>"
# fake credentials
GOOGLE_OAUTH_CLIENT_ID = 'fake-client-id'
GOOGLE_OAUTH_CLIENT_SECRET = 'fake-client-secret'
# need to allow logins in flask-security. see https://github.com/mattupstate/flask-security/issues/259
LOGIN_DISABLED = False
in conftest.py
import pytest
from racesupportcontracts import create_app
from racesupportcontracts.dbmodel import db
from racesupportcontracts.settings import Testing
#pytest.fixture
def app():
app = create_app(Testing)
yield app
#pytest.fixture
def dbapp(app):
db.drop_all()
db.create_all()
yield app
in test_basic.py
def login_test_user(email):
from racesupportcontracts.dbmodel import db, User
from flask_security import login_user
user = User.query.filter_by(email=email).one()
login_user(user)
db.session.commit()
def test_login(dbapp):
app = dbapp
from racesupportcontracts.dbmodel import db, init_db
from racesupportcontracts import user_datastore
from flask import url_for
# init_db should create at least superadmin, admin roles
init_db(defineowner=False)
useremail = 'testuser#example.com'
with app.test_client() as client:
create_user(useremail, 'superadmin')
login_test_user(useremail)
resp = client.get('/', follow_redirects=True)
assert resp.status_code == 200
assert url_for('admin.logout') in resp.data
When you call login_user(), that modifies the flask.session object. However, when using the test client, you can only modify flask.session inside of a session transaction. It should work if you do this:
with app.test_client() as client:
with client.session_transaction() as sess:
sess["user_id"] = 1 # if you want user 1 to be logged in for this test
resp = client.get('/', follow_redirects=True)
# make whatever assertions you want
If you install the latest version of Flask-Login from GitHub, you can also use the FlaskLoginClient class to make this more readable:
# in conftest.py
from flask_login import FlaskLoginClient
#pytest.fixture
def app():
app = create_app(Testing)
app.test_client_class = FlaskLoginClient
yield app
# in test_basic.py
def test_login(app):
user = User.query.filter_by(email='testuser#example.com').one()
with app.test_client(user=user) as client:
resp = client.get('/', follow_redirects=True)
# make whatever assertions you want
Unfortunately, the author of Flask-Login refuses to publish an update of the package to PyPI, so you can't use the version of Flask-Login that is on PyPI, you have to install from GitHub. (I have no idea why he refuses to publish an update.)

unittesting sqlalchemy updates in flask app

I have a Flask app, which performs operations on a mysql database. It uses sqlalchemy, and creates a session per request. In my testing framework, I set up a session, then insert appropriate data and ensure removal afterward. However, in my test, when I attempt to make sure that something was deleted (via a DELETE request to my Flask app), the row in my session is unaffected by the external thread. Do I need to close and reopen my session to ensure proper deletion, or can I refresh it somehow, or am I doing this all wrong somehow that I'm missing?
class Endpoint(flask_restful.Resource):
# in the webapp thread
def delete(self,id):
db.session.query(db.Table).filter(id=id).delete()
db.session.commit()
class Test(unittest.TestCase):
# in the testing thread
def setUp(self):
self.data = db.session.add(db.Table())
db.session.commit()
def tearDown(self):
self.data.delete()
db.session.commit()
def test_delete(self):
requests.delete(flask.url_for(Endpoint, id=self.data.id))
assert db.session.query(db.Table).filter(id = self.data.id).count() == 0

Testing Flask login and authentication?

I'm developing a Flask application and using Flask-security for user authentication (which in turn uses Flask-login underneath).
I have a route which requires authentication, /user. I'm trying to write a unit test which tests that, for an authenticated user, this returns the appropriate response.
In my unittest I'm creating a user and logging as that user like so:
from unittest import TestCase
from app import app, db
from models import User
from flask_security.utils import login_user
class UserTest(TestCase):
def setUp(self):
self.app = app
self.client = self.app.test_client()
self._ctx = self.app.test_request_context()
self._ctx.push()
db.create_all()
def tearDown(self):
if self._ctx is not None:
self._ctx.pop()
db.session.remove()
db.drop_all()
def test_user_authentication():
# (the test case is within a test request context)
user = User(active=True)
db.session.add(user)
db.session.commit()
login_user(user)
# current_user here is the user
print(current_user)
# current_user within this request is an anonymous user
r = test_client.get('/user')
Within the test current_user returns the correct user. However, the requested view always returns an AnonymousUser as the current_user.
The /user route is defined as:
class CurrentUser(Resource):
def get(self):
return current_user # returns an AnonymousUser
I'm fairly certain I'm just not fully understanding how testing Flask request contexts work. I've read this Flask Request Context documentation a bunch but am still not understanding how to approach this particular unit test.
The problem is different request contexts.
In your normal Flask application, each request creates a new context which will be reused through the whole chain until creating the final response and sending it back to the browser.
When you create and run Flask tests and execute a request (e.g. self.client.post(...)) the context is discarded after receiving the response. Therefore, the current_user is always an AnonymousUser.
To fix this, we have to tell Flask to reuse the same context for the whole test. You can do that by simply wrapping your code with:
with self.client:
You can read more about this topic in the following wonderful article:
https://realpython.com/blog/python/python-web-applications-with-flask-part-iii/
Example
Before:
def test_that_something_works():
response = self.client.post('login', { username: 'James', password: '007' })
# this will fail, because current_user is an AnonymousUser
assertEquals(current_user.username, 'James')
After:
def test_that_something_works():
with self.client:
response = self.client.post('login', { username: 'James', password: '007' })
# success
assertEquals(current_user.username, 'James')
The problem is that the test_client.get() call causes a new request context to be pushed, so the one you pushed in your the setUp() method of your test case is not the one that the /user handler sees.
I think the approach shown in the Logging In and Out and Test Adding Messages sections of the documentation is the best approach for testing logins. The idea is to send the login request through the application, like a regular client would. This will take care of registering the logged in user in the user session of the test client.
I didn't much like the other solution shown, mainly because you have to keep your password in a unit test file (and I'm using Flask-LDAP-Login, so it's nonobvious to add a dummy user, etc.), so I hacked around it:
In the place where I set up my test app, I added:
#app.route('/auto_login')
def auto_login():
user = ( models.User
.query
.filter_by(username="Test User")
.first() )
login_user(user, remember=True)
return "ok"
However, I am making quite a lot of changes to the test instance of the flask app, like using a different DB, where I construct it, so adding a route doesn't make the code noticeably messier. Obv this route doesn't exist in the real app.
Then I do:
def login(self):
response = self.app.test_client.get("/auto_login")
Anything done after that with test_client should be logged in.
From the docs: https://flask-login.readthedocs.io/en/latest/
It can be convenient to globally turn off authentication when unit testing. To enable this, if the application configuration variable LOGIN_DISABLED is set to True, this decorator will be ignored.

Unable to test persistent sessions in Django 1.1

As part of the registration process for my site I have several views that set session data. Later views depend on the session data being set. This all works fine in the browser, however when I try to test it the session data seems to be lost in between requests which makes it impossible to test. Here's a simple example of the problem I'm having. I would expect get_name to have access to session['name'] and return a 200, however the session data is being lost and get_name returns a 302.
>>> c = Client()
>>> r = c.post(reverse(set_name))
>>> r = c.post(reverse(get_name))
>>> r.status_code
200
def set_name(request):
request.session['name'] = 'name'
return HttpResponse()
def get_name(request):
try:
name = request.session['name']
except KeyError:
return redirect(reverse(set_name))
return HttpResponse(name)
Sessions are tested quite awkwardly in Django. You have to setup the session engine first.
class TestSession(TestCase):
"""A class for working with sessions - working.
http://groups.google.com/group/django-users/browse_thread/thread/5278e2f2b9e6da13?pli=1
To modify the session in the client do:
session = self.client.session
session['key'] = 'value'
session.save()
"""
def setUp(self):
"""Do the session preparation magic here"""
super(TestSession, self).setUp()
from django.conf import settings
from django.utils.importlib import import_module
engine = import_module(settings.SESSION_ENGINE)
store = engine.SessionStore()
store.save() # we need to make load() work, or the cookie is worthless
self.client.cookies[settings.SESSION_COOKIE_NAME] = store.session_key
Use this class as a base class for your test cases. Check out the link for more info.

Categories

Resources