Copyright: Yan Bei (wechat Shin-devops), published in “Nuggets”, unauthorized reprint prohibited!

Unit testing is an important means of quality assurance in software development.

With unit testing, you can “test first” and get TDD to ground; You can also refactor code to keep the logic intact.

Unit testing is covered in some detail in the “Testing” chapter of the Official Django documentation. The purpose of this article is to “show you how to write a unit test base class in as little space as possible,” plus some advanced usage practices (such as mocks) to make it easier than a pain to write a unit test.

The official documentation

Basic usage

Unit testing calls a method (execution) and determines whether the method performed as expected (assertion).

The following code is easier to understand:

from django.test import TestCase
from myapp.models import Animal

class AnimalTestCase(TestCase):
    def test_animals_can_speak(self):
        """Animals that can speak are correctly identified"""
        Animal.objects.create(name="lion", sound="roar")
        Animal.objects.create(name="cat", sound="meow")
        lion = Animal.objects.get(name="lion")
        cat = Animal.objects.get(name="cat")
        self.assertEqual(lion.speak(), 'The lion says "roar"')
        self.assertEqual(cat.speak(), 'The cat says "meow"')
Copy the code

Examples are available on djangos website

In a test method that starts with test_, determine the result by calling the function and then using the Assert method.

If there is a process for preparing the environment and testing the data collection, it can be handled using the setUp and tearDown methods:

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
from django.test import TestCase
from myapp.models import Animal

class AnimalTestCase(TestCase):
    def setUp(self):
        Animal.objects.create(name="lion", sound="roar")
        Animal.objects.create(name="cat", sound="meow")
        
    def test_animals_can_speak(self):
        """Animals that can speak are correctly identified"""
        lion = Animal.objects.get(name="lion")
        cat = Animal.objects.get(name="cat")
        self.assertEqual(lion.speak(), 'The lion says "roar"')
        self.assertEqual(cat.speak(), 'The cat says "meow"')
    
    def tearDown(self):
        Animal.objects.filter(name__in=["lion"."cat"]).delete()
Copy the code

Run the test

python manage.py test
Copy the code

When running a single test, add the –keepdb parameter to avoid the problem of rebuilding the database each time a single test is executed, and improve the execution speed:

python manage.py test --keepdb
Copy the code

The interface test

For testing interfaces, Web frameworks typically integrate “test suites” themselves to execute unit test cases by simulating requests. Django already implements a RequestFactory class that can be used to send requests directly:

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
from django.contrib.auth.models import User
from django.test import RequestFactory, TestCase

from .views import MyView

class SimpleTest(TestCase):
    def setUp(self):
        # Every test needs access to the request factory.
        self.factory = RequestFactory()
        self.user = User.objects.create_user(
            username='jacob', email='Jacob @... ', password='top_secret')

    def test_details(self):
        # Create an instance of a GET request.
        request = self.factory.get('/customer/details')
        # Recall that middleware are not supported. You can simulate a
        # logged-in user by setting request.user manually.
        request.user = self.user
        # Use this syntax for class-based views.
        response = MyView.as_view()(request)
        self.assertEqual(response.status_code, 200)
Copy the code

In the DRF APITestCase

RequestFactory (response = myView.as_view ()(request)); RequestFactory (response = myView.as_view ()));

The Djang Rest Framework solves this problem by sending requests directly through self.client in APITestCase:

from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from myproject.apps.core.models import Account

class AccountTests(APITestCase):
    def test_create_account(self):
        """ Ensure we can create a new account object. """
        url = reverse('account-list')
        data = {'name': 'DabApps'}
        response = self.client.post(url, data, format='json')
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Account.objects.count(), 1)
        self.assertEqual(Account.objects.get().name, 'DabApps')
Copy the code

Examples are from the DRF website

Solve and optimize problems

Then we need to consider, do Django and DRF both meet our needs? Is there an easier way to do it?

Question 1. How do I simulate a user login state

Our project has permission control for users, so the first problem we encounter is “how to simulate a user login state”, so that the permission related logic will not be wrong.

In the Django example, constructing request and then explicitly configuring Request.user to be a created User object is a problem that needs to be resolved because DRF encapsulates the process of constructing request.

The solution

To solve this problem, I searched For Django projects with a high number of stars on GitHub and learned how to optimize unit testing for different projects.

Sentry encapsulates a login_AS method that bypasses user login by adding user information to the current session.

With this in mind, I simplified the Sentry implementation code to get the following method:

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
from django.conf import settings
from django.contrib.auth import login
from django.contrib.auth.models import AnonymousUser
from django.utils.functional import cached_property
from django.http import HttpRequest
from rest_framework.test import APITestCase as BaseAPITestCase

class APITestCase(BaseAPITestCase):
    @staticmethod
    def create_session(a):
        engine = import_module(settings.SESSION_ENGINE)
        session = engine.SessionStore()
        session.save()
        return session
        
    @cached_property
    def session(self):
        return self.create_session()

    def save_session(self):
        self.session.save()
        self.save_cookie(
            name=settings.SESSION_COOKIE_NAME,
            value=self.session.session_key,
            expires=None
        )

    def save_cookie(self, name, value, **params):
        self.client.cookies[name] = value
        self.client.cookies[name].update({
            k.replace('_'.The '-'): v
            for k, v in params.items()
        })

    def login(self, user):
        "" Login user for permission verification. ""
        user.backend = settings.AUTHENTICATION_BACKENDS[0]
        request = self.make_request()
        login(request, user)
        request.user = user
        self.save_session()

    def make_request(self, user=None, auth=None, method=None):
        request = HttpRequest()
        if method:
            request.method = method
        request.META['REMOTE_ADDR'] = '127.0.0.1'
        request.META['SERVER_NAME'] = 'testserver'
        request.META['SERVER_PORT'] = 80
        request.REQUEST = {}

        # order matters here, session -> user -> other things
        request.session = self.session
        request.auth = auth
        request.user = user or AnonymousUser()
        request.is_superuser = lambda: request.user.is_superuser
        request.successful_authenticator = None
        return request
Copy the code

Before sending the request, the Session after simulated login is saved by calling the login method, so that the SessionId is added when calling self.client to send the request, so as to achieve the login effect:

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
class MyViewTest(APITestCase):
    def setUp(self) -> None:
        self.user = self.create_user(is_staff=True)
        self.login(self.user)
        
    def test_get_myview_details(self) -> None:
        # assume/API/myView will only be requested if is_staff user is logged in
        response = self.client.get(path=/api/myview)
        If # status_code is not 401, the user is logged in
        self.assertEqual(response.status_code, 200)
Copy the code

Question 2. How can I easily initialize prepared data

The interface logic code often relies on existing data for reading and writing operations, and “initialize ready data” needs to be more flexible to reduce initialization of properties that are not of interest in this test case, while keeping the operations performed by the use case clear.

Write some create_ methods with default values

When initializing some data in a unit test, we usually only want to configure certain fields, so we can encapsulate all data model creation methods into the test suite and automatically add default values for all fields:

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
from django.contrib.auth.models import User
from uuid import uuid4
from rest_framework.test import APITestCase as BaseAPITestCase

class APITestCase(BaseAPITestCase):
    @staticmethod
    def create_user(username=None, **kwargs):
        if username is None:
            username = uuid4().hex

        return User.objects.create_user(username=username, **kwargs)
Copy the code

It would be better to extract a class that handles initialization data, which would make the code look nicer and easier to maintain:

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
from rest_framework.test import APITestCase as BaseAPITestCase

class Factories(object):
    @staticmethod
    def create_user(username=None, **kwargs):.    @staticmethod 
    def create_task(task_name=None, **kwargs):.class APITestCase(Factories, BaseAPITestCase):
    pass
Copy the code

Processing of URLS

The URL can be in write path format, but it is cumbersome to maintain in case the path changes.

Routes in Django support reverse path lookup via an Endpoint:

>>>from django.urls import reverse
>>>reverse("app_label.endpoint")
/api/my-endpoint
Copy the code

We add app_label and endpoint to the APITestCase class to provide get_URL:

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
class APITestCase(Factories, BaseAPITestCase):
    # django App name
    app_label = 'my_app'
    # endpoint, used to identify the URL
    endpoint = None
    
    def get_url(self, *args, **kwargs):
        return reverse(f"{self.app_label}:{self.endpoint}", args=args, kwargs=kwargs)
Copy the code

In the test case:

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
class TaskDetailTest(APITestCase):
    endpoint = 'task-detail'

    def setUp(self) -> None:
        self.url = self.get_url(task_id=self.task.pk)
        ...
    
    def test_get_task_details(self):
        result = self.client.get(self.url)
        ...
Copy the code

Question 3. How can I execute an asynchronous task without starting the service

We also use celery to perform asynchronous tasks, “how to perform asynchronous tasks without starting the service”?

Solution 1: Asynchronous code executes synchronously

By modifying the configuration of Celery:

class MyTest(TestCase):

    def setUP(self):
        celery.conf.update(CELERY_ALWAYS_EAGER=True)
Copy the code

With this modification, asynchronous tasks will be executed synchronously and services like Celery Worker and RabbitMQ will not need to be started. The following two are equivalent:

add.delay(2.2)
add(2.2)
Copy the code

However, this approach has a disadvantage that the number of test cases will be multiplied by the number of branches of the Celery task * the number of conditional branches of the main task.

Mocks are needed to reduce cross-coverage scenarios.

Solution 2: Mock

Assume the following code:

tasks.py

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
@celery_task
def add(x, y):
    print(x + y)
    
def main_func(a):
    x, y = do_something()
    add.delay(x, y)
Copy the code

You can Mock out the delay of the asynchronous task and test only the other code in the main_func method. Since we skipped the add method, we need to test the add method:

test.py

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
from unittest import mock
from tasks import add, main_func

class MyTest(TestCase):

    @mock.patch("tasks.add.delay")
    def test_main_func(self, mocked_delay):
        mocked_delay.return_value = None
        result = main_func()
        mocked_delay.assert_called_with(1.2)
        self.assertEqual(result, my_expect1)
        
    def test_add(self):
        result = add()
        self.assertEqual(result, my_expect2)
Copy the code

conclusion

To go back to the beginning, “unit testing is an important quality assurance tool in software development,” but many companies/teams/developers ignore single testing in favor of visual “output.”

Unit testing is a long-term investment. Writing unit tests while developing requirements may feel like writing two pieces of code, but in fact, for the company, having single-tested code reduces the probability of taking risks while maintaining the code and the losses incurred in solving those risks. As a developer, quality awareness and testing mindset enable you to write better code with more maintainable, testable code in mind.

reference

  • Docs.djangoproject.com/zh-hans/2.2…
  • www.django-rest-framework.org/api-guide/t…
  • Github.com/getsentry/s…

Copyright: Yan Bei (wechat Shin-devops), published in “Nuggets”, unauthorized reprint prohibited!