Mocking ASGI Scope in WSGI Requests when Testing Django Async Views

1 month ago
3 min read

Django channels are great for asynchronous views, but they can be a headache to test. This is due to the incompatibility with database transactions and all the side effects that the incompatibility produces. For example, the common pattern of rolling back the database after tests does not work, the data remains there which makes other tests fail as test outputs become flaky. However, using synchronous tests causes the request object to have different properties than in asynchronous tests. The request object is a WSGIRequest object in synchronous views and an ASGIRequest object in asynchronous views and the ASGIRequest has specifically the scope attribute which differs from the WSGIRequest. However, due to the database incompatibilities, we'd still prefer to use synchronous tests if possible. This blog post will show you how to make the request object in synchronous views have the same properties as in asynchronous views by adding a middleware that injects the scope.

The scope is a dictionary that contains information about the request. It is used in asynchronous views to pass information about the request to the view. It is commonly used in for example Django's GraphQL views.

This blog post tries to circumvent the following issues:

If they are fixed in the future, this blog post will be obsolete.

Creating a Scope Test Middleware

First, we'll create a middleware that injects the scope into the request object. This middleware will be used in synchronous tests to make the request object have the same properties as in asynchronous tests. The scope will be based on the HttpRequest object. You might want to customize the details based on your specific needs.

If we take a look at AsyncRequestFactory we can see how the default scope is created in Django. We'll use this as a reference. The middleware will create the dict as in the below example:

class EnsureScopeMiddleware:
    """
    This middleware ensures that the request object has a 'scope' attribute.
    Used in WSGI tests.
    """

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # Check if the request object has a 'scope' attribute
        if not hasattr(request, 'scope'):
            request.scope = self.initialize_default_scope(request)

        response = self.get_response(request)

        return response

    def initialize_default_scope(self, request):
        # Here you create a default scope based on the HttpRequest
        # You might want to customize the details based on your specific needs
        return {
            'type': 'http',
            'path': request.path,
            'headers': dict(request.headers.items()),
            'method': request.method,
            'query_string': request.META['QUERY_STRING'].encode('utf-8'),
            'client': (request.META['REMOTE_ADDR'], request.META.get('REMOTE_PORT', 0)),
            'server': (request.META['SERVER_NAME'], request.META['SERVER_PORT']),
            'asgi': {'version': '3.0'},
        }

We'll add this middleware to the MIDDLEWARE setting in the test settings file e.g. test.py file.

test.py
# MIDDLEWARE
# ------------------------------------------------------------------------------
MIDDLEWARE += ["myproject.middleware.EnsureScopeMiddleware"]  # noqa F405

The middleware will now ensure that the request object has a scope attribute. The difference between the server scope and the test scope can be seen in the below example.

Test middleware:

{'type': 'http', 'path': '/gql/', 'headers': {'Cookie': 'sessionid=b22it9p7pk3jcth2eaymk0rcf9798mzv', 'Content-Length': '487',
'Content-Type': 'application/json'}, 'method': 'POST', 'query_string': b'', 'client': ('127.0.0.1', 0), 'server':
('testserver', '80'), 'asgi': {'version': '3.0'}}

Django ASGI server:

{'asgi': {'version': '3.0'}, 'type': 'http', 'http_version': '1.1', 'client': ['127.0.0.1', 0], 'server': ('127.0.0.1', '80'), 'scheme': 'http', 'method': 'POST', 'headers': [(b'host', b'testserver'), (b'content-length', b'460'), (b'content-type',
b'application/json'), (b'cookie', b'sessionid=5rlfj3qwh1qj4rwef05uekt29i6013po')], 'path': '/gql/', 'query_string': ''}

Any functionality that interacts with the ASGI scope can be tested now in synchronous WSGI tests.

Conclusion

We were using asynchronous views and testing them asynchronously as well, however the data inconsistency due to database issues were too many which was causing extremely flaky tests. Therefore, we decided to just use synchronous tests whenever possible and use the EnsureScopeMiddleware to make the request object have the same properties as in asynchronous views. This way we can use synchronous tests and still have the same properties in the request object as in asynchronous views. This makes testing much easier and less flaky.


Similar Posts

Adding a Shell to the Django Admin

3 min read

Django's admin is an amazing batteries-included addition to the Django framework. It's a great tool for managing your data, and it is easily extendable and customizable. The same goes for Django's ./manage.py shell command. If we were to combine these …


Deploying a Django Channels ASGI Server to Kubernetes

6 min read

Django channels is great for adding support of various network protocols as WebSockets, chat protocols, IoT protocols and other. It's a great way to add real-time functionality to your Django application. To use channels however, requires you to deploy an …


Kickstarting Infrastructure for Django Applications with Terraform

8 min read

When creating Django applications or using cookiecutters as Django Cookiecutter you will have by default a number of dependencies that will be needed to be created as a S3 bucket, a Postgres Database and a Mailgun domain.