Django — написание хороших модульных тестов

Django

Заявление об авторских правах: Автор «Ян Бэй (WeChat)shin-devops)",опубликовано"Наггетс», несанкционированное воспроизведение запрещено!

Модульное тестирование является важным методом обеспечения качества при разработке программного обеспечения.

С помощью модульного тестирования вы можете «сначала протестировать» и внедрить TDD; вы также можете гарантировать, что исходная логика не будет затронута при рефакторинге кода.

В официальной документации Django "контрольная работа«В главе более подробно описано, как выполнить модульное тестирование, цель этой статьи — «Расскажите, как написать базовый класс модульного тестирования, заняв как можно меньше места.", а также некоторые расширенные возможности (такие какMock) практиковаться, делая написание одного теста проще, чем мучительнее.

официальная документация

Основное использование

Модульное тестирование вызывает метод (выполнение), чтобы определить, является ли эффект метода ожидаемым (утверждение), что является процессом выполнения и оценки результата.

Тогда следующий код будет легче понять:

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"')

Пример взят с официального сайта Django.

вtest_в тестовом методе в начале, вызвав функцию, а затем используяassertрезультат метода.

Если есть процесс подготовки среды и восстановления тестовых данных, то его можно использоватьsetUpиtearDownспособ обработки:

"""
@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()

запустить тест

python manage.py test

При запуске одного теста добавьте--keepdbпараметры, чтобы избежать необходимости перестраивать базу данных каждый раз, когда выполняется один тест, и повысить скорость выполнения:

python manage.py test --keepdb

тест интерфейса

Для тестирования интерфейса обычно сама веб-инфраструктура интегрирует «набор тестов» для выполнения модульных тестов путем имитации запросов. Джанго реализовалRequestFactoryкласс, который можно использовать непосредственно для отправки запросов:

"""
@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)

APITestCase в DRF

Видно, что метод построения запроса все еще довольно хлопотный, в каждом варианте использования нам нужно инициализироватьRequestFactoryобъект при вызове (response = MyView.as_view()(request)) недостаточно интуитивен.

существуетDjang Rest FrameworkЭта проблема решается вAPITestCaseсерединаself.clientпослать запрос:

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')

Пример с официального сайта DRF

Решайте и оптимизируйте проблемы

Итак, нам нужно подумать, соответствуют ли эти методы Django и DRF нашим потребностям? Есть ли более простой способ сделать это?

Вопрос 1. Как имитировать состояние входа пользователя

В нашем проекте есть контроль разрешений для пользователей, поэтому первая возникшая проблема — «как смоделировать состояние входа пользователя», чтобы не было ошибок в логике, связанной с разрешениями.

В примере Django, поскольку сначала создается запрос, а затем явноrequest.userНастроен на созданиеUserобъект для достижения, но в DRF, поскольку процесс построения запроса инкапсулирован, этот метод больше нельзя использовать, что является проблемой, которую необходимо решить.

Решение

Решая эту проблему, я искал на GitHub проекты Django с большим количеством звезд и узнал, как разные проекты оптимизируют модульные тесты.

вSentryупаковалlogin_asметод обхода входа пользователя путем добавления информации о пользователе в текущий сеанс.

По этой идее я упростил код реализации Sentry и получил следующий метод:

"""
@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():
        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('_', '-'): v
            for k, v in params.items()
        })

    def login(self, user):
        """登录用户,用于通过权限校验"""
        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

Перед отправкой запроса, позвонивloginМетод сохраняет сеанс после имитации входа в систему, так что вызовself.clientПринесите SessionId при отправке запроса для достижения эффекта входа в систему:

"""
@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:
        # 假设 /api/myview 只有在 is_staff 用户登录情况下才可请求
        response = self.client.get(path=/api/myview)
        # status_code 不为 401,说明用户已经登录
        self.assertEqual(response.status_code, 200)

Вопрос 2. Как легко инициализировать и подготовить данные

Код логики интерфейса обычно опирается на существующие данные для операций чтения и записи. «Данные подготовки к инициализации» должны быть более гибкими, уменьшая инициализацию свойств, не связанных с этим тестовым примером, и в то же время сохраняя операции. выполняется с помощью варианта использования ясно.

Напишите несколько методов create_ со значениями по умолчанию.

При инициализации некоторых данных в модульном тесте мы обычно хотим настроить только некоторые поля, поэтому мы можем инкапсулировать все методы создания модели данных в набор тестов и автоматически добавлять значения по умолчанию для всех полей:

"""
@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)

Лучший способ — извлечь класс, предназначенный для обработки данных инициализации, который будет выглядеть лучше и его будет проще поддерживать:

"""
@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

Обработка URL-адресов

URL-адрес может быть записан в формате пути, но если путь изменен, его сложнее поддерживать.

Маршрутизация в Django поддерживает поиск обратного пути через Endpoint:

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

мы вAPITestCaseдобавлен классapp_labelиendpointдва свойства, обеспечивающиеget_urlЛегко позвонить:

"""
@Author: Shin Yang
@WeChat: shin-devops
"""
class APITestCase(Factories, BaseAPITestCase):
    # django App 名
    app_label = 'my_app'
    # 端点,用于标识 URL
    endpoint = None
    
    def get_url(self, *args, **kwargs):
        return reverse(f"{self.app_label}:{self.endpoint}", args=args, kwargs=kwargs)

В тестовом случае:

"""
@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)
        ...

Вопрос 3. Как выполнить асинхронную задачу без запуска службы

И тогда мы используем сельдерей для выполнения асинхронных задач, «как выполнять асинхронные задачи без запуска службы»?

Решение 1. Синхронное выполнение асинхронного кода

Изменив конфигурацию Celery:

class MyTest(TestCase):

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

После модификации асинхронные задачи будут выполняться синхронно, и нет необходимости запускать такие сервисы, как Celery Worker и RabbitMQ. Следующие два эквивалентны:

add.delay(2, 2)
add(2, 2)

Но у этого способа есть недостаток, то есть количество тестовых случаев станет количеством ветвей задачи Celery * количеством условных ветвей основной задачи, которые будут умножаться.

Следовательно, необходимо использовать Mock для уменьшения сценариев перекрестного покрытия.

Решение 2: Макет

Предположим, что код выглядит следующим образом:

tasks.py

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

тогда вы можете использоватьMockбудет метод асинхронной задачиdelayпропустить, дойти только до тестаmain_funcдругой код в методе; потому что пропустилaddметод, то количествоaddметод проверки:

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)

Суммировать

Возвращаясь к началу, «модульное тестирование является важным методом обеспечения качества при разработке программного обеспечения», но многие компании/команды/разработчики игнорируют модульное тестирование ради «вывода», видимого невооруженным глазом.

Модульное тестирование — это долгосрочное вложение, написание модульных тестов при разработке требований может показаться написанием двух кусков кода, но на самом деле для компаний наличие однократно протестированного кода может снизить вероятность рисков в процессе сопровождения кода, и для решения этих проблем Потери, вызванные рисками; для разработчиков осведомленность о качестве и мышление в области тестирования могут позволить вам учитывать удобство сопровождения и тестируемость большего количества кода во время разработки и писать более качественный код.

Ссылаться на

Заявление об авторских правах: Автор «Ян Бэй (WeChat)shin-devops)",опубликовано"Наггетс», несанкционированное воспроизведение запрещено!