Archive for the ‘Django’ Category

Тестирование Middleware в Django

Thursday, November 24th, 2011

Для тестирования view в django есть удобный класс django.test.client.Client и хорошая официальная документация, но что делать, если нам надо протестировать лишь middleware, а не всю фазу от запроса до ответа?
Допустим у нас есть middleware, позволяющее задавать маску по урлам для обертывания в login_required и маску с исключениями в конфигурационном файле:

import re
 
from django.conf import settings
from django.contrib.auth.decorators import login_required
 
class RequireLoginMiddleware(object):
    """
    Middleware component that wraps the login_required decorator around 
    matching URL patterns. To use, add the class to MIDDLEWARE_CLASSES and 
    define LOGIN_REQUIRED_URLS and LOGIN_REQUIRED_URLS_EXCEPTIONS in your 
    settings.py. For example:
    ------
    LOGIN_REQUIRED_URLS = (
        r'/topsecret/(.*)$',
    )
    LOGIN_REQUIRED_URLS_EXCEPTIONS = (
        r'/topsecret/login(.*)$', 
        r'/topsecret/logout(.*)$',
    )
    ------                 
    LOGIN_REQUIRED_URLS is where you define URL patterns; each pattern must 
    be a valid regex.     
 
    LOGIN_REQUIRED_URLS_EXCEPTIONS is, conversely, where you explicitly 
    define any exceptions (like login and logout URLs).
    """
    def __init__(self):
        self.required = tuple([re.compile(url) for url in settings.LOGIN_REQUIRED_URLS])
        self.exceptions = tuple([re.compile(url) for url in settings.LOGIN_REQUIRED_URLS_EXCEPTIONS])
 
    def process_view(self, request, view_func, view_args, view_kwargs):
        # No need to process URLs if user already logged in
        if request.user.is_authenticated(): return None
        # An exception match should immediately return None
        for url in self.exceptions:
            if url.match(request.path): return None
        # Requests matching a restricted URL pattern are returned 
        # wrapped with the login_required decorator
        for url in self.required:
            if url.match(request.path): return login_required(view_func)(request, *view_args, **view_kwargs)
        # Explicitly return None for all non-matching requests
        return None

Класс достаточно простой, но в его тестировании есть ряд ньюансов.
Начнем с того, что на вход подается объект request, который неплохо бы как-то получить в подходящем для тестирования виде. Для этого в django есть специальный класс RequestFactory:

from django.test import TestCase
from django.test.client import RequestFactory
 
class RequireLoginMiddlewareTest(TestCase):
    def test_url_is_not_in_login_required(self):
        rf = RequestFactory()
        request = rf.get('/abc/test')

Возникает вопрос, что делать с масками для URL? Тест не должен полагаться на данные из конфига, которые могут быть в любой момент изменены. В версии 1.4 будет добавлена возможность временно менять значения из настроек в тестах: Overriding settings, но это trunk, а на продакшене стоит последний stable релиз, т.е. 1.3
Решение не столь элегантное, но зато доволное простое: тесты запускаются в single-thread, так что мы можем поменять настройки в setUp и использовать их далее в тестах:

    def setUp(self):
        self.old_settings_required_urls = settings.LOGIN_REQUIRED_URLS
        self.old_settings_exceptions_urls = settings.LOGIN_REQUIRED_URLS_EXCEPTIONS
        settings.LOGIN_REQUIRED_URLS = (
            r'/test/(.*)$',
        )
        settings.LOGIN_REQUIRED_URLS_EXCEPTIONS = (
            r'/test/exc1(.*)$',
            r'/test/exc2(.*)$',
        )
        self.middleware = RequireLoginMiddleware()
 
    def tearDown(self):
        settings.LOGIN_REQUIRED_URLS = self.old_settings_required_urls
        settings.LOGIN_REQUIRED_URLS_EXCEPTIONS = self.old_settings_exceptions_urls

Теперь нам осталось добавить атрибут session к request’у, иначе мы получим
AttributeError: ‘WSGIRequest’ object has no attribute ‘session’
так как сессия к request’у добавляется в отдельном SessionMiddleware.
Вместо view_func передадим аргументом пустую анонимную функцию, нам важен только возвращаемый результат: None (если пользователь залогинен или URL не требует аутентификации) или HttpResponseRedirect на форму входа.

    def test_url_is_not_in_login_required(self):
        rf = RequestFactory()
        request = rf.get('/abc/test')
        request.session = {}
        response = self.middleware.process_view(request, lambda x: x, [], {})
        self.assertIsNone(response)
 
    def test_access_excluded_from_login_required_urls(self):
        rf = RequestFactory()
        request = rf.get('/test/exc1/foo')
        request.session = {}
        response = self.middleware.process_view(request, lambda x: x, [], {})
        self.assertIsNone(response)
 
    def test_redirect_guests(self):
        rf = RequestFactory()
        request = rf.get('/test/something/')
        request.session = {}
        response = self.middleware.process_view(request, lambda x: x, [], {})
        self.assertIsInstance(response, HttpResponseRedirect)
        self.assertEqual(reverse('auth_login') + '?next=/test/something/', response['Location'])
        self.assertEqual(response.status_code, 302)

Остался последний случай: залогиненные пользователи.
Здесь мы воспользуемся библиотекой Mock (опять из-за сессий на которые опирается django.contrib.auth.login) и “подменить” метод is_authenticated() у user’a чтобы он возвращал True:

    def test_logged_user_access(self):
        rf = RequestFactory()
        request = rf.get('/test/something/')
        request.session = {}
        request.user.is_authenticated = Mock(return_value=True)
        response = self.middleware.process_view(request, lambda x: x, [], {})
        self.assertIsNone(response)