solo1977
solo1977

Reputation: 393

pytest-monkeypatch a decorator (not using mock / patch)

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

Answers (3)

Adam Parkin
Adam Parkin

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

solo1977
solo1977

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

soundstripe
soundstripe

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

Related Questions