Reputation: 393
I am writing some tests using pytest with the monkeypatch fixture. Following the rules I am importing the classes and methods to mock out from the module they are being used in and not from the source.
The application I am writing tests for is a Google App Engine application which uses the Standard environment. As such I have to use python 2.7, the actual version I am using is 2.7.15 - pytest version is 3.5.0
Everything has been working well so far but I have hit a problem when trying to mock out a decorator function.
Starting from the top. In a py file called decorators.py contains all the auth decorators including the decorator I want to mock out. The decorator in question is a module function, not part of a class.
def user_login_required(handler):
def is_authenticated(self, *args, **kwargs):
u = self.auth.get_user_by_session()
if u.access == '' or u.access is None:
# return the response
self.redirect('/admin', permanent=True)
else:
return handler(self, *args, **kwargs)
return is_authenticated
The decorator is applied to the web request function. A basic example in a file called UserDetails.py in a folder called handlers (handlers.UserDetails)
from decorators import user_login_required
class UserDetailsHandler(BaseHandler):
@user_login_required
def get(self):
# Do web stuff, return html, etc
In a test module I am setting up the test like this:
from handlers.UserDetails import user_login_required
@pytest.mark.parametrize('params', get_params, ids=get_ids)
def test_post(self, params, monkeypatch):
monkeypatch.setattr(user_login_required, mock_user_login_required_func)
The problem with this is that monkeypatch does not allow me to put a single function in as the target. It wants the target to be a Class, followed by the method name to be replaced then the mock method....
monkeypatch.setattr(WouldBeClass, "user_login_required", mock_user_login_required_func)
I have tried to adjust the code to see if I can get round it by changing how the decorator is imported and used like this:
import decorators
class UserDetailsHandler(BaseHandler):
@decorators.user_login_required
def get(self):
# Do web stuff, return html, etc
Then in the test I try to patch the function name like so.....
from handlers.UserDetails import decorators
@pytest.mark.parametrize('params', get_params, ids=get_ids)
def test_post(self, params, monkeypatch):
monkeypatch.setattr(decorators, "user_login_required" , mock_user_login_required_func)
Although this code does not throw any errors, when I step through the test the code never enters the mock_user_login_required_func. It always goes into the live decorator.
What am I doing wrong? This this a problem with trying to monkeypatch decorators in general or can lone functions in modules not be patched?
Upvotes: 8
Views: 7082
Reputation: 18680
I had a similar problem, and solved it by using patch within a fixture to patch out code that the decorator deferred to. To give some context I had a view on a Django project that used a decorator on the view function to enforce authentication. Kinda something like:
# myproject/myview.py
@user_authenticated("some_arg")
def my_view():
... normal view code ...
The code for user_authenticated
lived in a separate file:
# myproject/auth.py
def user_authenticated(argument):
... code for the decorator at some point had a call to:
actual_auth_logic()
def actual_auth_logic():
... the actual logic around validating auth ...
To test, I wrote something like:
import pytest
from unittest.mock import patch
@pytest.fixture
def mock_auth():
patcher = patch("myproject.auth")
mock_auth = patcher.start()
mock_auth.actual_auth_logic.return_value = ... a simulated "user is logged in" value
yield
patcher.stop()
Then any test of the view that wanted to effectively skip the auth (ie assume the user is logged in) could just use that fixture:
def test_view(client, mock_auth):
response = client.get('/some/request/path/to/my/view')
assert response.content == "what I expect in the response content when user is logged in"
When I wanted to test that say an unauthenticated user doesn't see the authenticated content I just leave out the auth fixture:
def test_view_when_user_is_unauthenticated(client):
response = client.get('/some/request/path/to/my/view')
assert response.content == "content when user is not logged in"
It's a bit brittle in that now the tests for the view are tied to internals of the auth mechanisms (ie if that actual_auth_logic
method was renamed/refactored it'd be bad times), but at least it's isolated to just the fixture.
Upvotes: 0
Reputation: 393
Because of the import / modification gotchas mentioned here I have decided to avoid trying to use mocking for this particular decorator.
For the moment I have created a fixture to set an environment variable:
@pytest.fixture()
def enable_fake_auth():
""" Sets the "enable_fake_auth" then deletes after use"""
import os
os.environ["enable_fake_auth"] = "true"
yield
del os.environ["enable_fake_auth"]
Then in the decorator I have modified the is_authenticated method:
def is_authenticated(self, *args, **kwargs):
import os
env = os.getenv('enable_fake_auth')
if env:
return handler(self, *args, **kwargs)
else:
# get user from session
u = self.auth.get_user_by_session()
if u:
access = u.get("access", None)
if access == '' or access is None:
# return the response
self.redirect('/admin', permanent=True)
else:
return handler(self, *args, **kwargs)
else:
self.redirect('/admin?returnPath=' + self.request.path, permanent=True)
return is_authenticated
It doesn't answer my original question asked but I have put my solution here in case it can help anyone else. As hoefling has pointed out, modifying production code like this is usually a bad idea so use at your own risk!
The original solution I had in place before this did not modify or mock any code. It involved creating a fake secure cookie then sending this up in the headers in the test request. This would make the call to self.auth.get_user_by_session() return a valid object with the access set. I may revert back to this.
Upvotes: 0
Reputation: 1474
It looks like the quick answer here is simply to move your Handler import so that it occurs after the patch. The decorator and the decorated functions must be in separate modules so that python doesn’t execute the decorator before you’ve patched it.
from decorators import user_login_required
@pytest.mark.parametrize('params', get_params, ids=get_ids)
def test_post(self, params, monkeypatch):
monkeypatch.setattr(decorators, "user_login_required" , mock_user_login_required_func)
from handlers.UserDetails import UserDetailsHandler
You may find this easier to accomplish using the patch function from the built in unittest.mock module.
Upvotes: 2