Для большего удобства ознакомьтесь с этой статьей на моем сайте.

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

class FeatureSet(models.Model):
    name = models.CharField(max_length=50)
    can_pay_with_credit_card = models.BooleanField()
    can_save_credit_card = models.BooleanField()
    can_receive_email_notifications = models.BooleanField()

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

Чтобы обеспечить соблюдение функций, мы добавили тесты в соответствующие места. Например:

def pay_with_credit_card(self, user_account, amount):
    if not user_account.feature_set.can_pay_with_credit_card:
        raise FeatureDisabled('can_pay_with_credit_card')
    ...

Эта проблема

К этому моменту у нас была большая кодовая база и множество тестов. К сожалению, многие наши тесты являются пережитком того времени, когда мы активно использовали приборы.

Мысль о необходимости обновлять и добавлять новые приборы была неприемлема. Но нам все еще нужно было протестировать новые функции, поэтому мы начали писать такие тесты:

def test_should_charge_credit_card(self):
    feature_set = user_account.feature_set
    feature_set.can_pay_with_credit_card = True
    feature_set.save(update_fields=['can_pay_with_credit_card'])
    pay_with_credit_card(user_account, 100)
    
def test_should_fail_when_feature_disabled(self):
    feature_set = user_account.feature_set
    feature_set.can_pay_with_credit_card = False
    with self.assertRaises(FeatureDisabled):
        pay_with_credit_card(self.user_account, 100)

Нам нужно было обновить множество тестов, и некоторые из добавленных нами функций прервали выполнение других тестов, что привело к беспорядку!

Менеджер контекста

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

from contextlib import contextmanager
@contextmanager
def feature(feature_set, feature_name, enabled):
   original_value = getattr(feature_set, feature_name)
   setattr(feature_set, feature_name, enabled)
   feature_set.save(update_fields=[feature_name])
   try:
       yield
   finally:
       setattr(feature_set, feature_name, original_value)
       feature_set.save(update_fields=[feature_name])

Что делает этот диспетчер контекста?

  1. Сохраните исходное значение функции.
  2. Установите новое значение для функции.
  3. Урожайность - это то место, где фактически выполняется наш тестовый код.
  4. Верните для функции исходное значение

Это сделало наши тесты более элегантными:

def test_should_charge_credit_card(self):
   with feature(
       user_account.feature_set,
       can_pay_with_credit_card,
       True,
   ):
       pay_with_credit_card(user_account, 100)
def test_should_fail_when_feature_disabled(self):
   with feature(
       user_account.feature_set,
       can_pay_with_credit_card,
       False,
   ):
       with self.assertRaises(FeatureDisabled):
           pay_with_credit_card(self.user_account, 100)

Это был большой шаг вперед, но мы все еще не были удовлетворены.

** kwargs

Этот диспетчер контекста оказался очень полезным для функций, поэтому мы подумали… почему бы не использовать его и для других целей?

У нас было много методов, включающих более одной функции:

def test_should_not_send_notification(self):
   feature_set = user_account.feature_set 
   with feature(feature_set, can_pay_with_credit_card, True):
       with feature(feature_set, can_receive_notifications, False):
           pay_with_credit_card(user_account, 100)

Или более одного объекта:

def test_should_not_send_notification_to_inactive_user(self):
   feature_set = user_account.feature_set 
   user_account.user.is_active = False
   with feature(feature_set, can_receive_notifications, False):
       pay_with_credit_card(user_account, 100)

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

@contextmanager
def temporarily(obj, **kwargs):
   original_values = {k: getattr(obj, k) for k in kwargs}
   for k, v in kwargs.items():
       setattr(obj, k, v)
   obj.save(update_fields=kwargs.keys())
   try:
       yield
   finally:
       for k, v in original_values.items():
           setattr(obj, k, v)
       obj.save(update_fields=original_values.keys())

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

Тестировать стало намного проще:

def test_should_not_send_notification(self):
    with temporarily(
        user_account.feature_set,
        can_pay_with_credit_card=True,
        can_receive_notifications=False,
    ):
        pay_with_credit_card(user_account, 100)
    self.assertEquals(len(outbox), 0)

Теперь мы можем использовать эту функцию и для других объектов:

def test_should_fail_to_login_inactive_user(self):
    with temporarily(user, is_active=False):
        response = self.login(user)
    self.assertEqual(response.status_code, 400)

Прибыль!

Скрытое преимущество в производительности

Через некоторое время, освоившись с новой утилитой, мы заметили еще одно преимущество в производительности.

В тестах с тяжелыми настройками нам удалось переместить настройку с тестового уровня на уровень класса.

Чтобы проиллюстрировать разницу, давайте протестируем функцию, которая отправляет счета пользователям. Счета обычно отправляются только после завершения транзакции. Чтобы создать полную транзакцию, нам потребуется много настроек (выбрать продукты, оформить заказ, произвести оплату и т. Д.).

Это тест, который требует большой настройки:

class TestSendInvoice(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(...)
        self.transaction = Transaction.create(self.user, ...)
        Transaction.add_product(...)
        Transaction.add_product(...)
        Transaction.checkout(...)
        Transaction.request_payment(...)
        Transaction.process_payment(...)
    
    def test_should_not_send_invoice_to_commercial_user(self):
        self.user.type = 'commercial'
        mail.outbox = []
        Transaction.send_invoice(self.user)
        self.assertEqual(len(mail.outbox), 0)
    
    def test_should_attach_special_offer_to_pro_user(self):
        self.user.type = 'pro'
        mail.outbox = []
        Transaction.send_invoice(self.user)
        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(
            mail.outbox[0].subject,
            'Invoice and a special offer!'
        )

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

Чтобы предотвратить зависимости между тестовыми примерами, мы должны убедиться, что каждый тест оставляет данные в точности в том виде, в котором они были получены. К счастью, это именно то, что делает наш новый менеджер контекста:

class TestSendInvoice(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user(...)
        cls.transaction = Transaction.create(cls.user, ...)
        Transaction.add_product(...)
        Transaction.add_product(...)
        Transaction.checkout(...)
        Transaction.request_payment(...)
        Transaction.process_payment(...)
    
    def test_should_not_send_invoice_to_commercial_user(self):
        mail.outbox = []
        with temporarily(self.user, type='commercial'):
            Transaction.send_invoice(self.user)
        self.assertEqual(len(mail.outbox), 0)
    
    def test_should_attach_special_offer_to_pro_user(self):
        mail.outbox = []
        with temporarily(self.user, type='pro'):
            Transaction.send_invoice(self.user)
        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(
            mail.outbox[0].subject,
            'Invoice and a special offer!'
        )

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

Заключительные слова

Мотивом для этого обработчика контекста были наши давние нездоровые отношения с приборами. Когда мы масштабировали наше приложение, приспособления стали обузой. Из-за того, что на них полагается так много тестов, их трудно полностью заменить.

С добавлением функций мы знали, что больше не хотим полагаться на приборы, и стали искать творческие, более подробные и удобные в обслуживании способы управления тестовыми данными. Нам нужен был простой способ создания различных вариантов объекта для тестирования.